diff options
| author | Daniil Baturin <daniil@vyos.io> | 2026-09-28 12:18:50 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-28 12:18:50 +0100 |
| commit | 98a1ee6719371d26b70a3208e6ede37cb158accb (patch) | |
| tree | d7021560c5e302d7786190053678d6835aa4729a /plugins | |
| parent | 332a4fba3e9d0824f55c4fc5efd22091fee49c60 (diff) | |
| parent | a92fab433125c75e80bde7796db962be4d0a4c7f (diff) | |
| download | rest.vyos-T8989_ospv2_dict_op_refactor.tar.gz rest.vyos-T8989_ospv2_dict_op_refactor.zip | |
Merge branch 'main' into T8989_ospv2_dict_op_refactorT8989_ospv2_dict_op_refactor
Diffstat (limited to 'plugins')
| -rw-r--r-- | plugins/module_utils/vyos.py | 6 | ||||
| -rw-r--r-- | plugins/modules/vyos_l3_interfaces.py | 372 | ||||
| -rw-r--r-- | plugins/modules/vyos_ospfv3.py | 367 |
3 files changed, 403 insertions, 342 deletions
diff --git a/plugins/module_utils/vyos.py b/plugins/module_utils/vyos.py index f7c17f9..b7a519e 100644 --- a/plugins/module_utils/vyos.py +++ b/plugins/module_utils/vyos.py @@ -320,7 +320,8 @@ class VyOSModule: that's actually already correct. """ result = self._client.retrieve_return_value(path) - return result.get("data") or "" + data = result.get("data") + return "" if data is None else data def apply_commands(self, commands): if not commands: @@ -372,7 +373,8 @@ class VyOSModule: rather than being indistinguishable from a valid empty response. """ result = self._client.show(path) - return result.get("data") or "" + data = result.get("data") + return "" if data is None else data def save_config(self, file_path=None): """Save the running configuration to disk.""" diff --git a/plugins/modules/vyos_l3_interfaces.py b/plugins/modules/vyos_l3_interfaces.py index 9b33973..c9c6deb 100644 --- a/plugins/modules/vyos_l3_interfaces.py +++ b/plugins/modules/vyos_l3_interfaces.py @@ -14,7 +14,13 @@ short_description: Manage L3 interface configuration on VyOS devices via REST AP description: - Manages IPv4 and IPv6 address configuration on VyOS interfaces using the HTTPS REST API. - - Mirrors C(vyos.vyos.vyos_l3_interfaces) but uses the HTTP API instead of CLI. + - >- + L2 attributes (description, mtu, duplex, speed, vrf) and VIF + presence/L2 attributes are owned by M(vyos.rest.vyos_interfaces), not + this module -- confirmed by design: a VIF's device path is shared + between the two modules, and this module's own commands only ever + touch the C(address) leaf within it, never the VIF subtree as a whole + or any of vyos_interfaces' own fields. version_added: "1.0.0" author: - VyOS Community (@vyos) @@ -71,9 +77,6 @@ options: address: description: IPv6 address in CIDR notation, C(dhcpv6), or C(auto-config). type: str - running_config: - description: Used only with state C(parsed). - type: str state: description: - C(merged) - Add addresses without removing existing ones. @@ -81,10 +84,8 @@ options: - C(overridden) - Replace addresses for all interfaces. - C(deleted) - Remove listed or all interface addresses. - C(gathered) - Read interface addresses 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 seealso: - module: vyos.vyos.vyos_l3_interfaces @@ -142,14 +143,6 @@ gathered: description: Current L3 interface configuration as structured data. returned: when state is gathered type: list -rendered: - description: Commands for the 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 @@ -161,10 +154,16 @@ 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, to_tag_dict + +_BASE = ["interfaces"] -_IFACE_TYPE = { +# Aligned with vyos_interfaces' own 11-type table -- confirmed +# inconsistent in the original (missing ppp/wlan here specifically), +# corrected for consistency between the two modules that share the +# same interface namespace. +_IFACE_TYPE_PREFIX = { "eth": "ethernet", "bond": "bonding", "lo": "loopback", @@ -173,23 +172,42 @@ _IFACE_TYPE = { "vti": "vti", "dum": "dummy", "vtun": "openvpn", + "ppp": "pppoe", + "wlan": "wireless", "br": "bridge", } -def _iface_type(name): - for prefix, itype in _IFACE_TYPE.items(): +def _guess_iface_type(name): + for prefix, itype in _IFACE_TYPE_PREFIX.items(): if name.startswith(prefix): return itype return "ethernet" -def _iface_base(name): - return ["interfaces", _iface_type(name), name] +def _resolve_iface_type(name, raw_have): + """Prefer the real type from the device's own raw response over a + name-prefix guess -- only fall back to guessing for a brand-new + interface that doesn't exist on the device yet. Same fix as + vyos_interfaces' own confirmed bug: the original guessed + unconditionally, even for interfaces the device already knows the + real type of. + """ + for itype, ifaces in (raw_have or {}).items(): + if name in to_tag_dict(ifaces): + return itype + return _guess_iface_type(name) + + +def _iface_base(name, raw_have): + return _BASE + [_resolve_iface_type(name, raw_have), name] def _addr_list(raw): - """Normalize address field — string or list → sorted list.""" + """VyOS's address leaf collapses to a bare string for a single + value, or a list for multiple -- confirmed device behavior, + handled explicitly here since it's a plain multi-value leaf, not + a tag-node (to_tag_dict doesn't apply).""" if not raw: return [] if isinstance(raw, str): @@ -200,92 +218,105 @@ def _addr_list(raw): def _split_addresses(addresses): - """Split address list into ipv4 and ipv6 lists.""" ipv4 = [] ipv6 = [] for addr in addresses: - if addr in ("dhcp", "dhcpv6"): - if addr == "dhcp": - ipv4.append(addr) - else: - ipv6.append(addr) - elif ":" in addr: + if addr == "dhcp": + ipv4.append(addr) + elif addr in ("dhcpv6", "auto-config") or ":" in addr: ipv6.append(addr) else: ipv4.append(addr) return sorted(ipv4), sorted(ipv6) -def _parse_iface(name, idata): - """Parse raw API interface data into argspec format.""" - idata = idata or {} - entry = {"name": name} - - addrs = _addr_list(idata.get("address")) - if addrs: - ipv4, ipv6 = _split_addresses(addrs) - if ipv4: - entry["ipv4"] = [{"address": a} for a in ipv4] - if ipv6: - entry["ipv6"] = [{"address": a} for a in ipv6] - - vif_data = idata.get("vif") or {} - if isinstance(vif_data, dict) and vif_data: - vifs = [] - for vlan_id, vdata in sorted(vif_data.items(), key=lambda x: int(x[0])): - vdata = vdata or {} - vif = {"vlan_id": int(vlan_id)} - vaddrs = _addr_list(vdata.get("address")) - if vaddrs: - vipv4, vipv6 = _split_addresses(vaddrs) - if vipv4: - vif["ipv4"] = [{"address": a} for a in vipv4] - if vipv6: - vif["ipv6"] = [{"address": a} for a in vipv6] - vifs.append(vif) - if vifs: - entry["vifs"] = vifs - - return entry +def _parse_addr_entry(idata): + """Returns (ipv4_argspec_list, ipv6_argspec_list) for one address + leaf's raw value -- shared by both interface-level and vif-level + parsing.""" + addrs = _addr_list((idata or {}).get("address")) + if not addrs: + return [], [] + ipv4, ipv6 = _split_addresses(addrs) + return ( + [{"address": a} for a in ipv4], + [{"address": a} for a in ipv6], + ) def get_running_config(vyos): - raw = vyos.get_config(["interfaces"]) - if not raw or not isinstance(raw, dict): - return [] + """VyOS's REST API collapses a single-child tag node to a plain + string (or a list for multiple) -- confirmed as a real failure + mode during vyos_ospf_interfaces's build. Normalizing through + to_tag_dict unconditionally means callers always receive a + genuine dict. + """ + return to_tag_dict(vyos.get_config(_BASE) or {}) + +def _device_to_argspec(raw): result = [] - for itype, ifaces in sorted(raw.items()): - if not isinstance(ifaces, dict): - continue - for iname, idata in sorted(ifaces.items()): - entry = _parse_iface(iname, idata) - # only include if there's at least one address or vif + for itype, ifaces in sorted((raw or {}).items()): + for name, idata in sorted(to_tag_dict(ifaces).items()): + idata = idata or {} + entry = {"name": name} + + ipv4, ipv6 = _parse_addr_entry(idata) + if ipv4: + entry["ipv4"] = ipv4 + if ipv6: + entry["ipv6"] = ipv6 + + vif_raw = idata.get("vif") + if vif_raw: + vifs = [] + for vlan_id, vdata in sorted(to_tag_dict(vif_raw).items(), key=lambda x: int(x[0])): + vipv4, vipv6 = _parse_addr_entry(vdata) + if vipv4 or vipv6: + vif = {"vlan_id": int(vlan_id)} + if vipv4: + vif["ipv4"] = vipv4 + if vipv6: + vif["ipv6"] = vipv6 + vifs.append(vif) + if vifs: + entry["vifs"] = vifs + if entry.get("ipv4") or entry.get("ipv6") or entry.get("vifs"): result.append(entry) - return result def _normalize(config): - """Convert argspec list to dict keyed by interface name.""" + """Argspec list -> dict keyed by interface name, with address + lists flattened to plain sorted string lists for set-diffing.""" result = {} for entry in config or []: - name = entry["name"] - ipv4 = sorted([a["address"] for a in (entry.get("ipv4") or [])]) - ipv6 = sorted([a["address"] for a in (entry.get("ipv6") or [])]) + name = entry.get("name") + if not name: + continue + ipv4 = sorted(a["address"] for a in (entry.get("ipv4") or []) if a.get("address")) + ipv6 = sorted(a["address"] for a in (entry.get("ipv6") or []) if a.get("address")) vifs = {} for vif in entry.get("vifs") or []: - vid = vif["vlan_id"] - vipv4 = sorted([a["address"] for a in (vif.get("ipv4") or [])]) - vipv6 = sorted([a["address"] for a in (vif.get("ipv6") or [])]) + vid = vif.get("vlan_id") + if vid is None: + continue + vipv4 = sorted(a["address"] for a in (vif.get("ipv4") or []) if a.get("address")) + vipv6 = sorted(a["address"] for a in (vif.get("ipv6") or []) if a.get("address")) vifs[vid] = {"ipv4": vipv4, "ipv6": vipv6} result[name] = {"ipv4": ipv4, "ipv6": ipv6, "vifs": vifs} return result def _addr_cmds(base, want_addrs, have_addrs, state): - """Generate set/delete commands for address lists.""" + """Address is a plain multi-value leaf, not a keyed/tag-node + section -- a set-diff is the correct, direct mechanism here + (dict_op's keyed-entry model doesn't fit a bare value list). + Confirmed scoped to exactly the "address" leaf under base, + never anything else -- base may be an interface or a vif, and + in neither case does this function ever touch a sibling leaf. + """ cmds = [] want_set = set(want_addrs) have_set = set(have_addrs) @@ -300,15 +331,25 @@ def _addr_cmds(base, want_addrs, have_addrs, state): return cmds -def _vif_cmds(iface_base, want_vifs, have_vifs, state): - """Generate commands for VIF subinterfaces.""" +def _vif_addr_cmds(iface_base, want_vifs, have_vifs, state): + """VIF address commands, explicitly scoped to only the address + leaf within each VIF -- never the VIF subtree as a whole. + + Confirmed severe bug in the original: an omitted VIF under + replaced/overridden/deleted generated a whole-VIF delete + (`delete ... vif <id>`), which would also destroy the VIF's own + description/mtu/disable settings -- fields owned by + vyos_interfaces, not this module. The same class of bug just + confirmed and fixed in vyos_interfaces for interface-level + "address" leaking the other way. Here, an omitted VIF under any + state only ever has its own known addresses individually deleted; + the VIF's own presence/other fields are never touched. + """ cmds = [] - if state in ("replaced", "overridden"): - for vid in set(have_vifs) - set(want_vifs): - cmds.append(("delete", iface_base + ["vif", str(vid)])) - - for vid, want_vif in want_vifs.items(): + all_vids = set(have_vifs) | set(want_vifs) + for vid in all_vids: + want_vif = want_vifs.get(vid, {"ipv4": [], "ipv6": []}) have_vif = have_vifs.get(vid, {"ipv4": [], "ipv6": []}) vif_base = iface_base + ["vif", str(vid)] cmds += _addr_cmds(vif_base, want_vif["ipv4"], have_vif["ipv4"], state) @@ -317,144 +358,119 @@ def _vif_cmds(iface_base, want_vifs, have_vifs, state): return cmds -def build_commands(config, have_raw, state): - cmds = [] - have_map = _normalize(have_raw) +def build_commands(config, raw_have, state): + raw_have = raw_have or {} + have_list = _device_to_argspec(raw_have) + have_map = _normalize(have_list) if state == "deleted": + cmds = [] if not config: for name, have in have_map.items(): - base = _iface_base(name) - for addr in have["ipv4"] + have["ipv6"]: - cmds.append(("delete", base + ["address", addr])) - for vid in have["vifs"]: - cmds.append(("delete", base + ["vif", str(vid)])) - else: - want_map = _normalize(config) - for name, want in want_map.items(): - have = have_map.get(name, {"ipv4": [], "ipv6": [], "vifs": {}}) - base = _iface_base(name) - if not want["ipv4"] and not want["ipv6"] and not want["vifs"]: - # delete all addresses for this interface - for addr in have["ipv4"] + have["ipv6"]: + base = _iface_base(name, raw_have) + cmds += _addr_cmds(base, [], have["ipv4"], "deleted") + cmds += _addr_cmds(base, [], have["ipv6"], "deleted") + cmds += _vif_addr_cmds(base, {}, have["vifs"], "deleted") + return cmds + for entry in config or []: + name = entry.get("name") + if not name: + continue + have = have_map.get(name, {"ipv4": [], "ipv6": [], "vifs": {}}) + base = _iface_base(name, raw_have) + named_addrs = entry.get("ipv4") or entry.get("ipv6") or entry.get("vifs") + if not named_addrs: + cmds += _addr_cmds(base, [], have["ipv4"], "deleted") + cmds += _addr_cmds(base, [], have["ipv6"], "deleted") + cmds += _vif_addr_cmds(base, {}, have["vifs"], "deleted") + else: + want = _normalize([entry])[name] + for addr in want["ipv4"] + want["ipv6"]: + if addr in have["ipv4"] + have["ipv6"]: cmds.append(("delete", base + ["address", addr])) - for vid in have["vifs"]: - cmds.append(("delete", base + ["vif", str(vid)])) - else: - for addr in want["ipv4"] + want["ipv6"]: - if addr in have["ipv4"] + have["ipv6"]: - cmds.append(("delete", base + ["address", addr])) - for vid in want["vifs"]: - if vid in have["vifs"]: - cmds.append(("delete", base + ["vif", str(vid)])) + for vid, want_vif in want["vifs"].items(): + have_vif = have["vifs"].get(vid, {"ipv4": [], "ipv6": []}) + vif_base = base + ["vif", str(vid)] + want_addrs = want_vif["ipv4"] + want_vif["ipv6"] + addrs = want_addrs or (have_vif["ipv4"] + have_vif["ipv6"]) + for addr in addrs: + if addr in have_vif["ipv4"] + have_vif["ipv6"]: + cmds.append(("delete", vif_base + ["address", addr])) return cmds want_map = _normalize(config) + commands = [] if state == "overridden": for name in set(have_map) - set(want_map): have = have_map[name] - base = _iface_base(name) - for addr in have["ipv4"] + have["ipv6"]: - cmds.append(("delete", base + ["address", addr])) - for vid in have["vifs"]: - cmds.append(("delete", base + ["vif", str(vid)])) + base = _iface_base(name, raw_have) + commands += _addr_cmds(base, [], have["ipv4"], "overridden") + commands += _addr_cmds(base, [], have["ipv6"], "overridden") + commands += _vif_addr_cmds(base, {}, have["vifs"], "overridden") for name, want in want_map.items(): have = have_map.get(name, {"ipv4": [], "ipv6": [], "vifs": {}}) - base = _iface_base(name) - cmds += _addr_cmds(base, want["ipv4"], have["ipv4"], state) - cmds += _addr_cmds(base, want["ipv6"], have["ipv6"], state) - cmds += _vif_cmds(base, want["vifs"], have["vifs"], state) + base = _iface_base(name, raw_have) + commands += _addr_cmds(base, want["ipv4"], have["ipv4"], state) + commands += _addr_cmds(base, want["ipv6"], have["ipv6"], state) + commands += _vif_addr_cmds(base, want["vifs"], have["vifs"], state) - return cmds + return commands +_ADDR_OPTIONS = dict(address=dict(type="str")) + +_VIF_OPTIONS = dict( + vlan_id=dict(type="int", required=True), + ipv4=dict(type="list", elements="dict", options=_ADDR_OPTIONS), + ipv6=dict(type="list", elements="dict", options=_ADDR_OPTIONS), +) + +_ENTRY_OPTIONS = dict( + name=dict(type="str", required=True), + ipv4=dict(type="list", elements="dict", options=_ADDR_OPTIONS), + ipv6=dict(type="list", elements="dict", options=_ADDR_OPTIONS), + vifs=dict(type="list", elements="dict", options=_VIF_OPTIONS), +) + ARGUMENT_SPEC = dict( - config=dict( - type="list", - elements="dict", - options=dict( - name=dict(type="str", required=True), - ipv4=dict( - type="list", - elements="dict", - options=dict(address=dict(type="str")), - ), - ipv6=dict( - type="list", - elements="dict", - options=dict(address=dict(type="str")), - ), - vifs=dict( - type="list", - elements="dict", - options=dict( - vlan_id=dict(type="int", required=True), - ipv4=dict( - type="list", - elements="dict", - options=dict(address=dict(type="str")), - ), - ipv6=dict( - type="list", - elements="dict", - options=dict(address=dict(type="str")), - ), - ), - ), - ), - ), - 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": - # parsed is offline — just return empty for now - module.exit_json(parsed=[]) - - if state == "rendered": - # build commands without connecting - 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) 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) module.exit_json( changed=True, before=have, - after=get_running_config(vyos), + after=after, commands=commands, saved=saved, response=response, diff --git a/plugins/modules/vyos_ospfv3.py b/plugins/modules/vyos_ospfv3.py index fcf404e..5923a61 100644 --- a/plugins/modules/vyos_ospfv3.py +++ b/plugins/modules/vyos_ospfv3.py @@ -1,6 +1,7 @@ #!/usr/bin/python # -*- coding: utf-8 -*- -# GNU General Public License v3.0+ +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + from __future__ import absolute_import, division, print_function @@ -13,6 +14,22 @@ short_description: Manage OSPFv3 configuration on VyOS devices using REST API description: - Manages OSPFv3 configuration on VyOS devices via the REST API. - Uses REST API (C(connection=httpapi)) instead of CLI. + - >- + Scope matches the current vyos.vyos.vyos_ospfv3 (CLI collection) module, + confirmed against VyOS's official documentation (1.4+/1.5 LTS/rolling). + - >- + C(areas.interface) (an area-to-interface assignment list) exists in the + CLI module's argspec but was deliberately NOT carried over here -- + confirmed via VyOS's official docs across multiple versions that + C(set protocols ospfv3 area <id> interface <name>) is the superseded, + 1.3-era syntax. The current mechanism, C(set protocols ospfv3 interface + <name> area <id>), is a per-interface setting and is modeled in + M(vyos.rest.vyos_ospf_interfaces)'s C(area) field instead (and is not + affected by this module's C(state=replaced)). + - >- + C(distance) and C(graceful-restart) are real, confirmed OSPFv3 features + not modeled here, matching a genuine gap in the CLI module's own scope + rather than an oversight. version_added: "1.0.0" author: - VyOS Community (@vyos) @@ -37,7 +54,7 @@ options: description: Name of import-list. type: str range: - description: Summarize routes matching prefix. + description: Summarize routes matching prefix (border routers only). type: list elements: dict suboptions: @@ -65,6 +82,7 @@ options: suboptions: route_type: description: Protocol to redistribute. + required: true type: str choices: [bgp, connected, kernel, ripng, static] route_map: @@ -83,6 +101,9 @@ options: notes: - Requires C(ansible_connection=httpapi) with the VyOS httpapi plugin. - C(ansible_network_os) must be set to C(vyos.rest.vyos). +seealso: + - module: vyos.vyos.vyos_ospfv3 + - module: vyos.rest.vyos_ospf_interfaces """ EXAMPLES = r""" @@ -139,184 +160,201 @@ response: """ from ansible.module_utils.basic import AnsibleModule -from ansible_collections.vyos.rest.plugins.module_utils.vyos import VyOSModule +from ansible_collections.vyos.rest.plugins.module_utils.vyos import ( + VyOSModule, + autoclean, + cast_by_spec, + dict_op, + from_device, + to_tag_dict, +) _BASE = ["protocols", "ospfv3"] +def _kebab_fields(d): + """autoclean, then kebab-convert the resulting keys. + + Needed because dict_op requires have's keys to already be genuine + device kebab-case -- it only normalizes underscores to dashes for + its own lookup index, but uses have's key verbatim for the output + path. autoclean deliberately leaves keys exactly as given (dict_op + is meant to convert during its own want-vs-have comparison), which + only works when have comes straight from the device. Here, have is + reconstructed by round-tripping through this module's own entry- + transforms, so any field passed through unconverted would stay + snake_case and dict_op would have no way to recover the real + device key -- confirmed as a real bug during vyos_ospfv2's build. + Safe here since every call site is a leaf-level dict of schema + field names, never an opaque tag-node value like an area ID used + as a dict key. + """ + cleaned = autoclean(d) + return {k.replace("_", "-"): v for k, v in cleaned.items()} + + +def _derive_key_field(options_spec): + required = [k for k, spec in options_spec.items() if spec.get("required")] + if len(required) != 1: + raise ValueError( + "expected exactly one required suboption to serve as the key field, " + "found: {0}".format(required), + ) + return required[0] + + +def _keyed_list_to_device(items, key_field, entry_transform=None): + entry_transform = entry_transform or _kebab_fields + result = {} + for item in items or []: + if not item.get(key_field): + continue + rest = {k: v for k, v in item.items() if k != key_field} + result[str(item[key_field])] = entry_transform(rest) + return result + + +def _keyed_list_from_device(raw, key_field, entry_transform=None): + entry_transform = entry_transform or from_device + return [ + {key_field: key, **entry_transform(data or {})} + for key, data in sorted(to_tag_dict(raw).items()) + ] + + +# --------------------------------------------------------------------------- +# range -- confirmed against CLI: advertise/not_advertise are presence- +# only opposite flags (not both meaningful at once, though the argspec +# doesn't enforce mutual exclusivity -- matching the CLI's own scope). +# Fully generic once keyed by address. +# --------------------------------------------------------------------------- + +_RANGE_KEY = "address" + + +def _area_entry_to_device(rest): + exclude = {"range"} + device = _kebab_fields({k: v for k, v in rest.items() if k not in exclude}) + ranges = rest.get("range") or [] + if ranges: + device["range"] = _keyed_list_to_device(ranges, _RANGE_KEY) + return device + + +def _area_entry_from_device(data): + exclude = {"range"} + entry = from_device({k: v for k, v in data.items() if k not in exclude}) + range_raw = data.get("range") + if range_raw: + entry["range"] = _keyed_list_from_device(range_raw, _RANGE_KEY) + return entry + + +def _want_to_device(config): + config = config or {} + device = {} + + areas = config.get("areas") or [] + if areas: + device["area"] = _keyed_list_to_device(areas, _AREA_KEY, _area_entry_to_device) + + params_device = _kebab_fields(config.get("parameters") or {}) + if params_device: + device["parameters"] = params_device + + redist = config.get("redistribute") or [] + if redist: + device["redistribute"] = _keyed_list_to_device(redist, _REDISTRIBUTE_KEY) + + return device + + def get_running_config(vyos): - raw = vyos.get_config(_BASE) + """VyOS's REST API collapses a single-child tag node to a plain + string (or a list for multiple) -- confirmed as a real failure + mode during vyos_ospf_interfaces's build (an unguarded response + iterated character-by-character). Normalizing through to_tag_dict + unconditionally means callers always receive a genuine dict. + """ + return to_tag_dict(vyos.get_config(_BASE) or {}) + + +def _device_to_argspec(raw): if not raw or not isinstance(raw, dict): return {} - return _parse_ospfv3(raw) + entry = {} + area_raw = raw.get("area") + if area_raw: + areas = _keyed_list_from_device(area_raw, _AREA_KEY, _area_entry_from_device) + if areas: + entry["areas"] = areas -def _parse_ospfv3(raw): - result = {} + params_raw = raw.get("parameters") + if params_raw: + entry["parameters"] = from_device(params_raw) - # parameters - params = raw.get("parameters", {}) - if params: - result["parameters"] = {} - if "router-id" in params: - result["parameters"]["router_id"] = params["router-id"] - - # redistribute - redist_raw = raw.get("redistribute", {}) - if redist_raw and isinstance(redist_raw, dict): - redist = [] - for route_type, data in sorted(redist_raw.items()): - entry = {"route_type": route_type} - if isinstance(data, dict) and data.get("route-map"): - entry["route_map"] = data["route-map"] - redist.append(entry) - if redist: - result["redistribute"] = redist - - # areas - area_raw = raw.get("area", {}) - if area_raw and isinstance(area_raw, dict): - areas = [] - for area_id, area_data in sorted(area_raw.items()): - area = {"area_id": area_id} - area_data = area_data or {} - if area_data.get("export-list"): - area["export_list"] = area_data["export-list"] - if area_data.get("import-list"): - area["import_list"] = area_data["import-list"] - range_raw = area_data.get("range", {}) - if range_raw and isinstance(range_raw, dict): - ranges = [] - for prefix, rdata in sorted(range_raw.items()): - r = {"address": prefix} - rdata = rdata or {} - if "advertise" in rdata: - r["advertise"] = True - if "not-advertise" in rdata: - r["not_advertise"] = True - ranges.append(r) - if ranges: - area["range"] = ranges - areas.append(area) - if areas: - result["areas"] = areas + redist_raw = raw.get("redistribute") + if redist_raw: + entry["redistribute"] = _keyed_list_from_device(redist_raw, _REDISTRIBUTE_KEY) - return result + return entry -def build_commands(config, have, state): - cmds = [] +def build_commands(config, raw_have, state): + raw_have = raw_have or {} if state == "deleted": - if have: - cmds.append(("delete", _BASE)) - return cmds + return [("delete", _BASE)] if raw_have else [] + + want = _want_to_device(config) + norm_have = _want_to_device(_device_to_argspec(raw_have)) + commands = [] if state == "replaced": - # Build what we would set from scratch and compare to have - would_set = build_commands(config, {}, "merged") - have_set = build_commands(have, {}, "merged") - if would_set == have_set: - return [] - if have: - cmds.append(("delete", _BASE)) - have = {} - - # parameters - want_params = (config or {}).get("parameters") or {} - have_params = have.get("parameters") or {} - if want_params.get("router_id") and want_params["router_id"] != have_params.get("router_id"): - cmds.append(("set", _BASE + ["parameters", "router-id", want_params["router_id"]])) - - # redistribute - want_redist = {r["route_type"]: r for r in ((config or {}).get("redistribute") or [])} - have_redist = {r["route_type"]: r for r in (have.get("redistribute") or [])} - - for rt in set(have_redist) - set(want_redist): - if state == "merged": - pass # merged doesn't remove - for rt, entry in want_redist.items(): - if rt not in have_redist: - cmds.append(("set", _BASE + ["redistribute", rt])) - if entry.get("route_map"): - have_rm = have_redist.get(rt, {}).get("route_map") - if entry["route_map"] != have_rm: - cmds.append(("set", _BASE + ["redistribute", rt, "route-map", entry["route_map"]])) - - # areas - want_areas = {a["area_id"]: a for a in ((config or {}).get("areas") or [])} - have_areas = {a["area_id"]: a for a in (have.get("areas") or [])} - - for area_id, want_area in want_areas.items(): - have_area = have_areas.get(area_id, {}) - abase = _BASE + ["area", area_id] - - if want_area.get("export_list") and want_area["export_list"] != have_area.get( - "export_list", - ): - cmds.append(("set", abase + ["export-list", want_area["export_list"]])) - if want_area.get("import_list") and want_area["import_list"] != have_area.get( - "import_list", - ): - cmds.append(("set", abase + ["import-list", want_area["import_list"]])) - - want_ranges = {r["address"]: r for r in (want_area.get("range") or [])} - have_ranges = {r["address"]: r for r in (have_area.get("range") or [])} - - for addr in want_ranges: - if addr not in have_ranges: - cmds.append(("set", abase + ["range", addr])) - r = want_ranges[addr] - if r.get("not_advertise"): - cmds.append(("set", abase + ["range", addr, "not-advertise"])) - elif r.get("advertise"): - cmds.append(("set", abase + ["range", addr, "advertise"])) - - return cmds + commands += dict_op(want, norm_have, _BASE, op="purge") + commands += dict_op(want, norm_have, _BASE, op="set") + return commands -ARGUMENT_SPEC = dict( - config=dict( - type="dict", - options=dict( - areas=dict( - type="list", - elements="dict", - options=dict( - area_id=dict(type="str", required=True), - export_list=dict(type="str"), - import_list=dict(type="str"), - range=dict( - type="list", - elements="dict", - options=dict( - address=dict(type="str", required=True), - advertise=dict(type="bool"), - not_advertise=dict(type="bool"), - ), - ), - ), - ), - parameters=dict( - type="dict", - options=dict( - router_id=dict(type="str"), - ), - ), - redistribute=dict( - type="list", - elements="dict", - options=dict( - route_type=dict( - type="str", - choices=["bgp", "connected", "kernel", "ripng", "static"], - ), - route_map=dict(type="str"), - ), - ), - ), +_RANGE_OPTIONS = dict( + address=dict(type="str", required=True), + advertise=dict(type="bool"), + not_advertise=dict(type="bool"), +) + +_AREA_OPTIONS = dict( + area_id=dict(type="str", required=True), + export_list=dict(type="str"), + import_list=dict(type="str"), + range=dict(type="list", elements="dict", options=_RANGE_OPTIONS), +) + +_PARAMETERS_OPTIONS = dict( + router_id=dict(type="str"), +) + +_REDISTRIBUTE_OPTIONS = dict( + route_type=dict( + type="str", + required=True, + choices=["bgp", "connected", "kernel", "ripng", "static"], ), + route_map=dict(type="str"), +) + +_AREA_KEY = _derive_key_field(_AREA_OPTIONS) +_REDISTRIBUTE_KEY = _derive_key_field(_REDISTRIBUTE_OPTIONS) + +_CONFIG_OPTIONS = dict( + areas=dict(type="list", elements="dict", options=_AREA_OPTIONS), + parameters=dict(type="dict", options=_PARAMETERS_OPTIONS), + redistribute=dict(type="list", elements="dict", options=_REDISTRIBUTE_OPTIONS), +) + +ARGUMENT_SPEC = dict( + config=dict(type="dict", options=_CONFIG_OPTIONS), state=dict( default="merged", choices=["merged", "replaced", "deleted", "gathered"], @@ -331,12 +369,14 @@ def main(): state = module.params["state"] config = module.params.get("config") or {} - have = get_running_config(vyos) + raw_have = get_running_config(vyos) + have = _device_to_argspec(raw_have) + cast_by_spec(have, _CONFIG_OPTIONS) if state == "gathered": module.exit_json(changed=False, gathered=have) - commands = build_commands(config, have, state) + commands = build_commands(config, raw_have, state) if module.check_mode: module.exit_json(changed=bool(commands), commands=commands, before=have) @@ -344,10 +384,13 @@ def main(): if commands: response = vyos.apply_commands(commands) saved = vyos.save_config() + after_raw = get_running_config(vyos) + after = _device_to_argspec(after_raw) + cast_by_spec(after, _CONFIG_OPTIONS) module.exit_json( changed=True, before=have, - after=get_running_config(vyos), + after=after, commands=commands, saved=saved, response=response, |
