diff options
| -rw-r--r-- | changelogs/fragments/t8989_lldp_interfaces.yml | 3 | ||||
| -rw-r--r-- | docs/vyos.rest.vyos_lldp_interfaces_module.rst | 51 | ||||
| -rw-r--r-- | plugins/modules/vyos_lldp_interfaces.py | 276 | ||||
| -rw-r--r-- | tests/unit/fixtures/lldp_interfaces_running.json | 26 | ||||
| -rw-r--r-- | tests/unit/modules/test_vyos_lldp_interfaces.py | 357 |
5 files changed, 250 insertions, 463 deletions
diff --git a/changelogs/fragments/t8989_lldp_interfaces.yml b/changelogs/fragments/t8989_lldp_interfaces.yml new file mode 100644 index 0000000..59abb1e --- /dev/null +++ b/changelogs/fragments/t8989_lldp_interfaces.yml @@ -0,0 +1,3 @@ +--- +minor_changes: + - vyos_lldp_interfaces - Refactor lldp_interface module to comply with dict_op paradigm. diff --git a/docs/vyos.rest.vyos_lldp_interfaces_module.rst b/docs/vyos.rest.vyos_lldp_interfaces_module.rst index c294c79..d029429 100644 --- a/docs/vyos.rest.vyos_lldp_interfaces_module.rst +++ b/docs/vyos.rest.vyos_lldp_interfaces_module.rst @@ -202,7 +202,7 @@ Parameters </td> <td> <div>LLDP administrative mode for this interface.</div> - <div><code>rx-tx</code> sends and receives LLDP frames (default).</div> + <div><code>rx-tx</code> sends and receives LLDP frames (device default).</div> <div><code>disable</code> disables LLDP on this interface.</div> <div><code>rx</code> receives only.</div> <div><code>tx</code> transmits only.</div> @@ -229,21 +229,6 @@ Parameters <tr> <td colspan="4"> <div class="ansibleOptionAnchor" id="parameter-"></div> - <b>running_config</b> - <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a> - <div style="font-size: small"> - <span style="color: purple">string</span> - </div> - </td> - <td> - </td> - <td> - <div>Used only with state <code>parsed</code>.</div> - </td> - </tr> - <tr> - <td colspan="4"> - <div class="ansibleOptionAnchor" id="parameter-"></div> <b>state</b> <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a> <div style="font-size: small"> @@ -257,8 +242,6 @@ Parameters <li>overridden</li> <li>deleted</li> <li>gathered</li> - <li>rendered</li> - <li>parsed</li> </ul> </td> <td> @@ -267,8 +250,6 @@ Parameters <div><code>overridden</code> - Replace config for all LLDP interfaces.</div> <div><code>deleted</code> - Remove listed or all LLDP interface config.</div> <div><code>gathered</code> - Read LLDP interface config from device without changes.</div> - <div><code>rendered</code> - Return commands for provided config without connecting.</div> - <div><code>parsed</code> - Parse running_config into structured data.</div> </td> </tr> </table> @@ -399,36 +380,6 @@ Common return values are documented `here <https://docs.ansible.com/ansible/late <tr> <td colspan="1"> <div class="ansibleOptionAnchor" id="return-"></div> - <b>parsed</b> - <a class="ansibleOptionLink" href="#return-" title="Permalink to this return value"></a> - <div style="font-size: small"> - <span style="color: purple">list</span> - </div> - </td> - <td>when state is parsed</td> - <td> - <div>Structured data parsed from running_config (state=parsed).</div> - <br/> - </td> - </tr> - <tr> - <td colspan="1"> - <div class="ansibleOptionAnchor" id="return-"></div> - <b>rendered</b> - <a class="ansibleOptionLink" href="#return-" title="Permalink to this return value"></a> - <div style="font-size: small"> - <span style="color: purple">list</span> - </div> - </td> - <td>when state is rendered</td> - <td> - <div>Commands for provided config (state=rendered).</div> - <br/> - </td> - </tr> - <tr> - <td colspan="1"> - <div class="ansibleOptionAnchor" id="return-"></div> <b>response</b> <a class="ansibleOptionLink" href="#return-" title="Permalink to this return value"></a> <div style="font-size: small"> diff --git a/plugins/modules/vyos_lldp_interfaces.py b/plugins/modules/vyos_lldp_interfaces.py index aa80b2f..ddc11e2 100644 --- a/plugins/modules/vyos_lldp_interfaces.py +++ b/plugins/modules/vyos_lldp_interfaces.py @@ -31,7 +31,7 @@ options: mode: description: - LLDP administrative mode for this interface. - - C(rx-tx) sends and receives LLDP frames (default). + - C(rx-tx) sends and receives LLDP frames (device default). - C(disable) disables LLDP on this interface. - C(rx) receives only. - C(tx) transmits only. @@ -63,9 +63,6 @@ options: description: Coordinate datum type. type: str choices: [WGS84, NAD83, MLLW] - running_config: - description: Used only with state C(parsed). - type: str state: description: - C(merged) - Merge config with existing LLDP interface settings. @@ -73,10 +70,8 @@ options: - C(overridden) - Replace config for all LLDP interfaces. - C(deleted) - Remove listed or all LLDP interface config. - C(gathered) - Read LLDP interface config from device without changes. - - C(rendered) - Return commands for provided config without connecting. - - C(parsed) - Parse running_config into structured data. type: str - choices: [merged, replaced, overridden, deleted, gathered, rendered, parsed] + choices: [merged, replaced, overridden, deleted, gathered] default: merged notes: - Targets VyOS 1.5+ exclusively. The C(mode) parameter replaces the C(enable) @@ -129,14 +124,6 @@ gathered: description: Current LLDP interface configuration as structured data. returned: when state is gathered type: list -rendered: - description: Commands for provided config (state=rendered). - returned: when state is rendered - type: list -parsed: - description: Structured data parsed from running_config (state=parsed). - returned: when state is parsed - type: list saved: description: Whether the config was saved after changes. returned: when changes are applied @@ -148,7 +135,14 @@ 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, + scope_to_spec, +) _BASE = ["service", "lldp", "interface"] @@ -158,201 +152,161 @@ def _iface_base(name): return _BASE + [name] +def _to_device_recursive(d): + """autoclean, then recursively kebab-convert every key at every + level. Needed because dict_op requires have's keys to already be + genuine device kebab-case; autoclean deliberately doesn't convert + keys itself (by design, since it's meant to be paired with dict_op + doing the conversion when comparing against a raw device have -- + but want_device here needs to already match the shape have_device + is built in). Safe to recurse arbitrarily deep here since every + level (location, coordinate_based) is plain scalar fields, no + opaque tag-node values or presence-only nested structures like + vyos_lag_interfaces' member/arp-monitor.target. + """ + cleaned = autoclean(d) + if not isinstance(cleaned, dict): + return cleaned + return {k.replace("_", "-"): _to_device_recursive(v) for k, v in cleaned.items()} + + +def _entry_to_device(rest): + """Confirmed device path (service lldp interface <name>) is + exclusively owned by this module -- no sibling module shares it, + unlike interfaces/l3_interfaces/lag_interfaces' shared + "interfaces <type> <name>" tree. scope_to_spec is still used here + for consistency and defense against any field this module's own + argspec doesn't declare. + """ + scoped = scope_to_spec(rest, _ENTRY_OPTIONS, exclude=("name",)) + return _to_device_recursive(scoped) + + +def _entry_from_device(data): + """from_device already recurses into nested dicts and converts + keys at every level -- no hand-rolling needed for a structure + this simple (plain scalar fields throughout, no keyed-list/tag- + node complexity). scope_to_spec still applied for the same + defensive reason as the to_device direction. + """ + scoped = scope_to_spec(data, _ENTRY_OPTIONS, exclude=("name",)) + return from_device(scoped) + + def get_running_config(vyos): - raw = vyos.get_config(["service", "lldp"]) - if not raw or not isinstance(raw, dict): - return [] + raw = vyos.get_config(["service", "lldp"]) or {} + if not isinstance(raw, dict): + return {} + iface_data = raw.get("interface") + return iface_data if isinstance(iface_data, dict) else {} - iface_data = raw.get("interface") or {} - if not isinstance(iface_data, dict): - return [] +def _device_to_argspec(raw): result = [] - for name, data in sorted(iface_data.items()): - data = data or {} + for name, data in sorted((raw or {}).items()): entry = {"name": name} - - if data.get("mode"): - entry["mode"] = data["mode"] - - loc_data = data.get("location") or {} - if isinstance(loc_data, dict) and loc_data: - loc = {} - if "elin" in loc_data: - loc["elin"] = loc_data["elin"] - cb = loc_data.get("coordinate-based") or {} - if isinstance(cb, dict) and cb: - coord = {} - if "latitude" in cb: - coord["latitude"] = cb["latitude"] - if "longitude" in cb: - coord["longitude"] = cb["longitude"] - if "altitude" in cb: - coord["altitude"] = int(cb["altitude"]) - if "datum" in cb: - coord["datum"] = cb["datum"] - if coord: - loc["coordinate_based"] = coord - if loc: - entry["location"] = loc - + entry.update(_entry_from_device(data or {})) result.append(entry) - return result -def _normalize(config): - result = {} - for entry in config or []: - name = entry["name"] - loc = entry.get("location") or {} - cb = loc.get("coordinate_based") or {} - result[name] = { - "mode": entry.get("mode"), - "elin": loc.get("elin"), - "latitude": cb.get("latitude"), - "longitude": cb.get("longitude"), - "altitude": cb.get("altitude"), - "datum": cb.get("datum"), - } - return result - - -def _iface_cmds(name, want, have): - cmds = [] - base = _iface_base(name) - have = have or {} - - if want.get("mode") and want["mode"] != have.get("mode"): - cmds.append(("set", base + ["mode", want["mode"]])) - elif not want.get("mode") and have.get("mode"): - cmds.append(("delete", base + ["mode"])) - - loc_base = base + ["location"] - if want.get("elin") and want["elin"] != have.get("elin"): - cmds.append(("set", loc_base + ["elin", want["elin"]])) +def build_commands(config, raw_have, state): + raw_have = raw_have or {} + config = config or [] - cb_base = loc_base + ["coordinate-based"] - if want.get("latitude") and want["latitude"] != have.get("latitude"): - cmds.append(("set", cb_base + ["latitude", want["latitude"]])) - if want.get("longitude") and want["longitude"] != have.get("longitude"): - cmds.append(("set", cb_base + ["longitude", want["longitude"]])) - if want.get("altitude") is not None and want["altitude"] != have.get("altitude"): - cmds.append(("set", cb_base + ["altitude", str(want["altitude"])])) - if want.get("datum") and want["datum"] != have.get("datum"): - cmds.append(("set", cb_base + ["datum", want["datum"]])) - - return cmds - - -def build_commands(config, have_raw, state): - cmds = [] - have_map = _normalize(have_raw) + have_list = _device_to_argspec(raw_have) + have_by_name = {e["name"]: e for e in have_list} + want_by_name = {e["name"]: e for e in config if e.get("name")} if state == "deleted": + cmds = [] if not config: - for name in have_map: + for name in have_by_name: + cmds.append(("delete", _iface_base(name))) + return cmds + for entry in config: + name = entry.get("name") + if name and name in have_by_name: cmds.append(("delete", _iface_base(name))) - else: - want_map = _normalize(config) - for name in want_map: - if name in have_map: - cmds.append(("delete", _iface_base(name))) return cmds - want_map = _normalize(config) - + commands = [] if state == "overridden": - for name in set(have_map) - set(want_map): - cmds.append(("delete", _iface_base(name))) + for name in set(have_by_name) - set(want_by_name): + commands.append(("delete", _iface_base(name))) - for name, want in want_map.items(): - have = have_map.get(name, {}) + for name, want_entry in want_by_name.items(): + have_entry = have_by_name.get(name, {}) + want_device = _entry_to_device({k: v for k, v in want_entry.items() if k != "name"}) + have_device = _entry_to_device({k: v for k, v in have_entry.items() if k != "name"}) + base = _iface_base(name) - if state == "replaced" and name in have_map: - test_cmds = _iface_cmds(name, want, have) - if not test_cmds: - continue - cmds.append(("delete", _iface_base(name))) - have = {} + if state in ("replaced", "overridden"): + commands += dict_op(want_device, have_device, base, op="purge") + commands += dict_op(want_device, have_device, base, op="set") - cmds += _iface_cmds(name, want, have) + return commands - return cmds +_COORD_OPTIONS = dict( + latitude=dict(type="str", required=True), + longitude=dict(type="str", required=True), + altitude=dict(type="int"), + datum=dict(type="str", choices=["WGS84", "NAD83", "MLLW"]), +) + +_LOCATION_OPTIONS = dict( + elin=dict(type="str"), + coordinate_based=dict(type="dict", options=_COORD_OPTIONS), +) + +_ENTRY_OPTIONS = dict( + name=dict(type="str", required=True), + mode=dict(type="str", choices=["disable", "rx-tx", "rx", "tx"]), + location=dict(type="dict", options=_LOCATION_OPTIONS), +) ARGUMENT_SPEC = dict( - config=dict( - type="list", - elements="dict", - options=dict( - name=dict(type="str", required=True), - mode=dict(type="str", choices=["disable", "rx-tx", "rx", "tx"]), - location=dict( - type="dict", - options=dict( - elin=dict(type="str"), - coordinate_based=dict( - type="dict", - options=dict( - latitude=dict(type="str", required=True), - longitude=dict(type="str", required=True), - altitude=dict(type="int"), - datum=dict(type="str", choices=["WGS84", "NAD83", "MLLW"]), - ), - ), - ), - ), - ), - ), - running_config=dict(type="str"), + config=dict(type="list", elements="dict", options=_ENTRY_OPTIONS), state=dict( type="str", default="merged", - choices=["merged", "replaced", "overridden", "deleted", "gathered", "rendered", "parsed"], + choices=["merged", "replaced", "overridden", "deleted", "gathered"], ), ) def main(): - module = AnsibleModule( - argument_spec=ARGUMENT_SPEC, - mutually_exclusive=[["config", "running_config"]], - required_if=[ - ("state", "rendered", ["config"]), - ("state", "parsed", ["running_config"]), - ], - supports_check_mode=True, - ) + module = AnsibleModule(ARGUMENT_SPEC, supports_check_mode=True) vyos = VyOSModule(module) state = module.params["state"] config = module.params.get("config") or [] - if state == "parsed": - module.exit_json(parsed=[]) - - if state == "rendered": - cmds = build_commands(config, [], "merged") - module.exit_json(rendered=cmds, commands=cmds) - - have = get_running_config(vyos) + raw_have = get_running_config(vyos) + have = _device_to_argspec(raw_have) + for entry in have: + cast_by_spec(entry, _ENTRY_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) + module.exit_json(changed=bool(commands), commands=commands, before=have, after=have) if commands: response = vyos.apply_commands(commands) saved = vyos.save_config() + after_raw = get_running_config(vyos) + after = _device_to_argspec(after_raw) + for entry in after: + cast_by_spec(entry, _ENTRY_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/lldp_interfaces_running.json b/tests/unit/fixtures/lldp_interfaces_running.json index 905a88d..058b365 100644 --- a/tests/unit/fixtures/lldp_interfaces_running.json +++ b/tests/unit/fixtures/lldp_interfaces_running.json @@ -1,19 +1,15 @@ { - "interface": { - "eth0": { - "location": { - "elin": "1234567890" - }, - "mode": "disable" - }, - "eth1": { - "location": { - "coordinate-based": { - "altitude": "2200", - "datum": "WGS84", - "latitude": "33.524449N", - "longitude": "22.267255E" - } + "eth0": { + "mode": "disable", + "location": { "elin": "1234567890" } + }, + "eth1": { + "location": { + "coordinate-based": { + "latitude": "33.524449N", + "longitude": "22.267255E", + "altitude": "2200", + "datum": "WGS84" } } } diff --git a/tests/unit/modules/test_vyos_lldp_interfaces.py b/tests/unit/modules/test_vyos_lldp_interfaces.py index 0950991..bb107e1 100644 --- a/tests/unit/modules/test_vyos_lldp_interfaces.py +++ b/tests/unit/modules/test_vyos_lldp_interfaces.py @@ -4,287 +4,170 @@ 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_lldp_interfaces import ( + ARGUMENT_SPEC, + _device_to_argspec, + _entry_from_device, + _entry_to_device, _iface_base, - _iface_cmds, - _normalize, 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") - path = os.path.join(fixtures_dir, filename) - with open(path) as f: - return json.load(f) + +_BASE = ["service", "lldp", "interface"] 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 + self.fixture = load_fixture("lldp_interfaces_running.json") + self.mock_vyos.get_config = MagicMock(return_value={"interface": self.fixture}) + def gather(self): + have = _device_to_argspec(self.fixture) + for entry in have: + cast_by_spec(entry, ARGUMENT_SPEC["config"]["options"]) + return have -class TestVyOSLldpInterfacesIfaceBase(unittest.TestCase): - def test_iface_base(self): - self.assertEqual( - _iface_base("eth0"), - ["service", "lldp", "interface", "eth0"], - ) +class TestGetRunningConfig(VyOSModuleTestCase): + def test_returns_interface_dict(self): + result = get_running_config(self.mock_vyos) + self.assertIn("eth0", result) + def test_empty_config(self): + self.mock_vyos.get_config = MagicMock(return_value=None) + self.assertEqual(get_running_config(self.mock_vyos), {}) -class TestVyOSLldpInterfacesGetRunningFixture(VyOSModuleTestCase): + def test_non_dict_response_is_safe(self): + self.mock_vyos.get_config = MagicMock(return_value="eth0") + self.assertEqual(get_running_config(self.mock_vyos), {}) - def setUp(self): - super().setUp() - self.fixture = load_fixture("lldp_interfaces_running.json") - - def test_fixture_parses_eth0_mode(self): - self.set_running_config(self.fixture) - result = get_running_config(self.mock_vyos) - eth0 = next((e for e in result if e["name"] == "eth0"), None) - self.assertIsNotNone(eth0) - self.assertEqual(eth0["mode"], "disable") - def test_fixture_parses_elin(self): - self.set_running_config(self.fixture) - result = get_running_config(self.mock_vyos) - eth0 = next(e for e in result if e["name"] == "eth0") - self.assertEqual(eth0["location"]["elin"], "1234567890") +class TestIfaceBase(unittest.TestCase): + def test_base_path(self): + self.assertEqual(_iface_base("eth0"), _BASE + ["eth0"]) - def test_fixture_parses_coordinate_based(self): - self.set_running_config(self.fixture) - result = get_running_config(self.mock_vyos) - eth1 = next((e for e in result if e["name"] == "eth1"), None) - self.assertIsNotNone(eth1) - cb = eth1["location"]["coordinate_based"] - self.assertEqual(cb["latitude"], "33.524449N") - self.assertEqual(cb["longitude"], "22.267255E") - self.assertEqual(cb["altitude"], 2200) - self.assertEqual(cb["datum"], "WGS84") +class TestEntryToDeviceFromDevice(unittest.TestCase): + def test_mode(self): + result = _entry_to_device({"mode": "disable"}) + self.assertEqual(result, {"mode": "disable"}) -class TestVyOSLldpInterfacesGetRunning(VyOSModuleTestCase): + def test_elin(self): + result = _entry_to_device({"location": {"elin": "1234567890"}}) + self.assertEqual(result, {"location": {"elin": "1234567890"}}) - def test_empty_returns_empty_list(self): - self.set_running_config({}) - result = get_running_config(self.mock_vyos) - self.assertEqual(result, []) + def test_coordinate_based(self): + cb = {"latitude": "1N", "longitude": "1E", "altitude": 10} + result = _entry_to_device({"location": {"coordinate_based": cb}}) + self.assertEqual(result, {"location": {"coordinate-based": cb}}) - def test_no_interface_returns_empty(self): - self.set_running_config({"snmp": "enable"}) - result = get_running_config(self.mock_vyos) - self.assertEqual(result, []) + def test_from_device_defers_int_casting_to_cast_by_spec(self): + """_entry_from_device no longer manually casts altitude -- + that's cast_by_spec's job, applied later by callers (matching + the established pattern elsewhere, e.g. vyos_ospfv2's + distance/redistribute fields). Confirmed end-to-end via + TestDeviceToArgspecFixture, which does go through cast_by_spec.""" + cb = {"latitude": "1N", "longitude": "1E", "altitude": "10"} + entry = _entry_from_device({"location": {"coordinate-based": cb}}) + self.assertEqual(entry["location"]["coordinate_based"]["altitude"], "10") - def test_parses_mode(self): - self.set_running_config( - { - "interface": {"eth0": {"mode": "rx-tx"}}, - }, - ) - result = get_running_config(self.mock_vyos) - self.assertEqual(result[0]["mode"], "rx-tx") + def test_empty(self): + self.assertEqual(_entry_to_device({}), {}) + self.assertEqual(_entry_from_device({}), {}) - def test_parses_elin(self): - self.set_running_config( - { - "interface": {"eth0": {"location": {"elin": "9876543210"}}}, - }, - ) - result = get_running_config(self.mock_vyos) - self.assertEqual(result[0]["location"]["elin"], "9876543210") - - def test_parses_coordinate_based(self): - self.set_running_config( - { - "interface": { - "eth0": { - "location": { - "coordinate-based": { - "latitude": "33.524449N", - "longitude": "22.267255E", - "altitude": "2200", - "datum": "WGS84", - }, - }, - }, - }, - }, - ) - result = get_running_config(self.mock_vyos) - cb = result[0]["location"]["coordinate_based"] - self.assertEqual(cb["latitude"], "33.524449N") - self.assertEqual(cb["altitude"], 2200) - - def test_no_mode_not_in_entry(self): - self.set_running_config( - { - "interface": {"eth0": {"location": {"elin": "1234567890"}}}, - }, - ) - result = get_running_config(self.mock_vyos) - self.assertNotIn("mode", result[0]) +class TestDeviceToArgspecFixture(VyOSModuleTestCase): + def test_both_interfaces_present(self): + have = self.gather() + names = {e["name"] for e in have} + self.assertEqual(names, {"eth0", "eth1"}) -class TestVyOSLldpInterfacesNormalize(unittest.TestCase): + def test_eth0_mode_and_elin_parsed(self): + have = self.gather() + eth0 = next(e for e in have if e["name"] == "eth0") + self.assertEqual(eth0["mode"], "disable") + self.assertEqual(eth0["location"]["elin"], "1234567890") - def test_normalize_mode(self): + def test_eth1_coordinate_based_parsed(self): + have = self.gather() + eth1 = next(e for e in have if e["name"] == "eth1") + coord = eth1["location"]["coordinate_based"] + self.assertEqual(coord["latitude"], "33.524449N") + self.assertEqual(coord["altitude"], 2200) + + +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_clear_omitted_location_entirely_via_replaced(self): + """Confirmed bug in the original: elin/latitude/longitude/ + altitude/datum had no clear-on-omit logic at all, unlike mode + (a genuine inconsistency). dict_op's purge handles this + uniformly -- when location is entirely absent from want, the + whole node is deleted (not descended into).""" + raw_have = {"eth0": {"mode": "disable", "location": {"elin": "1234567890"}}} config = [{"name": "eth0", "mode": "disable"}] - result = _normalize(config) - self.assertEqual(result["eth0"]["mode"], "disable") - - def test_normalize_elin(self): - config = [{"name": "eth0", "location": {"elin": "1234567890"}}] - result = _normalize(config) - self.assertEqual(result["eth0"]["elin"], "1234567890") - - def test_normalize_coordinate_based(self): - config = [ - { - "name": "eth0", + cmds = build_commands(config, raw_have, "replaced") + self.assertIn(("delete", _BASE + ["eth0", "location"]), cmds) + + def test_clear_specific_field_when_location_partially_present(self): + raw_have = { + "eth0": { "location": { - "coordinate_based": { - "latitude": "33.524449N", - "longitude": "22.267255E", - "altitude": 2200, - "datum": "WGS84", - }, + "elin": "123", + "coordinate-based": {"latitude": "1N", "longitude": "1E"}, }, }, - ] - result = _normalize(config) - self.assertEqual(result["eth0"]["latitude"], "33.524449N") - self.assertEqual(result["eth0"]["altitude"], 2200) - - def test_normalize_empty(self): - result = _normalize([]) - self.assertEqual(result, {}) - - -class TestVyOSLldpInterfacesIfaceCmds(unittest.TestCase): - - def test_set_mode(self): - want = {"mode": "disable"} - cmds = _iface_cmds("eth0", want, {}) - self.assertIn( - ("set", ["service", "lldp", "interface", "eth0", "mode", "disable"]), - cmds, - ) - - def test_set_elin(self): - want = {"elin": "1234567890"} - cmds = _iface_cmds("eth0", want, {}) - self.assertIn( - ("set", ["service", "lldp", "interface", "eth0", "location", "elin", "1234567890"]), - cmds, - ) - - def test_set_latitude(self): - want = {"latitude": "33.524449N", "longitude": "22.267255E"} - cmds = _iface_cmds("eth0", want, {}) - self.assertIn( - ( - "set", - [ - "service", - "lldp", - "interface", - "eth0", - "location", - "coordinate-based", - "latitude", - "33.524449N", - ], - ), - cmds, - ) - - def test_idempotent_mode(self): - want = {"mode": "disable"} - have = {"mode": "disable"} - cmds = _iface_cmds("eth0", want, have) - self.assertEqual(cmds, []) - - def test_idempotent_elin(self): - want = {"elin": "1234567890"} - have = {"elin": "1234567890"} - cmds = _iface_cmds("eth0", want, have) + } + coord = {"latitude": "1N", "longitude": "1E"} + config = [{"name": "eth0", "location": {"coordinate_based": coord}}] + cmds = build_commands(config, raw_have, "replaced") + self.assertIn(("delete", _BASE + ["eth0", "location", "elin"]), cmds) + self.assertNotIn(("delete", _BASE + ["eth0", "location"]), cmds) + + def test_replaced_does_not_touch_unrelated_interfaces(self): + raw_have = {"eth0": {"mode": "disable"}, "eth1": {"mode": "rx"}} + config = [{"name": "eth0", "mode": "disable"}] + cmds = build_commands(config, raw_have, "replaced") self.assertEqual(cmds, []) - def test_delete_mode_when_none(self): - want = {} - have = {"mode": "disable"} - cmds = _iface_cmds("eth0", want, have) - self.assertIn( - ("delete", ["service", "lldp", "interface", "eth0", "mode"]), - cmds, - ) - + def test_deleted_all(self): + cmds = build_commands([], {"eth0": {}}, "deleted") + self.assertEqual(cmds, [("delete", _BASE + ["eth0"])]) -class TestVyOSLldpInterfacesBuildCommands(unittest.TestCase): - - def _have_eth0(self): - return [ - { - "name": "eth0", - "mode": "disable", - "location": {"elin": "1234567890"}, - }, - ] + def test_deleted_named(self): + cmds = build_commands([{"name": "eth0"}], {"eth0": {}}, "deleted") + self.assertEqual(cmds, [("delete", _BASE + ["eth0"])]) - def test_merged_adds_interface(self): - config = [{"name": "eth0", "mode": "disable"}] - cmds = build_commands(config, [], "merged") - self.assertIn( - ("set", ["service", "lldp", "interface", "eth0", "mode", "disable"]), - cmds, - ) - - def test_merged_idempotent(self): - cmds = build_commands(self._have_eth0(), self._have_eth0(), "merged") - self.assertEqual(cmds, []) - - def test_deleted_no_config_removes_all(self): - cmds = build_commands([], self._have_eth0(), "deleted") - self.assertIn( - ("delete", ["service", "lldp", "interface", "eth0"]), - cmds, - ) - - def test_deleted_with_config_removes_named(self): - config = [{"name": "eth0"}] - cmds = build_commands(config, self._have_eth0(), "deleted") - self.assertIn( - ("delete", ["service", "lldp", "interface", "eth0"]), - cmds, - ) - - def test_deleted_idempotent_when_empty(self): - cmds = build_commands([], [], "deleted") + def test_deleted_named_nonexistent_is_noop(self): + cmds = build_commands([{"name": "eth99"}], {"eth0": {}}, "deleted") self.assertEqual(cmds, []) - def test_replaced_idempotent(self): - cmds = build_commands(self._have_eth0(), self._have_eth0(), "replaced") - self.assertEqual(cmds, []) + def test_overridden_removes_omitted_interface(self): + raw_have = {"eth0": {}, "eth1": {"mode": "rx"}} + cmds = build_commands([{"name": "eth0"}], raw_have, "overridden") + self.assertIn(("delete", _BASE + ["eth1"]), cmds) - def test_overridden_removes_unlisted(self): - config = [{"name": "eth1", "mode": "rx-tx"}] - cmds = build_commands(config, self._have_eth0(), "overridden") - self.assertIn( - ("delete", ["service", "lldp", "interface", "eth0"]), - cmds, - ) + def test_merged_new_interface(self): + config = [{"name": "eth2", "mode": "tx"}] + cmds = build_commands(config, {}, "merged") + self.assertIn(("set", _BASE + ["eth2", "mode", "tx"]), cmds) if __name__ == "__main__": |
