diff options
| author | omnom62 <75066712+omnom62@users.noreply.github.com> | 2026-09-28 20:35:56 +1000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-28 11:35:56 +0100 |
| commit | 35f2016a0d5c40c3e4b6374ebe459571be544b1f (patch) | |
| tree | e0dbefaf076da14bae65c62aa25b394e19a7801a | |
| parent | 099b7477d5014499aa9aca41f8854be89356cb4e (diff) | |
| download | rest.vyos-35f2016a0d5c40c3e4b6374ebe459571be544b1f.tar.gz rest.vyos-35f2016a0d5c40c3e4b6374ebe459571be544b1f.zip | |
T8989: ospfv3 dict op refactor (#32)
* T8989: vyos_ospfv3 dict_op refactor
* T8989: vyos_ospfv3 dict_op refactor
* T8989: Update ospfv3 area interface syntax clarification
Clarify the usage of the ospfv3 area interface syntax and its relation to the state parameter.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* T8989: Update test description for OSPFv3 configuration
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* T8989: ospfv3 AI comment fixed
* T8989: ospfv3 AI comment fixed
---------
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Daniil Baturin <daniil@vyos.io>
| -rw-r--r-- | changelogs/fragments/t8989_ospfv3_dict_op.yml | 3 | ||||
| -rw-r--r-- | docs/vyos.rest.vyos_ospfv3_module.rst | 16 | ||||
| -rw-r--r-- | plugins/modules/vyos_ospfv3.py | 367 | ||||
| -rw-r--r-- | tests/unit/fixtures/ospfv3_running.json | 11 | ||||
| -rw-r--r-- | tests/unit/modules/test_vyos_ospfv3.py | 260 |
5 files changed, 400 insertions, 257 deletions
diff --git a/changelogs/fragments/t8989_ospfv3_dict_op.yml b/changelogs/fragments/t8989_ospfv3_dict_op.yml new file mode 100644 index 0000000..b151ac3 --- /dev/null +++ b/changelogs/fragments/t8989_ospfv3_dict_op.yml @@ -0,0 +1,3 @@ +--- +minor_changes: + - vyos_ospfv3 - Refactor module to dict_op style. diff --git a/docs/vyos.rest.vyos_ospfv3_module.rst b/docs/vyos.rest.vyos_ospfv3_module.rst index 18bb48e..2b63cf5 100644 --- a/docs/vyos.rest.vyos_ospfv3_module.rst +++ b/docs/vyos.rest.vyos_ospfv3_module.rst @@ -19,6 +19,9 @@ Synopsis -------- - Manages OSPFv3 configuration on VyOS devices via the REST API. - Uses REST API (``connection=httpapi``) instead of CLI. +- Scope matches the current vyos.vyos.vyos_ospfv3 (CLI collection) module, confirmed against VyOS's official documentation (1.4+/1.5 LTS/rolling). +- ``areas.interface`` (an area-to-interface assignment list) exists in the CLI module's argspec but was deliberately NOT carried over here -- confirmed via VyOS's official docs across multiple versions that ``set protocols ospfv3 area <id> interface <name>`` is the superseded, 1.3-era syntax. The current mechanism, ``set protocols ospfv3 interface <name> area <id>``, is a per-interface setting and is modeled in :ref:`vyos.rest.vyos_ospf_interfaces <vyos.rest.vyos_ospf_interfaces_module>`'s ``area`` field instead (and is not affected by this module's ``state=replaced``). +- ``distance`` and ``graceful-restart`` are real, confirmed OSPFv3 features not modeled here, matching a genuine gap in the CLI module's own scope rather than an oversight. @@ -133,7 +136,7 @@ Parameters <td> </td> <td> - <div>Summarize routes matching prefix.</div> + <div>Summarize routes matching prefix (border routers only).</div> </td> </tr> <tr> @@ -278,6 +281,7 @@ Parameters <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a> <div style="font-size: small"> <span style="color: purple">string</span> + / <span style="color: red">required</span> </div> </td> <td> @@ -332,6 +336,16 @@ Notes - ``ansible_network_os`` must be set to ``vyos.rest.vyos``. +See Also +-------- + +.. seealso:: + + :ref:`vyos.vyos.vyos_ospfv3_module` + The official documentation on the **vyos.vyos.vyos_ospfv3** module. + :ref:`vyos.rest.vyos_ospf_interfaces_module` + The official documentation on the **vyos.rest.vyos_ospf_interfaces** module. + Examples -------- diff --git a/plugins/modules/vyos_ospfv3.py b/plugins/modules/vyos_ospfv3.py index fcf404e..5923a61 100644 --- a/plugins/modules/vyos_ospfv3.py +++ b/plugins/modules/vyos_ospfv3.py @@ -1,6 +1,7 @@ #!/usr/bin/python # -*- coding: utf-8 -*- -# GNU General Public License v3.0+ +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + from __future__ import absolute_import, division, print_function @@ -13,6 +14,22 @@ short_description: Manage OSPFv3 configuration on VyOS devices using REST API description: - Manages OSPFv3 configuration on VyOS devices via the REST API. - Uses REST API (C(connection=httpapi)) instead of CLI. + - >- + Scope matches the current vyos.vyos.vyos_ospfv3 (CLI collection) module, + confirmed against VyOS's official documentation (1.4+/1.5 LTS/rolling). + - >- + C(areas.interface) (an area-to-interface assignment list) exists in the + CLI module's argspec but was deliberately NOT carried over here -- + confirmed via VyOS's official docs across multiple versions that + C(set protocols ospfv3 area <id> interface <name>) is the superseded, + 1.3-era syntax. The current mechanism, C(set protocols ospfv3 interface + <name> area <id>), is a per-interface setting and is modeled in + M(vyos.rest.vyos_ospf_interfaces)'s C(area) field instead (and is not + affected by this module's C(state=replaced)). + - >- + C(distance) and C(graceful-restart) are real, confirmed OSPFv3 features + not modeled here, matching a genuine gap in the CLI module's own scope + rather than an oversight. version_added: "1.0.0" author: - VyOS Community (@vyos) @@ -37,7 +54,7 @@ options: description: Name of import-list. type: str range: - description: Summarize routes matching prefix. + description: Summarize routes matching prefix (border routers only). type: list elements: dict suboptions: @@ -65,6 +82,7 @@ options: suboptions: route_type: description: Protocol to redistribute. + required: true type: str choices: [bgp, connected, kernel, ripng, static] route_map: @@ -83,6 +101,9 @@ options: notes: - Requires C(ansible_connection=httpapi) with the VyOS httpapi plugin. - C(ansible_network_os) must be set to C(vyos.rest.vyos). +seealso: + - module: vyos.vyos.vyos_ospfv3 + - module: vyos.rest.vyos_ospf_interfaces """ EXAMPLES = r""" @@ -139,184 +160,201 @@ response: """ from ansible.module_utils.basic import AnsibleModule -from ansible_collections.vyos.rest.plugins.module_utils.vyos import VyOSModule +from ansible_collections.vyos.rest.plugins.module_utils.vyos import ( + VyOSModule, + autoclean, + cast_by_spec, + dict_op, + from_device, + to_tag_dict, +) _BASE = ["protocols", "ospfv3"] +def _kebab_fields(d): + """autoclean, then kebab-convert the resulting keys. + + Needed because dict_op requires have's keys to already be genuine + device kebab-case -- it only normalizes underscores to dashes for + its own lookup index, but uses have's key verbatim for the output + path. autoclean deliberately leaves keys exactly as given (dict_op + is meant to convert during its own want-vs-have comparison), which + only works when have comes straight from the device. Here, have is + reconstructed by round-tripping through this module's own entry- + transforms, so any field passed through unconverted would stay + snake_case and dict_op would have no way to recover the real + device key -- confirmed as a real bug during vyos_ospfv2's build. + Safe here since every call site is a leaf-level dict of schema + field names, never an opaque tag-node value like an area ID used + as a dict key. + """ + cleaned = autoclean(d) + return {k.replace("_", "-"): v for k, v in cleaned.items()} + + +def _derive_key_field(options_spec): + required = [k for k, spec in options_spec.items() if spec.get("required")] + if len(required) != 1: + raise ValueError( + "expected exactly one required suboption to serve as the key field, " + "found: {0}".format(required), + ) + return required[0] + + +def _keyed_list_to_device(items, key_field, entry_transform=None): + entry_transform = entry_transform or _kebab_fields + result = {} + for item in items or []: + if not item.get(key_field): + continue + rest = {k: v for k, v in item.items() if k != key_field} + result[str(item[key_field])] = entry_transform(rest) + return result + + +def _keyed_list_from_device(raw, key_field, entry_transform=None): + entry_transform = entry_transform or from_device + return [ + {key_field: key, **entry_transform(data or {})} + for key, data in sorted(to_tag_dict(raw).items()) + ] + + +# --------------------------------------------------------------------------- +# range -- confirmed against CLI: advertise/not_advertise are presence- +# only opposite flags (not both meaningful at once, though the argspec +# doesn't enforce mutual exclusivity -- matching the CLI's own scope). +# Fully generic once keyed by address. +# --------------------------------------------------------------------------- + +_RANGE_KEY = "address" + + +def _area_entry_to_device(rest): + exclude = {"range"} + device = _kebab_fields({k: v for k, v in rest.items() if k not in exclude}) + ranges = rest.get("range") or [] + if ranges: + device["range"] = _keyed_list_to_device(ranges, _RANGE_KEY) + return device + + +def _area_entry_from_device(data): + exclude = {"range"} + entry = from_device({k: v for k, v in data.items() if k not in exclude}) + range_raw = data.get("range") + if range_raw: + entry["range"] = _keyed_list_from_device(range_raw, _RANGE_KEY) + return entry + + +def _want_to_device(config): + config = config or {} + device = {} + + areas = config.get("areas") or [] + if areas: + device["area"] = _keyed_list_to_device(areas, _AREA_KEY, _area_entry_to_device) + + params_device = _kebab_fields(config.get("parameters") or {}) + if params_device: + device["parameters"] = params_device + + redist = config.get("redistribute") or [] + if redist: + device["redistribute"] = _keyed_list_to_device(redist, _REDISTRIBUTE_KEY) + + return device + + def get_running_config(vyos): - raw = vyos.get_config(_BASE) + """VyOS's REST API collapses a single-child tag node to a plain + string (or a list for multiple) -- confirmed as a real failure + mode during vyos_ospf_interfaces's build (an unguarded response + iterated character-by-character). Normalizing through to_tag_dict + unconditionally means callers always receive a genuine dict. + """ + return to_tag_dict(vyos.get_config(_BASE) or {}) + + +def _device_to_argspec(raw): if not raw or not isinstance(raw, dict): return {} - return _parse_ospfv3(raw) + entry = {} + area_raw = raw.get("area") + if area_raw: + areas = _keyed_list_from_device(area_raw, _AREA_KEY, _area_entry_from_device) + if areas: + entry["areas"] = areas -def _parse_ospfv3(raw): - result = {} + params_raw = raw.get("parameters") + if params_raw: + entry["parameters"] = from_device(params_raw) - # parameters - params = raw.get("parameters", {}) - if params: - result["parameters"] = {} - if "router-id" in params: - result["parameters"]["router_id"] = params["router-id"] - - # redistribute - redist_raw = raw.get("redistribute", {}) - if redist_raw and isinstance(redist_raw, dict): - redist = [] - for route_type, data in sorted(redist_raw.items()): - entry = {"route_type": route_type} - if isinstance(data, dict) and data.get("route-map"): - entry["route_map"] = data["route-map"] - redist.append(entry) - if redist: - result["redistribute"] = redist - - # areas - area_raw = raw.get("area", {}) - if area_raw and isinstance(area_raw, dict): - areas = [] - for area_id, area_data in sorted(area_raw.items()): - area = {"area_id": area_id} - area_data = area_data or {} - if area_data.get("export-list"): - area["export_list"] = area_data["export-list"] - if area_data.get("import-list"): - area["import_list"] = area_data["import-list"] - range_raw = area_data.get("range", {}) - if range_raw and isinstance(range_raw, dict): - ranges = [] - for prefix, rdata in sorted(range_raw.items()): - r = {"address": prefix} - rdata = rdata or {} - if "advertise" in rdata: - r["advertise"] = True - if "not-advertise" in rdata: - r["not_advertise"] = True - ranges.append(r) - if ranges: - area["range"] = ranges - areas.append(area) - if areas: - result["areas"] = areas + redist_raw = raw.get("redistribute") + if redist_raw: + entry["redistribute"] = _keyed_list_from_device(redist_raw, _REDISTRIBUTE_KEY) - return result + return entry -def build_commands(config, have, state): - cmds = [] +def build_commands(config, raw_have, state): + raw_have = raw_have or {} if state == "deleted": - if have: - cmds.append(("delete", _BASE)) - return cmds + return [("delete", _BASE)] if raw_have else [] + + want = _want_to_device(config) + norm_have = _want_to_device(_device_to_argspec(raw_have)) + commands = [] if state == "replaced": - # Build what we would set from scratch and compare to have - would_set = build_commands(config, {}, "merged") - have_set = build_commands(have, {}, "merged") - if would_set == have_set: - return [] - if have: - cmds.append(("delete", _BASE)) - have = {} - - # parameters - want_params = (config or {}).get("parameters") or {} - have_params = have.get("parameters") or {} - if want_params.get("router_id") and want_params["router_id"] != have_params.get("router_id"): - cmds.append(("set", _BASE + ["parameters", "router-id", want_params["router_id"]])) - - # redistribute - want_redist = {r["route_type"]: r for r in ((config or {}).get("redistribute") or [])} - have_redist = {r["route_type"]: r for r in (have.get("redistribute") or [])} - - for rt in set(have_redist) - set(want_redist): - if state == "merged": - pass # merged doesn't remove - for rt, entry in want_redist.items(): - if rt not in have_redist: - cmds.append(("set", _BASE + ["redistribute", rt])) - if entry.get("route_map"): - have_rm = have_redist.get(rt, {}).get("route_map") - if entry["route_map"] != have_rm: - cmds.append(("set", _BASE + ["redistribute", rt, "route-map", entry["route_map"]])) - - # areas - want_areas = {a["area_id"]: a for a in ((config or {}).get("areas") or [])} - have_areas = {a["area_id"]: a for a in (have.get("areas") or [])} - - for area_id, want_area in want_areas.items(): - have_area = have_areas.get(area_id, {}) - abase = _BASE + ["area", area_id] - - if want_area.get("export_list") and want_area["export_list"] != have_area.get( - "export_list", - ): - cmds.append(("set", abase + ["export-list", want_area["export_list"]])) - if want_area.get("import_list") and want_area["import_list"] != have_area.get( - "import_list", - ): - cmds.append(("set", abase + ["import-list", want_area["import_list"]])) - - want_ranges = {r["address"]: r for r in (want_area.get("range") or [])} - have_ranges = {r["address"]: r for r in (have_area.get("range") or [])} - - for addr in want_ranges: - if addr not in have_ranges: - cmds.append(("set", abase + ["range", addr])) - r = want_ranges[addr] - if r.get("not_advertise"): - cmds.append(("set", abase + ["range", addr, "not-advertise"])) - elif r.get("advertise"): - cmds.append(("set", abase + ["range", addr, "advertise"])) - - return cmds + commands += dict_op(want, norm_have, _BASE, op="purge") + commands += dict_op(want, norm_have, _BASE, op="set") + return commands -ARGUMENT_SPEC = dict( - config=dict( - type="dict", - options=dict( - areas=dict( - type="list", - elements="dict", - options=dict( - area_id=dict(type="str", required=True), - export_list=dict(type="str"), - import_list=dict(type="str"), - range=dict( - type="list", - elements="dict", - options=dict( - address=dict(type="str", required=True), - advertise=dict(type="bool"), - not_advertise=dict(type="bool"), - ), - ), - ), - ), - parameters=dict( - type="dict", - options=dict( - router_id=dict(type="str"), - ), - ), - redistribute=dict( - type="list", - elements="dict", - options=dict( - route_type=dict( - type="str", - choices=["bgp", "connected", "kernel", "ripng", "static"], - ), - route_map=dict(type="str"), - ), - ), - ), +_RANGE_OPTIONS = dict( + address=dict(type="str", required=True), + advertise=dict(type="bool"), + not_advertise=dict(type="bool"), +) + +_AREA_OPTIONS = dict( + area_id=dict(type="str", required=True), + export_list=dict(type="str"), + import_list=dict(type="str"), + range=dict(type="list", elements="dict", options=_RANGE_OPTIONS), +) + +_PARAMETERS_OPTIONS = dict( + router_id=dict(type="str"), +) + +_REDISTRIBUTE_OPTIONS = dict( + route_type=dict( + type="str", + required=True, + choices=["bgp", "connected", "kernel", "ripng", "static"], ), + route_map=dict(type="str"), +) + +_AREA_KEY = _derive_key_field(_AREA_OPTIONS) +_REDISTRIBUTE_KEY = _derive_key_field(_REDISTRIBUTE_OPTIONS) + +_CONFIG_OPTIONS = dict( + areas=dict(type="list", elements="dict", options=_AREA_OPTIONS), + parameters=dict(type="dict", options=_PARAMETERS_OPTIONS), + redistribute=dict(type="list", elements="dict", options=_REDISTRIBUTE_OPTIONS), +) + +ARGUMENT_SPEC = dict( + config=dict(type="dict", options=_CONFIG_OPTIONS), state=dict( default="merged", choices=["merged", "replaced", "deleted", "gathered"], @@ -331,12 +369,14 @@ def main(): state = module.params["state"] config = module.params.get("config") or {} - have = get_running_config(vyos) + raw_have = get_running_config(vyos) + have = _device_to_argspec(raw_have) + cast_by_spec(have, _CONFIG_OPTIONS) if state == "gathered": module.exit_json(changed=False, gathered=have) - commands = build_commands(config, have, state) + commands = build_commands(config, raw_have, state) if module.check_mode: module.exit_json(changed=bool(commands), commands=commands, before=have) @@ -344,10 +384,13 @@ def main(): if commands: response = vyos.apply_commands(commands) saved = vyos.save_config() + after_raw = get_running_config(vyos) + after = _device_to_argspec(after_raw) + cast_by_spec(after, _CONFIG_OPTIONS) module.exit_json( changed=True, before=have, - after=get_running_config(vyos), + after=after, commands=commands, saved=saved, response=response, diff --git a/tests/unit/fixtures/ospfv3_running.json b/tests/unit/fixtures/ospfv3_running.json index 44a1bcd..6588a1d 100644 --- a/tests/unit/fixtures/ospfv3_running.json +++ b/tests/unit/fixtures/ospfv3_running.json @@ -1,6 +1,4 @@ { - "parameters": { "router-id": "192.0.2.10" }, - "redistribute": { "bgp": {}, "connected": { "route-map": "RM1" } }, "area": { "2": { "export-list": "export1", @@ -11,9 +9,12 @@ } }, "3": { - "range": { - "2001:db40::/32": {} - } + "range": { "2001:db40::/32": { "advertise": {} } } } + }, + "parameters": { "router-id": "192.0.2.10" }, + "redistribute": { + "bgp": { "route-map": "redist-map" }, + "static": {} } } diff --git a/tests/unit/modules/test_vyos_ospfv3.py b/tests/unit/modules/test_vyos_ospfv3.py index a84041b..a19b5fa 100644 --- a/tests/unit/modules/test_vyos_ospfv3.py +++ b/tests/unit/modules/test_vyos_ospfv3.py @@ -4,136 +4,218 @@ from __future__ import absolute_import, division, print_function __metaclass__ = type -import json -import os import unittest from unittest.mock import MagicMock from ansible_collections.vyos.rest.plugins.modules.vyos_ospfv3 import ( + _CONFIG_OPTIONS, + ARGUMENT_SPEC, + _area_entry_from_device, + _area_entry_to_device, + _derive_key_field, + _device_to_argspec, + _kebab_fields, build_commands, + cast_by_spec, get_running_config, ) +from .base import load_fixture -def load_fixture(filename): - fixtures_dir = os.path.join(os.path.dirname(__file__), "..", "fixtures") - with open(os.path.join(fixtures_dir, filename)) as f: - return json.load(f) + +_BASE = ["protocols", "ospfv3"] class VyOSModuleTestCase(unittest.TestCase): def setUp(self): self.mock_vyos = MagicMock() - self.mock_vyos.get_config = MagicMock(return_value={}) - - def set_running_config(self, data): - self.mock_vyos.get_config.return_value = data - - -class TestVyOSOspfv3Parse(VyOSModuleTestCase): - - def setUp(self): - super().setUp() self.fixture = load_fixture("ospfv3_running.json") + self.mock_vyos.get_config = MagicMock(return_value=self.fixture) - def test_parses_parameters(self): - self.set_running_config(self.fixture) - result = get_running_config(self.mock_vyos) - self.assertEqual(result["parameters"]["router_id"], "192.0.2.10") + def gather(self): + have = _device_to_argspec(self.fixture) + cast_by_spec(have, _CONFIG_OPTIONS) + return have - def test_parses_redistribute(self): - self.set_running_config(self.fixture) + +class TestGetRunningConfig(VyOSModuleTestCase): + def test_returns_config_directly(self): result = get_running_config(self.mock_vyos) - route_types = [r["route_type"] for r in result["redistribute"]] - self.assertIn("bgp", route_types) - self.assertIn("connected", route_types) - connected = next(r for r in result["redistribute"] if r["route_type"] == "connected") - self.assertEqual(connected["route_map"], "RM1") - - def test_parses_areas(self): - self.set_running_config(self.fixture) + self.assertIn("area", result) + + def test_empty_config(self): + self.mock_vyos.get_config = MagicMock(return_value=None) + self.assertEqual(get_running_config(self.mock_vyos), {}) + + def test_collapsed_response_normalized(self): + """Regression coverage for the confirmed failure mode from + vyos_ospf_interfaces's build: an unguarded collapsed response + (VyOS's single-value tag-node quirk) would otherwise be + iterated character-by-character downstream.""" + self.mock_vyos.get_config = MagicMock(return_value="eth1") result = get_running_config(self.mock_vyos) - self.assertEqual(len(result["areas"]), 2) - area2 = next(a for a in result["areas"] if a["area_id"] == "2") - self.assertEqual(area2["export_list"], "export1") - self.assertEqual(area2["import_list"], "import1") - self.assertEqual(len(area2["range"]), 2) - not_adv = next(r for r in area2["range"] if r["address"] == "2001:db20::/32") - self.assertTrue(not_adv["not_advertise"]) + self.assertEqual(result, {"eth1": {}}) - def test_empty_config_returns_empty(self): - self.set_running_config({}) - result = get_running_config(self.mock_vyos) - self.assertEqual(result, {}) +class TestDeriveKeyField(unittest.TestCase): + def test_derives_area_id(self): + area_opts = ARGUMENT_SPEC["config"]["options"]["areas"]["options"] + self.assertEqual(_derive_key_field(area_opts), "area_id") -class TestVyOSOspfv3BuildCommands(unittest.TestCase): + def test_raises_if_none_required(self): + with self.assertRaises(ValueError): + _derive_key_field({"a": {"type": "str"}}) - def test_deleted_with_have(self): - have = {"parameters": {"router_id": "192.0.2.10"}} - cmds = build_commands({}, have, "deleted") - self.assertEqual(cmds, [("delete", ["protocols", "ospfv3"])]) - def test_deleted_without_have(self): - cmds = build_commands({}, {}, "deleted") - self.assertEqual(cmds, []) +class TestKebabFields(unittest.TestCase): + def test_converts_multiword_keys(self): + result = _kebab_fields({"router_id": "1.1.1.1"}) + self.assertEqual(result, {"router-id": "1.1.1.1"}) - def test_merged_parameters(self): - config = {"parameters": {"router_id": "192.0.2.10"}} - cmds = build_commands(config, {}, "merged") - self.assertIn( - ("set", ["protocols", "ospfv3", "parameters", "router-id", "192.0.2.10"]), - cmds, + +class TestAreaEntry(unittest.TestCase): + def test_export_import_list_to_device(self): + result = _area_entry_to_device({"export_list": "el1", "import_list": "il1"}) + self.assertEqual(result, {"export-list": "el1", "import-list": "il1"}) + + def test_range_to_device(self): + result = _area_entry_to_device( + {"range": [{"address": "2001:db1::/32", "not_advertise": True}]}, ) + self.assertEqual(result, {"range": {"2001:db1::/32": {"not-advertise": {}}}}) - def test_merged_redistribute(self): - config = {"redistribute": [{"route_type": "bgp"}]} - cmds = build_commands(config, {}, "merged") - self.assertIn( - ("set", ["protocols", "ospfv3", "redistribute", "bgp"]), - cmds, + def test_from_device(self): + entry = _area_entry_from_device( + {"export-list": "el1", "range": {"2001:db1::/32": {"advertise": {}}}}, ) + self.assertEqual(entry["export_list"], "el1") + self.assertEqual(entry["range"][0]["address"], "2001:db1::/32") + self.assertTrue(entry["range"][0]["advertise"]) + + def test_empty(self): + self.assertEqual(_area_entry_to_device({}), {}) + self.assertEqual(_area_entry_from_device({}), {}) - def test_merged_idempotent(self): - config = {"parameters": {"router_id": "192.0.2.10"}} - have = {"parameters": {"router_id": "192.0.2.10"}} - cmds = build_commands(config, have, "merged") - self.assertEqual(cmds, []) - def test_merged_area_range(self): +class TestDeviceToArgspecFixture(VyOSModuleTestCase): + def test_areas_parsed(self): + have = self.gather() + area_ids = {a["area_id"] for a in have["areas"]} + self.assertEqual(area_ids, {"2", "3"}) + + def test_area_export_import_list_parsed(self): + have = self.gather() + area2 = next(a for a in have["areas"] if a["area_id"] == "2") + self.assertEqual(area2["export_list"], "export1") + self.assertEqual(area2["import_list"], "import1") + + def test_range_advertise_not_advertise_parsed(self): + have = self.gather() + area2 = next(a for a in have["areas"] if a["area_id"] == "2") + r1 = next(r for r in area2["range"] if r["address"] == "2001:db10::/32") + r2 = next(r for r in area2["range"] if r["address"] == "2001:db20::/32") + self.assertNotIn("advertise", r1) + self.assertNotIn("not_advertise", r1) + self.assertTrue(r2["not_advertise"]) + + area3 = next(a for a in have["areas"] if a["area_id"] == "3") + self.assertTrue(area3["range"][0]["advertise"]) + + def test_parameters_parsed(self): + have = self.gather() + self.assertEqual(have["parameters"]["router_id"], "192.0.2.10") + + def test_redistribute_parsed(self): + have = self.gather() + route_types = {r["route_type"] for r in have["redistribute"]} + self.assertEqual(route_types, {"bgp", "static"}) + bgp = next(r for r in have["redistribute"] if r["route_type"] == "bgp") + self.assertEqual(bgp["route_map"], "redist-map") + + def test_empty(self): + self.assertEqual(_device_to_argspec({}), {}) + self.assertEqual(_device_to_argspec(None), {}) + + +class TestBuildCommands(VyOSModuleTestCase): + def test_merged_idempotent_against_own_fixture(self): + have = self.gather() + self.assertEqual(build_commands(have, self.fixture, "merged"), []) + + def test_replaced_idempotent_against_own_fixture(self): + have = self.gather() + self.assertEqual(build_commands(have, self.fixture, "replaced"), []) + + def test_changing_existing_range_flag_via_replaced(self): + """Primary confirmed bug from the original hand-rolled + implementation: range advertise/not_advertise were only ever + set on initial range creation (an "if addr not in have_ranges" + guard) -- changing an existing range's flag generated no + command under either state. dict_op's purge (via "replaced") + correctly handles this generically.""" + raw_have = {"area": {"2": {"range": {"2001:db20::/32": {"not-advertise": {}}}}}} config = { - "areas": [{"area_id": "2", "range": [{"address": "2001:db10::/32"}]}], + "areas": [ + { + "area_id": "2", + "range": [ + {"address": "2001:db20::/32", "advertise": True}, + ], + }, + ], } - cmds = build_commands(config, {}, "merged") + cmds = build_commands(config, raw_have, "replaced") self.assertIn( - ("set", ["protocols", "ospfv3", "area", "2", "range", "2001:db10::/32"]), + ("delete", _BASE + ["area", "2", "range", "2001:db20::/32", "not-advertise"]), cmds, ) - - def test_replaced_idempotent(self): - config = {"parameters": {"router_id": "192.0.2.10"}} - have = {"parameters": {"router_id": "192.0.2.10"}} - cmds = build_commands(config, have, "replaced") - self.assertEqual(cmds, []) - - def test_replaced_rebuilds_on_change(self): - config = {"parameters": {"router_id": "192.0.2.11"}} - have = {"parameters": {"router_id": "192.0.2.10"}} - cmds = build_commands(config, have, "replaced") - self.assertEqual(cmds[0], ("delete", ["protocols", "ospfv3"])) self.assertIn( - ("set", ["protocols", "ospfv3", "parameters", "router-id", "192.0.2.11"]), + ("set", _BASE + ["area", "2", "range", "2001:db20::/32", "advertise"]), cmds, ) - def test_merged_area_export_list(self): - config = {"areas": [{"area_id": "2", "export_list": "export1"}]} + def test_merged_does_not_clear_conflicting_flag(self): + """merged never runs a purge pass, by design (established, + consistent semantic throughout this collection) -- confirms + it correctly does NOT clear not-advertise even when advertise + is set instead, unlike replaced above.""" + raw_have = {"area": {"2": {"range": {"2001:db20::/32": {"not-advertise": {}}}}}} + config = { + "areas": [ + { + "area_id": "2", + "range": [ + {"address": "2001:db20::/32", "advertise": True}, + ], + }, + ], + } + cmds = build_commands(config, raw_have, "merged") + self.assertFalse(any(c[0] == "delete" and "not-advertise" in str(c) for c in cmds)) + + def test_replaced_is_whole_resource_not_scoped(self): + """Confirmed matching this module's own documented contract + ("replaced replaces the module-managed OSPFv3 configuration") and + vyos_ospfv2's own established precedent -- unlike + vyos_static_routes/vyos_route_maps, which scope replaced per + named item.""" + raw_have = {"parameters": {"router-id": "1.1.1.1"}, "redistribute": {"bgp": {}}} + config = {"parameters": {"router_id": "1.1.1.1"}} + cmds = build_commands(config, raw_have, "replaced") + self.assertIn(("delete", _BASE + ["redistribute"]), cmds) + + def test_deleted_with_have(self): + cmds = build_commands({}, {"parameters": {"router-id": "1.1.1.1"}}, "deleted") + self.assertEqual(cmds, [("delete", _BASE)]) + + def test_deleted_no_have_is_noop(self): + self.assertEqual(build_commands({}, {}, "deleted"), []) + + def test_merged_new_area(self): + config = {"areas": [{"area_id": "5", "export_list": "el5"}]} cmds = build_commands(config, {}, "merged") - self.assertIn( - ("set", ["protocols", "ospfv3", "area", "2", "export-list", "export1"]), - cmds, - ) + self.assertIn(("set", _BASE + ["area", "5", "export-list", "el5"]), cmds) if __name__ == "__main__": |
