summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authoromnom62 <75066712+omnom62@users.noreply.github.com>2026-09-11 21:43:10 +1000
committerGitHub <noreply@github.com>2026-09-11 14:43:10 +0300
commita5fbed02fcd4a98845d1510be322d4404cfbd1e8 (patch)
tree8b3d256008d52361bbd53195e0ffd8e383e64313
parent97c43a34bd953a06bfe6c4b010ab79f645bac061 (diff)
downloadrest.vyos-a5fbed02fcd4a98845d1510be322d4404cfbd1e8.tar.gz
rest.vyos-a5fbed02fcd4a98845d1510be322d4404cfbd1e8.zip
T8989: lldp interfaces dict op (#36)HEADmain
* T8989: lldp_interfaces dict_op refactor
-rw-r--r--changelogs/fragments/t8989_lldp_interfaces.yml3
-rw-r--r--docs/vyos.rest.vyos_lldp_interfaces_module.rst51
-rw-r--r--plugins/modules/vyos_lldp_interfaces.py276
-rw-r--r--tests/unit/fixtures/lldp_interfaces_running.json26
-rw-r--r--tests/unit/modules/test_vyos_lldp_interfaces.py357
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__":