summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authoromnom62 <75066712+omnom62@users.noreply.github.com>2026-09-28 20:35:56 +1000
committerGitHub <noreply@github.com>2026-09-28 11:35:56 +0100
commit35f2016a0d5c40c3e4b6374ebe459571be544b1f (patch)
treee0dbefaf076da14bae65c62aa25b394e19a7801a
parent099b7477d5014499aa9aca41f8854be89356cb4e (diff)
downloadrest.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.yml3
-rw-r--r--docs/vyos.rest.vyos_ospfv3_module.rst16
-rw-r--r--plugins/modules/vyos_ospfv3.py367
-rw-r--r--tests/unit/fixtures/ospfv3_running.json11
-rw-r--r--tests/unit/modules/test_vyos_ospfv3.py260
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__":