summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authoromnom62 <75066712+omnom62@users.noreply.github.com>2026-09-28 21:31:03 +1000
committerGitHub <noreply@github.com>2026-09-28 12:31:03 +0100
commit683e120725c4b03b671ac66ce47b2fceccf6f8ba (patch)
tree45e527539ba9e5c306ae6744387f7b360ce50508
parent9a0deaabf0d046db4e99c20d09b04e8c2f5fef1c (diff)
downloadrest.vyos-683e120725c4b03b671ac66ce47b2fceccf6f8ba.tar.gz
rest.vyos-683e120725c4b03b671ac66ce47b2fceccf6f8ba.zip
T8989: vyos_ospf_inferfaces dict_op rework (#31)
* T8989: vyos_ospf_inferfaces dict_op rework * T8989: vyos_ospf_inferfaces Copilot comments * T8989: vyos_ospf_inferfaces Copilot comments * T8989: vyos_ospf_inferfaces Copilot comments * T8989: vyos_ospf_inferfaces Copilot comments * T8989: ospf_interfaces AI comment fixes * T8989: ospf_interfaces AI comment fixes --------- Co-authored-by: Daniil Baturin <daniil@vyos.io>
-rw-r--r--changelogs/fragments/t8989_ospf_interfaces_dict_op.yml3
-rw-r--r--docs/vyos.rest.vyos_ospf_interfaces_module.rst57
-rw-r--r--plugins/modules/vyos_ospf_interfaces.py677
-rw-r--r--tests/unit/fixtures/ospf_interfaces_ipv4.json13
-rw-r--r--tests/unit/fixtures/ospf_interfaces_ipv6.json7
-rw-r--r--tests/unit/modules/test_vyos_ospf_interfaces.py298
6 files changed, 599 insertions, 456 deletions
diff --git a/changelogs/fragments/t8989_ospf_interfaces_dict_op.yml b/changelogs/fragments/t8989_ospf_interfaces_dict_op.yml
new file mode 100644
index 0000000..81a23f5
--- /dev/null
+++ b/changelogs/fragments/t8989_ospf_interfaces_dict_op.yml
@@ -0,0 +1,3 @@
+---
+minor_changes:
+ - vyos_ospf_interfaces - Refactor module to dict_op arch, add more tests.
diff --git a/docs/vyos.rest.vyos_ospf_interfaces_module.rst b/docs/vyos.rest.vyos_ospf_interfaces_module.rst
index 1439a71..60424ac 100644
--- a/docs/vyos.rest.vyos_ospf_interfaces_module.rst
+++ b/docs/vyos.rest.vyos_ospf_interfaces_module.rst
@@ -17,10 +17,7 @@ Version added: 1.0.0
Synopsis
--------
-- Manages OSPF and OSPFv3 interface configuration on VyOS devices via the REST API.
-- IPv4 OSPF maps to ``protocols ospf interface``.
-- IPv6 OSPFv3 maps to ``protocols ospfv3 interface``.
-- Uses REST API (``connection=httpapi``) instead of CLI.
+- Manages OSPFv2 (IPv4) and OSPFv3 (IPv6) per-interface configuration on VyOS devices via the REST API.
@@ -96,6 +93,23 @@ Parameters
<td class="elbow-placeholder"></td>
<td colspan="3">
<div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>area</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>OSPF area to assign this interface to (<code>set protocols ospf interface &lt;name&gt; area &lt;id&gt;</code> for OSPFv2, <code>set protocols ospfv3 interface &lt;name&gt; area &lt;id&gt;</code> for OSPFv3). This is a per-interface alternative to enabling OSPF via <code>area &lt;id&gt; network &lt;prefix&gt;</code> -- VyOS&#x27;s own documentation frames the network-based form as standard and this per-interface form as more convenient in some cases (many interfaces/subnets). Confirmed via VyOS&#x27;s official documentation across 1.4+/1.5 LTS/rolling as the current syntax, distinct from and superseding the older (1.3-era) <code>area &lt;id&gt; interface &lt;name&gt;</code> form.</div>
+ </td>
+ </tr>
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td colspan="3">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
<b>authentication</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
<div style="font-size: small">
@@ -198,7 +212,7 @@ Parameters
<td>
</td>
<td>
- <div>Interface bandwidth in kbps (IPv4 only).</div>
+ <div>Interface bandwidth in Mbit/s (IPv4 only).</div>
</td>
</tr>
<tr>
@@ -304,7 +318,7 @@ Parameters
</ul>
</td>
<td>
- <div>Disable MTU check (IPv4 only).</div>
+ <div>Disable MTU mismatch detection.</div>
</td>
</tr>
<tr>
@@ -327,7 +341,7 @@ Parameters
</ul>
</td>
<td>
- <div>Network type (IPv4 only).</div>
+ <div>Network type.</div>
</td>
</tr>
<tr>
@@ -348,7 +362,7 @@ Parameters
</ul>
</td>
<td>
- <div>Disable adjacency formation (IPv6 only).</div>
+ <div>Suppress adjacency formation on this interface.</div>
</td>
</tr>
<tr>
@@ -460,6 +474,14 @@ Notes
- ``ansible_network_os`` must be set to ``vyos.rest.vyos``.
+See Also
+--------
+
+.. seealso::
+
+ :ref:`vyos.vyos.vyos_ospf_interfaces_module`
+ The official documentation on the **vyos.vyos.vyos_ospf_interfaces** module.
+
Examples
--------
@@ -473,27 +495,8 @@ Examples
address_family:
- afi: ipv4
cost: 100
- transmit_delay: 50
- priority: 26
- - afi: ipv6
- dead_interval: 39
- passive: true
state: merged
- - name: Delete OSPF interface configuration
- vyos.rest.vyos_ospf_interfaces:
- config:
- - name: eth1
- state: deleted
-
- - name: Delete all OSPF interface configuration
- vyos.rest.vyos_ospf_interfaces:
- state: deleted
-
- - name: Gather current OSPF interface configuration
- vyos.rest.vyos_ospf_interfaces:
- state: gathered
-
Return Values
diff --git a/plugins/modules/vyos_ospf_interfaces.py b/plugins/modules/vyos_ospf_interfaces.py
index 5388ef0..992aa4d 100644
--- a/plugins/modules/vyos_ospf_interfaces.py
+++ b/plugins/modules/vyos_ospf_interfaces.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
@@ -11,10 +12,8 @@ DOCUMENTATION = r"""
module: vyos_ospf_interfaces
short_description: Manage OSPF interface configuration on VyOS devices using REST API
description:
- - Manages OSPF and OSPFv3 interface configuration on VyOS devices via the REST API.
- - IPv4 OSPF maps to C(protocols ospf interface).
- - IPv6 OSPFv3 maps to C(protocols ospfv3 interface).
- - Uses REST API (C(connection=httpapi)) instead of CLI.
+ - Manages OSPFv2 (IPv4) and OSPFv3 (IPv6) per-interface configuration on
+ VyOS devices via the REST API.
version_added: "1.0.0"
author:
- VyOS Community (@vyos)
@@ -38,6 +37,21 @@ options:
type: str
choices: [ipv4, ipv6]
required: true
+ area:
+ description: >-
+ OSPF area to assign this interface to (C(set protocols
+ ospf interface <name> area <id>) for OSPFv2, C(set
+ protocols ospfv3 interface <name> area <id>) for
+ OSPFv3). This is a per-interface alternative to
+ enabling OSPF via C(area <id> network <prefix>) --
+ VyOS's own documentation frames the network-based form
+ as standard and this per-interface form as more
+ convenient in some cases (many interfaces/subnets).
+ Confirmed via VyOS's official documentation across
+ 1.4+/1.5 LTS/rolling as the current syntax, distinct
+ from and superseding the older (1.3-era) C(area <id>
+ interface <name>) form.
+ type: str
authentication:
description: Authentication settings (IPv4 only).
type: dict
@@ -56,7 +70,7 @@ options:
description: MD5 key string.
type: str
bandwidth:
- description: Interface bandwidth in kbps (IPv4 only).
+ description: Interface bandwidth in Mbit/s (IPv4 only).
type: int
cost:
description: Interface cost metric.
@@ -74,14 +88,14 @@ options:
description: OSPFv3 instance ID (IPv6 only).
type: str
mtu_ignore:
- description: Disable MTU check (IPv4 only).
+ description: Disable MTU mismatch detection.
type: bool
network:
- description: Network type (IPv4 only).
+ description: Network type.
type: str
choices: [broadcast, non-broadcast, point-to-multipoint, point-to-point]
passive:
- description: Disable adjacency formation (IPv6 only).
+ description: Suppress adjacency formation on this interface.
type: bool
priority:
description: Interface priority.
@@ -106,6 +120,8 @@ 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_ospf_interfaces
"""
EXAMPLES = r"""
@@ -116,26 +132,7 @@ EXAMPLES = r"""
address_family:
- afi: ipv4
cost: 100
- transmit_delay: 50
- priority: 26
- - afi: ipv6
- dead_interval: 39
- passive: true
state: merged
-
-- name: Delete OSPF interface configuration
- vyos.rest.vyos_ospf_interfaces:
- config:
- - name: eth1
- state: deleted
-
-- name: Delete all OSPF interface configuration
- vyos.rest.vyos_ospf_interfaces:
- state: deleted
-
-- name: Gather current OSPF interface configuration
- vyos.rest.vyos_ospf_interfaces:
- state: gathered
"""
RETURN = r"""
@@ -166,336 +163,339 @@ 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,
+)
_BASE4 = ["protocols", "ospf", "interface"]
_BASE6 = ["protocols", "ospfv3", "interface"]
-# IPv4 scalar fields: argspec_key -> api_key
-_IPV4_SCALARS = {
- "bandwidth": "bandwidth",
- "cost": "cost",
- "dead_interval": "dead-interval",
- "hello_interval": "hello-interval",
- "mtu_ignore": "mtu-ignore",
- "network": "network",
- "priority": "priority",
- "retransmit_interval": "retransmit-interval",
- "transmit_delay": "transmit-delay",
-}
-
-# IPv6 scalar fields
-_IPV6_SCALARS = {
- "cost": "cost",
- "dead_interval": "dead-interval",
- "hello_interval": "hello-interval",
- "ifmtu": "ifmtu",
- "instance": "instance-id",
- "passive": "passive",
- "priority": "priority",
- "retransmit_interval": "retransmit-interval",
- "transmit_delay": "transmit-delay",
-}
-
-# bool fields that use presence (no value)
-_IPV4_BOOL_PRESENCE = {"mtu_ignore"}
-_IPV6_BOOL_PRESENCE = {"passive"}
-
-
-def _parse_ipv4_iface(data):
- af = {"afi": "ipv4"}
- data = data or {}
- for arg_key, api_key in _IPV4_SCALARS.items():
- if api_key in data:
- if arg_key in _IPV4_BOOL_PRESENCE:
- af[arg_key] = True
- else:
- val = data[api_key]
- if arg_key in (
- "bandwidth",
- "cost",
- "dead_interval",
- "hello_interval",
- "priority",
- "retransmit_interval",
- "transmit_delay",
- ):
- try:
- af[arg_key] = int(val)
- except (TypeError, ValueError):
- af[arg_key] = val
- else:
- af[arg_key] = val
- # authentication
- auth = data.get("authentication", {})
- if auth:
- auth_entry = {}
- if "plaintext-password" in auth:
- auth_entry["plaintext_password"] = auth["plaintext-password"]
- md5 = auth.get("md5", {})
- if md5:
- key_id_data = md5.get("key-id", {})
- if key_id_data:
- key_id = list(key_id_data.keys())[0]
- md5_key = key_id_data[key_id].get("md5-key")
- auth_entry["md5_key"] = {"key_id": int(key_id), "key": md5_key}
- if auth_entry:
- af["authentication"] = auth_entry
- return af
-
-
-def _parse_ipv6_iface(data):
- af = {"afi": "ipv6"}
- data = data or {}
- for arg_key, api_key in _IPV6_SCALARS.items():
- if api_key in data:
- if arg_key in _IPV6_BOOL_PRESENCE:
- af[arg_key] = True
- else:
- val = data[api_key]
- if arg_key in (
- "cost",
- "dead_interval",
- "hello_interval",
- "ifmtu",
- "priority",
- "retransmit_interval",
- "transmit_delay",
- ):
- try:
- af[arg_key] = int(val)
- except (TypeError, ValueError):
- af[arg_key] = val
- else:
- af[arg_key] = val
- return af
+_IPV6_RENAMES = {"instance": "instance-id"}
-def get_running_config(vyos):
- raw4 = vyos.get_config(_BASE4) or {}
- raw4 = raw4.get("interface", raw4)
- raw6 = vyos.get_config(_BASE6) or {}
- raw6 = raw6.get("interface", raw6)
+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]
- ifaces = {}
- for iface_name, data in raw4.items():
- if iface_name not in ifaces:
- ifaces[iface_name] = {"name": iface_name, "address_family": []}
- af = _parse_ipv4_iface(data)
- if len(af) > 1: # more than just afi key
- ifaces[iface_name]["address_family"].append(af)
-
- for iface_name, data in raw6.items():
- if iface_name not in ifaces:
- ifaces[iface_name] = {"name": iface_name, "address_family": []}
- af = _parse_ipv6_iface(data)
- if len(af) > 1:
- ifaces[iface_name]["address_family"].append(af)
-
- result = sorted(ifaces.values(), key=lambda x: x["name"])
- # Remove empty address_family lists
- for iface in result:
- if not iface["address_family"]:
- del iface["address_family"]
- return result
+def _kebab_fields(d):
+ cleaned = autoclean(d)
+ return {k.replace("_", "-"): v for k, v in cleaned.items()}
-def _ipv4_af_cmds(iface_name, af, have_af, op="set"):
- cmds = []
- base = _BASE4 + [iface_name]
- have_af = have_af or {}
+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
- for arg_key, api_key in _IPV4_SCALARS.items():
- want_val = af.get(arg_key)
- have_val = have_af.get(arg_key)
- if want_val is not None and want_val != have_val:
- if arg_key in _IPV4_BOOL_PRESENCE:
- cmds.append(("set", base + [api_key]))
- else:
- cmds.append(("set", base + [api_key, str(want_val)]))
- elif op == "replace" and have_val is not None and want_val != have_val:
- cmds.append(("delete", base + [api_key]))
-
- # authentication
- want_auth = af.get("authentication") or {}
- have_auth = have_af.get("authentication") or {}
- if want_auth.get("plaintext_password") and want_auth["plaintext_password"] != have_auth.get(
- "plaintext_password",
- ):
- cmds.append(
- (
- "set",
- base
- + [
- "authentication",
- "plaintext-password",
- want_auth["plaintext_password"],
- ],
- ),
- )
- md5 = want_auth.get("md5_key") or {}
- if md5 and md5 != have_auth.get("md5_key"):
- cmds.append(
- (
- "set",
- base
- + [
- "authentication",
- "md5",
- "key-id",
- str(md5["key_id"]),
- "md5-key",
- md5["key"],
- ],
- ),
- )
- return cmds
+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())
+ ]
+
+
+def _auth_to_device(auth):
+ if not auth:
+ return {}
+ device = {}
+ if auth.get("plaintext_password"):
+ device["plaintext-password"] = auth["plaintext_password"]
+ md5 = auth.get("md5_key") or {}
+ if md5.get("key_id") is not None and md5.get("key") is not None:
+ device["md5"] = {"key-id": {str(md5["key_id"]): {"md5-key": md5["key"]}}}
+ return device
+
+
+def _auth_from_device(data):
+ if not data:
+ return None
+ entry = {}
+ if data.get("plaintext-password"):
+ entry["plaintext_password"] = data["plaintext-password"]
+ key_id_raw = (data.get("md5") or {}).get("key-id")
+ if key_id_raw:
+ key_id, kdata = sorted(to_tag_dict(key_id_raw).items())[0]
+ entry["md5_key"] = {"key_id": int(key_id), "key": (kdata or {}).get("md5-key")}
+ return entry or None
+
+
+def _af4_entry_to_device(rest):
+ exclude = {"afi", "authentication"}
+ device = _kebab_fields({k: v for k, v in rest.items() if k not in exclude})
+ if rest.get("authentication"):
+ auth_device = _auth_to_device(rest["authentication"])
+ if auth_device:
+ device["authentication"] = auth_device
+ return device
+
+
+def _af4_entry_from_device(data):
+ exclude = {"authentication"}
+ entry = from_device({k: v for k, v in data.items() if k not in exclude})
+ auth = _auth_from_device(data.get("authentication"))
+ if auth:
+ entry["authentication"] = auth
+ return entry
+
+
+def _af6_entry_to_device(rest):
+ exclude = set(_IPV6_RENAMES)
+ device = _kebab_fields({k: v for k, v in rest.items() if k not in exclude})
+ for arg_key, device_key in _IPV6_RENAMES.items():
+ if rest.get(arg_key) is not None:
+ device[device_key] = rest[arg_key]
+ return device
+
+
+def _af6_entry_from_device(data):
+ exclude = set(_IPV6_RENAMES.values())
+ entry = from_device({k: v for k, v in data.items() if k not in exclude})
+ for arg_key, device_key in _IPV6_RENAMES.items():
+ if data.get(device_key) is not None:
+ entry[arg_key] = data[device_key]
+ return entry
+
+
+def _want_to_device(config):
+ want4 = {}
+ want6 = {}
+ for entry in config or []:
+ name = entry.get("name")
+ if not name:
+ continue
+ for af in entry.get("address_family") or []:
+ afi = af.get("afi")
+ rest = {k: v for k, v in af.items() if k != "afi"}
+ if afi == "ipv4":
+ want4[name] = _af4_entry_to_device(rest)
+ elif afi == "ipv6":
+ want6[name] = _af6_entry_to_device(rest)
+ return want4, want6
-def _ipv6_af_cmds(iface_name, af, have_af, op="set"):
- cmds = []
- base = _BASE6 + [iface_name]
- have_af = have_af or {}
+def get_running_config(vyos):
+ """VyOS's REST API collapses a single-child tag node to a plain
+ string (or a list for multiple) -- confirmed by reproduction: an
+ unguarded .get("interface", ...) on a non-dict response raises
+ AttributeError, and consuming an unnormalized raw4/raw6 downstream
+ (e.g. in build_commands's deleted branch) iterates a collapsed
+ string's individual characters as if they were interface names,
+ producing corrupted delete paths. Guarding the unwrap and
+ normalizing via to_tag_dict here means every caller always
+ receives a genuine {name: {...}} dict, with no need to re-guard
+ at each use site.
+ """
+ raw4 = vyos.get_config(_BASE4) or {}
+ if isinstance(raw4, dict):
+ raw4 = raw4.get("interface", raw4)
+ raw4 = to_tag_dict(raw4)
- for arg_key, api_key in _IPV6_SCALARS.items():
- want_val = af.get(arg_key)
- have_val = have_af.get(arg_key)
- if want_val is not None and want_val != have_val:
- if arg_key in _IPV6_BOOL_PRESENCE:
- cmds.append(("set", base + [api_key]))
- else:
- cmds.append(("set", base + [api_key, str(want_val)]))
- elif op == "replace" and have_val is not None and want_val != have_val:
- cmds.append(("delete", base + [api_key]))
+ raw6 = vyos.get_config(_BASE6) or {}
+ if isinstance(raw6, dict):
+ raw6 = raw6.get("interface", raw6)
+ raw6 = to_tag_dict(raw6)
- return cmds
+ return raw4, raw6
-def build_commands(config, have_raw, state):
- cmds = []
- have_map = {e["name"]: e for e in have_raw}
+def _device_to_argspec(raw4, raw6):
+ ifaces = {}
+ for name, data in sorted(to_tag_dict(raw4 or {}).items()):
+ entry = _af4_entry_from_device(data or {})
+ if entry:
+ ifaces.setdefault(name, {"name": name, "address_family": []})
+ ifaces[name]["address_family"].append({"afi": "ipv4", **entry})
+ for name, data in sorted(to_tag_dict(raw6 or {}).items()):
+ entry = _af6_entry_from_device(data or {})
+ if entry:
+ ifaces.setdefault(name, {"name": name, "address_family": []})
+ ifaces[name]["address_family"].append({"afi": "ipv6", **entry})
+ return sorted(ifaces.values(), key=lambda i: i["name"])
+
+
+def _validate_config(config):
+ """Confirmed real bugs, both by reproduction:
+
+ 1. md5_key.key/key_id were checked by truthiness, not presence --
+ key_id set without key reached _auth_to_device and produced
+ {"md5-key": None} in the generated command (a broken device
+ write, not a validation failure); an empty-string key without
+ key_id silently passed validation and then got dropped
+ entirely by _auth_to_device's own key_id is not None check
+ (the original confirmed bug, just reachable via a second
+ value). is not None checks catch both directions and don't
+ treat a legitimately-empty (but explicitly set) value as
+ "unset".
+
+ 2. authentication is documented and confirmed (via vyos-1x
+ schema) as IPv4-only -- OSPFv3's interface config has no
+ authentication node at all -- but the shared argspec doesn't
+ enforce this, and _af6_entry_to_device would silently forward
+ it into the ipv6 device path, where the real device would
+ reject it at apply time instead of failing cleanly upfront.
+ """
+ for entry in config or []:
+ name = entry.get("name")
+ for af in entry.get("address_family") or []:
+ afi = af.get("afi")
+ auth = af.get("authentication") or {}
+
+ if afi == "ipv6" and auth:
+ return (
+ "address_family.authentication was set for interface "
+ "'{0}' with afi: ipv6 -- authentication is IPv4-only "
+ "and has no corresponding OSPFv3 device path.".format(name)
+ )
+
+ md5 = auth.get("md5_key") or {}
+ has_key = md5.get("key") is not None
+ has_key_id = md5.get("key_id") is not None
+ if has_key != has_key_id:
+ return (
+ "address_family.authentication.md5_key.key and .key_id "
+ "were not both set for interface '{0}' -- both are "
+ "required together.".format(name)
+ )
+ return None
+
+
+def build_commands(config, raw_have, state):
+ raw4, raw6 = raw_have
+ config = config or []
if state == "deleted":
+ cmds = []
if not config:
- # delete all
- if have_raw:
- for iface in have_raw:
- name = iface["name"]
- afs = {af["afi"] for af in iface.get("address_family", [])}
- if "ipv4" in afs:
- cmds.append(("delete", _BASE4 + [name]))
- if "ipv6" in afs:
- cmds.append(("delete", _BASE6 + [name]))
- else:
- for entry in config:
- name = entry["name"]
- if name in have_map:
- have_afs = {af["afi"] for af in have_map[name].get("address_family", [])}
- want_afis = {af["afi"] for af in (entry.get("address_family") or [])}
- if not want_afis:
- # delete all AFIs for this interface
- if "ipv4" in have_afs:
- cmds.append(("delete", _BASE4 + [name]))
- if "ipv6" in have_afs:
- cmds.append(("delete", _BASE6 + [name]))
- else:
- if "ipv4" in want_afis and "ipv4" in have_afs:
- cmds.append(("delete", _BASE4 + [name]))
- if "ipv6" in want_afis and "ipv6" in have_afs:
- cmds.append(("delete", _BASE6 + [name]))
- return cmds
-
- if state == "overridden":
- want_names = {e["name"] for e in config}
- for name, have_iface in have_map.items():
- if name not in want_names:
- have_afs = {af["afi"] for af in have_iface.get("address_family", [])}
- if "ipv4" in have_afs:
+ for name in raw4 or {}:
+ cmds.append(("delete", _BASE4 + [name]))
+ for name in raw6 or {}:
+ cmds.append(("delete", _BASE6 + [name]))
+ return cmds
+ for entry in config:
+ name = entry.get("name")
+ if not name:
+ continue
+ want_afis = {af.get("afi") for af in (entry.get("address_family") or [])}
+ if not want_afis:
+ if name in (raw4 or {}):
cmds.append(("delete", _BASE4 + [name]))
- if "ipv6" in have_afs:
+ if name in (raw6 or {}):
cmds.append(("delete", _BASE6 + [name]))
-
- for entry in config:
- name = entry["name"]
- have_iface = have_map.get(name, {})
- have_af_map = {af["afi"]: af for af in have_iface.get("address_family", [])}
-
- for af in entry.get("address_family", []):
- afi = af["afi"]
- have_af = have_af_map.get(afi, {})
-
- if state == "replaced":
- af_clean = {k: v for k, v in af.items() if v is not None}
- if have_af and have_af != af_clean:
- if afi == "ipv4":
- cmds.append(("delete", _BASE4 + [name]))
- have_af = {}
- else:
- cmds.append(("delete", _BASE6 + [name]))
- have_af = {}
- elif have_af == af_clean:
- continue # already matches — idempotent
-
- if afi == "ipv4":
- cmds += _ipv4_af_cmds(name, af, have_af)
else:
- cmds += _ipv6_af_cmds(name, af, have_af)
-
- return cmds
+ if "ipv4" in want_afis and name in (raw4 or {}):
+ cmds.append(("delete", _BASE4 + [name]))
+ if "ipv6" in want_afis and name in (raw6 or {}):
+ cmds.append(("delete", _BASE6 + [name]))
+ return cmds
+ want4, want6 = _want_to_device(config)
+ have_argspec = _device_to_argspec(raw4, raw6)
+ norm4, norm6 = _want_to_device(have_argspec)
-ARGUMENT_SPEC = dict(
- config=dict(
- type="list",
- elements="dict",
+ commands = []
+ if state == "overridden":
+ commands += dict_op(want4, norm4, _BASE4, op="purge")
+ commands += dict_op(want6, norm6, _BASE6, op="purge")
+ # Confirmed bug: an interface present on the raw device with
+ # an entirely empty per-AFI entry (bare presence, nothing
+ # configured under it) produces {} from _af*_entry_from_device,
+ # which _device_to_argspec silently omits -- invisible to both
+ # want and norm_have, so dict_op's purge above (which only
+ # ever iterates have's own keys) can never generate a delete
+ # for it. These names are, by construction, guaranteed absent
+ # from norm4/norm6, so there's no risk of duplicating a delete
+ # dict_op already issued.
+ for name in raw4 or {}:
+ if name not in norm4 and name not in want4:
+ commands.append(("delete", _BASE4 + [name]))
+ for name in raw6 or {}:
+ if name not in norm6 and name not in want6:
+ commands.append(("delete", _BASE6 + [name]))
+ elif state == "replaced":
+ for entry in config:
+ name = entry.get("name")
+ if not name:
+ continue
+ for af in entry.get("address_family") or []:
+ afi = af.get("afi")
+ if afi == "ipv4":
+ commands += dict_op(
+ want4.get(name, {}),
+ norm4.get(name, {}),
+ _BASE4 + [name],
+ op="purge",
+ )
+ elif afi == "ipv6":
+ commands += dict_op(
+ want6.get(name, {}),
+ norm6.get(name, {}),
+ _BASE6 + [name],
+ op="purge",
+ )
+ commands += dict_op(want4, norm4, _BASE4, op="set")
+ commands += dict_op(want6, norm6, _BASE6, op="set")
+ return commands
+
+
+_AUTH_OPTIONS = dict(
+ plaintext_password=dict(type="str", no_log=True),
+ md5_key=dict(
+ type="dict",
+ no_log=True,
options=dict(
- name=dict(type="str", required=True),
- address_family=dict(
- type="list",
- elements="dict",
- options=dict(
- afi=dict(type="str", choices=["ipv4", "ipv6"], required=True),
- authentication=dict(
- type="dict",
- options=dict(
- plaintext_password=dict(type="str", no_log=True),
- md5_key=dict(
- type="dict",
- no_log=True,
- options=dict(
- key_id=dict(type="int"),
- key=dict(type="str", no_log=True),
- ),
- ),
- ),
- ),
- bandwidth=dict(type="int"),
- cost=dict(type="int"),
- dead_interval=dict(type="int"),
- hello_interval=dict(type="int"),
- ifmtu=dict(type="int"),
- instance=dict(type="str"),
- mtu_ignore=dict(type="bool"),
- network=dict(
- type="str",
- choices=[
- "broadcast",
- "non-broadcast",
- "point-to-multipoint",
- "point-to-point",
- ],
- ),
- passive=dict(type="bool"),
- priority=dict(type="int"),
- retransmit_interval=dict(type="int"),
- transmit_delay=dict(type="int"),
- ),
- ),
+ key_id=dict(type="int"),
+ key=dict(type="str", no_log=True),
),
),
+)
+
+_AF_OPTIONS = dict(
+ afi=dict(type="str", choices=["ipv4", "ipv6"], required=True),
+ area=dict(type="str"),
+ authentication=dict(type="dict", options=_AUTH_OPTIONS),
+ bandwidth=dict(type="int"),
+ cost=dict(type="int"),
+ dead_interval=dict(type="int"),
+ hello_interval=dict(type="int"),
+ ifmtu=dict(type="int"),
+ instance=dict(type="str"),
+ mtu_ignore=dict(type="bool"),
+ network=dict(
+ type="str",
+ choices=["broadcast", "non-broadcast", "point-to-multipoint", "point-to-point"],
+ ),
+ passive=dict(type="bool"),
+ priority=dict(type="int"),
+ retransmit_interval=dict(type="int"),
+ transmit_delay=dict(type="int"),
+)
+
+_ENTRY_OPTIONS = dict(
+ name=dict(type="str", required=True),
+ address_family=dict(type="list", elements="dict", options=_AF_OPTIONS),
+)
+
+ARGUMENT_SPEC = dict(
+ config=dict(type="list", elements="dict", options=_ENTRY_OPTIONS),
state=dict(
default="merged",
choices=["merged", "replaced", "overridden", "deleted", "gathered"],
@@ -510,23 +510,36 @@ def main():
state = module.params["state"]
config = module.params.get("config") or []
- have = get_running_config(vyos)
+ validation_error = _validate_config(config)
+ if validation_error:
+ module.fail_json(msg=validation_error)
+
+ raw_have = get_running_config(vyos)
+ have = _device_to_argspec(*raw_have)
+ for iface in have:
+ for af in iface.get("address_family") or []:
+ cast_by_spec(af, _AF_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 iface in after:
+ for af in iface.get("address_family") or []:
+ cast_by_spec(af, _AF_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/ospf_interfaces_ipv4.json b/tests/unit/fixtures/ospf_interfaces_ipv4.json
index 1f199a6..9b40f66 100644
--- a/tests/unit/fixtures/ospf_interfaces_ipv4.json
+++ b/tests/unit/fixtures/ospf_interfaces_ipv4.json
@@ -1,4 +1,13 @@
{
- "eth1": { "cost": "100", "transmit-delay": "50", "priority": "26" },
- "eth2": { "cost": "200" }
+ "eth1": {
+ "cost": "100",
+ "transmit-delay": "50",
+ "priority": "26",
+ "dead-interval": "40",
+ "mtu-ignore": {},
+ "authentication": { "md5": { "key-id": { "10": { "md5-key": "secret" } } } }
+ },
+ "eth2": {
+ "cost": "200"
+ }
}
diff --git a/tests/unit/fixtures/ospf_interfaces_ipv6.json b/tests/unit/fixtures/ospf_interfaces_ipv6.json
index 580cecf..5d96817 100644
--- a/tests/unit/fixtures/ospf_interfaces_ipv6.json
+++ b/tests/unit/fixtures/ospf_interfaces_ipv6.json
@@ -1,4 +1,9 @@
{
- "eth1": { "dead-interval": "39", "passive": {} },
+ "eth1": {
+ "dead-interval": "39",
+ "passive": {},
+ "instance-id": "5",
+ "ifmtu": "1500"
+ },
"eth2": {}
}
diff --git a/tests/unit/modules/test_vyos_ospf_interfaces.py b/tests/unit/modules/test_vyos_ospf_interfaces.py
index 4d6be0d..ee58f23 100644
--- a/tests/unit/modules/test_vyos_ospf_interfaces.py
+++ b/tests/unit/modules/test_vyos_ospf_interfaces.py
@@ -4,30 +4,34 @@ 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_ospf_interfaces import (
- _parse_ipv4_iface,
- _parse_ipv6_iface,
+ _AF_OPTIONS,
+ ARGUMENT_SPEC,
+ _af4_entry_from_device,
+ _af4_entry_to_device,
+ _af6_entry_from_device,
+ _af6_entry_to_device,
+ _auth_from_device,
+ _auth_to_device,
+ _derive_key_field,
+ _device_to_argspec,
+ _kebab_fields,
build_commands,
+ cast_by_spec,
get_running_config,
)
+from .base import load_fixture
+
_BASE4 = ["protocols", "ospf", "interface"]
_BASE6 = ["protocols", "ospfv3", "interface"]
-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)
-
-
class VyOSModuleTestCase(unittest.TestCase):
def setUp(self):
self.mock_vyos = MagicMock()
@@ -37,111 +41,217 @@ class VyOSModuleTestCase(unittest.TestCase):
side_effect=lambda path: (self.ipv4_fixture if path == _BASE4 else self.ipv6_fixture),
)
+ def gather(self):
+ raw4, raw6 = get_running_config(self.mock_vyos)
+ have = _device_to_argspec(raw4, raw6)
+ for iface in have:
+ for af in iface.get("address_family") or []:
+ cast_by_spec(af, _AF_OPTIONS)
+ return have
+
+
+class TestGetRunningConfig(VyOSModuleTestCase):
+ def test_returns_both_afi_trees_separately(self):
+ raw4, raw6 = get_running_config(self.mock_vyos)
+ self.assertIn("eth1", raw4)
+ self.assertIn("eth1", raw6)
+
+ def test_empty_config(self):
+ self.mock_vyos.get_config = MagicMock(return_value=None)
+ raw4, raw6 = get_running_config(self.mock_vyos)
+ self.assertEqual(raw4, {})
+ self.assertEqual(raw6, {})
+
+
+class TestDeriveKeyField(unittest.TestCase):
+ def test_derives_name(self):
+ opts = ARGUMENT_SPEC["config"]["options"]
+ self.assertEqual(_derive_key_field(opts), "name")
+
+ def test_derives_afi(self):
+ af_opts = ARGUMENT_SPEC["config"]["options"]["address_family"]["options"]
+ self.assertEqual(_derive_key_field(af_opts), "afi")
+
+
+class TestKebabFields(unittest.TestCase):
+ """Regression coverage for the confirmed bug from vyos_ospfv2's
+ build, applied here from the start: dict_op requires have's keys
+ to already be genuine device kebab-case, but autoclean deliberately
+ leaves keys as given."""
+
+ def test_converts_multiword_keys(self):
+ result = _kebab_fields({"dead_interval": 40, "mtu_ignore": True})
+ self.assertEqual(result, {"dead-interval": 40, "mtu-ignore": {}})
+
+
+class TestAuthentication(unittest.TestCase):
+ """IPv4-only. Confirmed against vyos-1x: md5 is a fixed "key-id"
+ node containing a single tagNode entry, not a list."""
+
+ def test_plaintext_to_device(self):
+ result = _auth_to_device({"plaintext_password": "pw"})
+ self.assertEqual(result, {"plaintext-password": "pw"})
+
+ def test_md5_to_device(self):
+ result = _auth_to_device({"md5_key": {"key_id": 10, "key": "secret"}})
+ self.assertEqual(result, {"md5": {"key-id": {"10": {"md5-key": "secret"}}}})
-class TestVyOSOspfInterfacesParse(VyOSModuleTestCase):
-
- def test_parse_ipv4_scalars(self):
- raw = {"cost": "100", "transmit-delay": "50", "priority": "26"}
- result = _parse_ipv4_iface(raw)
- self.assertEqual(result["afi"], "ipv4")
- self.assertEqual(result["cost"], 100)
- self.assertEqual(result["transmit_delay"], 50)
- self.assertEqual(result["priority"], 26)
-
- def test_parse_ipv4_md5_auth(self):
- raw = {"authentication": {"md5": {"key-id": {"10": {"md5-key": "secret"}}}}}
- result = _parse_ipv4_iface(raw)
- self.assertEqual(result["authentication"]["md5_key"]["key_id"], 10)
- self.assertEqual(result["authentication"]["md5_key"]["key"], "secret")
-
- def test_parse_ipv6_scalars(self):
- raw = {"dead-interval": "39", "passive": {}}
- result = _parse_ipv6_iface(raw)
- self.assertEqual(result["afi"], "ipv6")
- self.assertEqual(result["dead_interval"], 39)
- self.assertTrue(result["passive"])
-
- def test_get_running_config(self):
- result = get_running_config(self.mock_vyos)
- names = [e["name"] for e in result]
+ def test_md5_from_device(self):
+ entry = _auth_from_device({"md5": {"key-id": {"10": {"md5-key": "secret"}}}})
+ self.assertEqual(entry["md5_key"], {"key_id": 10, "key": "secret"})
+
+ def test_empty(self):
+ self.assertEqual(_auth_to_device({}), {})
+ self.assertIsNone(_auth_from_device({}))
+
+
+class TestAf6InstanceRename(unittest.TestCase):
+ """Confirmed genuine rename: the argspec's "instance" (matching
+ the CLI collection's naming) maps to the device's actual leaf
+ name "instance-id"."""
+
+ def test_to_device(self):
+ result = _af6_entry_to_device({"instance": "5"})
+ self.assertEqual(result, {"instance-id": "5"})
+
+ def test_from_device(self):
+ entry = _af6_entry_from_device({"instance-id": "5"})
+ self.assertEqual(entry["instance"], "5")
+
+
+class TestAf4EntryToDeviceFromDevice(unittest.TestCase):
+ def test_generic_fields(self):
+ result = _af4_entry_to_device({"cost": 100, "priority": 26})
+ self.assertEqual(result, {"cost": 100, "priority": 26})
+
+ def test_mtu_ignore_presence(self):
+ result = _af4_entry_to_device({"mtu_ignore": True})
+ self.assertEqual(result, {"mtu-ignore": {}})
+
+ def test_from_device_with_authentication(self):
+ entry = _af4_entry_from_device(
+ {"cost": "100", "authentication": {"plaintext-password": "pw"}},
+ )
+ self.assertEqual(entry["authentication"]["plaintext_password"], "pw")
+
+
+class TestDeviceToArgspecFixture(VyOSModuleTestCase):
+ def test_both_interfaces_present(self):
+ have = self.gather()
+ names = [i["name"] for i in have]
self.assertIn("eth1", names)
self.assertIn("eth2", names)
- eth1 = next(e for e in result if e["name"] == "eth1")
- afis = {af["afi"] for af in eth1["address_family"]}
- self.assertIn("ipv4", afis)
- self.assertIn("ipv6", afis)
- def test_get_running_config_empty(self):
- self.mock_vyos.get_config = MagicMock(return_value={})
- result = get_running_config(self.mock_vyos)
- self.assertEqual(result, [])
+ def test_eth1_has_both_afis(self):
+ have = self.gather()
+ eth1 = next(i for i in have if i["name"] == "eth1")
+ afis = {af["afi"] for af in eth1["address_family"]}
+ self.assertEqual(afis, {"ipv4", "ipv6"})
+ def test_eth2_ipv4_only_minimal(self):
+ """eth2's ipv6 fixture entry is empty ({}) -- confirms an
+ interface with config on only one AFI doesn't spuriously
+ appear on the other."""
+ have = self.gather()
+ eth2 = next(i for i in have if i["name"] == "eth2")
+ afis = {af["afi"] for af in eth2["address_family"]}
+ self.assertEqual(afis, {"ipv4"})
-class TestVyOSOspfInterfacesBuildCommands(unittest.TestCase):
+ def test_ipv4_fields_parsed(self):
+ have = self.gather()
+ eth1 = next(i for i in have if i["name"] == "eth1")
+ af4 = next(af for af in eth1["address_family"] if af["afi"] == "ipv4")
+ self.assertEqual(af4["cost"], 100)
+ self.assertTrue(af4["mtu_ignore"])
+ self.assertEqual(af4["authentication"]["md5_key"]["key_id"], 10)
- def test_deleted_all(self):
- have = [
- {
- "name": "eth1",
- "address_family": [{"afi": "ipv4", "cost": 100}, {"afi": "ipv6", "passive": True}],
- },
- ]
- cmds = build_commands([], have, "deleted")
- self.assertIn(("delete", _BASE4 + ["eth1"]), cmds)
- self.assertIn(("delete", _BASE6 + ["eth1"]), cmds)
+ def test_ipv6_fields_parsed(self):
+ have = self.gather()
+ eth1 = next(i for i in have if i["name"] == "eth1")
+ af6 = next(af for af in eth1["address_family"] if af["afi"] == "ipv6")
+ self.assertTrue(af6["passive"])
+ self.assertEqual(af6["instance"], "5")
+ self.assertEqual(af6["ifmtu"], 1500)
- def test_deleted_specific(self):
- have = [
- {"name": "eth1", "address_family": [{"afi": "ipv4", "cost": 100}]},
- {"name": "eth2", "address_family": [{"afi": "ipv4", "cost": 200}]},
- ]
- cmds = build_commands([{"name": "eth1"}], have, "deleted")
- self.assertIn(("delete", _BASE4 + ["eth1"]), cmds)
- paths = [c[1] for c in cmds]
- self.assertNotIn(_BASE4 + ["eth2"], paths)
+ def test_empty(self):
+ self.assertEqual(_device_to_argspec({}, {}), [])
- def test_merged_adds_cost(self):
- config = [{"name": "eth1", "address_family": [{"afi": "ipv4", "cost": 100}]}]
- cmds = build_commands(config, [], "merged")
- self.assertIn(("set", _BASE4 + ["eth1", "cost", "100"]), cmds)
- def test_merged_idempotent(self):
- have = [{"name": "eth1", "address_family": [{"afi": "ipv4", "cost": 100}]}]
- config = [{"name": "eth1", "address_family": [{"afi": "ipv4", "cost": 100}]}]
- cmds = build_commands(config, have, "merged")
- self.assertEqual(cmds, [])
+class TestBuildCommands(VyOSModuleTestCase):
+ def test_merged_idempotent_against_own_fixture(self):
+ have = self.gather()
+ raw4, raw6 = get_running_config(self.mock_vyos)
+ self.assertEqual(build_commands(have, (raw4, raw6), "merged"), [])
- def test_merged_ipv6_passive(self):
- config = [{"name": "eth1", "address_family": [{"afi": "ipv6", "passive": True}]}]
- cmds = build_commands(config, [], "merged")
- self.assertIn(("set", _BASE6 + ["eth1", "passive"]), cmds)
+ def test_replaced_idempotent_against_own_fixture(self):
+ have = self.gather()
+ raw4, raw6 = get_running_config(self.mock_vyos)
+ self.assertEqual(build_commands(have, (raw4, raw6), "replaced"), [])
- def test_overridden_removes_extra_interface(self):
- have = [
- {"name": "eth1", "address_family": [{"afi": "ipv4", "cost": 100}]},
- {"name": "eth2", "address_family": [{"afi": "ipv4", "cost": 200}]},
- ]
+ def test_clear_omitted_attribute_on_replaced(self):
+ """Primary confirmed bug: the original's clear-on-omit logic
+ only fired for op="replace", which was never actually passed
+ anywhere in build_commands -- genuinely dead code, since
+ "replaced" already deleted and recreated the whole AFI block
+ before calling it with an empty have."""
+ raw4 = {"eth1": {"cost": "100", "priority": "26"}}
config = [{"name": "eth1", "address_family": [{"afi": "ipv4", "cost": 100}]}]
- cmds = build_commands(config, have, "overridden")
- self.assertIn(("delete", _BASE4 + ["eth2"]), cmds)
+ cmds = build_commands(config, (raw4, {}), "replaced")
+ self.assertIn(("delete", _BASE4 + ["eth1", "priority"]), cmds)
- def test_replaced_idempotent(self):
- have = [{"name": "eth1", "address_family": [{"afi": "ipv4", "cost": 200}]}]
- config = [{"name": "eth1", "address_family": [{"afi": "ipv4", "cost": 200}]}]
- cmds = build_commands(config, have, "replaced")
+ def test_replaced_does_not_touch_unrelated_interfaces(self):
+ """Confirmed fix for the original's disruptive "delete
+ everything and recreate" replaced heuristic."""
+ raw4 = {"eth1": {"cost": "100"}, "eth2": {"cost": "200"}}
+ config = [{"name": "eth1", "address_family": [{"afi": "ipv4", "cost": 100}]}]
+ cmds = build_commands(config, (raw4, {}), "replaced")
self.assertEqual(cmds, [])
- def test_replaced_rebuilds_on_change(self):
- have = [
+ def test_ipv4_and_ipv6_target_separate_device_trees(self):
+ config = [
{
"name": "eth1",
- "address_family": [{"afi": "ipv4", "cost": 100, "transmit_delay": 50}],
+ "address_family": [
+ {"afi": "ipv4", "cost": 100},
+ {"afi": "ipv6", "passive": True},
+ ],
},
]
- config = [{"name": "eth1", "address_family": [{"afi": "ipv4", "cost": 200}]}]
- cmds = build_commands(config, have, "replaced")
+ cmds = build_commands(config, ({}, {}), "merged")
+ self.assertIn(("set", _BASE4 + ["eth1", "cost", "100"]), cmds)
+ self.assertIn(("set", _BASE6 + ["eth1", "passive"]), cmds)
+
+ def test_overridden_removes_omitted_interface(self):
+ raw4 = {"eth1": {"cost": "100"}, "eth3": {"cost": "50"}}
+ config = [{"name": "eth1", "address_family": [{"afi": "ipv4", "cost": 100}]}]
+ cmds = build_commands(config, (raw4, {}), "overridden")
+ self.assertIn(("delete", _BASE4 + ["eth3"]), cmds)
+
+ def test_deleted_all_covers_both_trees(self):
+ cmds = build_commands([], ({"eth1": {}}, {"eth1": {}}), "deleted")
self.assertIn(("delete", _BASE4 + ["eth1"]), cmds)
- self.assertIn(("set", _BASE4 + ["eth1", "cost", "200"]), cmds)
+ self.assertIn(("delete", _BASE6 + ["eth1"]), cmds)
+ self.assertEqual(len(cmds), 2)
+
+ def test_deleted_named_no_afi_removes_both(self):
+ cmds = build_commands([{"name": "eth1"}], ({"eth1": {}}, {"eth1": {}}), "deleted")
+ self.assertIn(("delete", _BASE4 + ["eth1"]), cmds)
+ self.assertIn(("delete", _BASE6 + ["eth1"]), cmds)
+ self.assertEqual(len(cmds), 2)
+
+ def test_deleted_named_specific_afi_only(self):
+ config = [{"name": "eth1", "address_family": [{"afi": "ipv4"}]}]
+ cmds = build_commands(config, ({"eth1": {}}, {"eth1": {}}), "deleted")
+ self.assertEqual(cmds, [("delete", _BASE4 + ["eth1"])])
+
+ def test_deleted_named_nonexistent_is_noop(self):
+ cmds = build_commands([{"name": "eth99"}], ({"eth1": {}}, {}), "deleted")
+ self.assertEqual(cmds, [])
+
+ def test_merged_adds_cost(self):
+ config = [{"name": "eth1", "address_family": [{"afi": "ipv4", "cost": 100}]}]
+ cmds = build_commands(config, ({}, {}), "merged")
+ self.assertIn(("set", _BASE4 + ["eth1", "cost", "100"]), cmds)
if __name__ == "__main__":