diff options
Diffstat (limited to 'plugins')
| -rw-r--r-- | plugins/modules/vyos_ospfv2.py | 1327 |
1 files changed, 773 insertions, 554 deletions
diff --git a/plugins/modules/vyos_ospfv2.py b/plugins/modules/vyos_ospfv2.py index 5ac333d..efdd2a5 100644 --- a/plugins/modules/vyos_ospfv2.py +++ b/plugins/modules/vyos_ospfv2.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,7 +14,25 @@ short_description: Manage OSPFv2 configuration on VyOS devices using REST API description: - Manages OSPFv2 configuration on VyOS devices via the REST API. - Uses REST API (C(connection=httpapi)) instead of CLI. - - 'In VyOS 1.5+, passive interfaces use per-interface config rather than passive-interface.' + - >- + Scope matches the current vyos.vyos.vyos_ospfv2 (CLI collection) module: + areas (including virtual_link), auto_cost, default_information, + default_metric, distance, log_adjacency_changes, max_metric, mpls_te, + neighbor, parameters, passive_interface, passive_interface_exclude, + redistribute, timers. VyOS's OSPF schema is considerably larger than + even this -- access-list, aggregation, capability, graceful-restart, + ldp-sync, maximum-paths, per-interface + tuning (bandwidth/hello-multiplier/network-type/authentication/intervals beyond passive), + segment-routing, and summary-address are not modeled here, matching + real gaps in the CLI module's own scope, not oversights. + - >- + A standalone top-level C(route_map) field exists in the CLI module's + argspec but does not correspond to any real device path -- confirmed + against a live VyOS 1.5.0 device's C(set protocols ospf) completions, + which list no C(route-map) entry at that level (only nested under + C(default-information originate) and per-protocol under + C(redistribute), both of which are modeled here). Deliberately + omitted rather than built against a confirmed non-functional field. version_added: "1.0.0" author: - VyOS Community (@vyos) @@ -103,6 +122,45 @@ options: description: Shortcut mode. type: str choices: [default, disable, enable] + virtual_link: + description: Virtual link. + type: list + elements: dict + suboptions: + address: + description: Virtual link address (router ID of the remote ABR). + type: str + required: true + authentication: + description: Virtual link authentication. + type: dict + suboptions: + md5: + description: MD5 key id based authentication. + type: list + elements: dict + suboptions: + key_id: + description: MD5 key id (1-255). + type: int + md5_key: + description: MD5 key (16 characters or less). + type: str + plaintext_password: + description: Plain text password (8 characters or less). + type: str + dead_interval: + description: Interval after which a neighbor is declared dead. + type: int + hello_interval: + description: Interval between hello packets. + type: int + retransmit_interval: + description: Interval between retransmitting lost link state advertisements. + type: int + transmit_delay: + description: Link state transmit delay. + type: int auto_cost: description: Auto-cost reference bandwidth. type: dict @@ -157,6 +215,33 @@ options: description: Log adjacency changes. type: str choices: [detail] + max_metric: + description: OSPFv2 maximum/infinite-distance metric. + type: dict + suboptions: + router_lsa: + description: Advertise own Router-LSA with infinite distance (stub router). + type: dict + suboptions: + administrative: + description: Administratively apply, for an indefinite period. + type: bool + on_shutdown: + description: Time (seconds) to advertise self as stub-router before shutdown. + type: int + on_startup: + description: Time (seconds) to advertise self as stub-router on startup. + type: int + mpls_te: + description: MPLS Traffic Engineering parameters. + type: dict + suboptions: + enabled: + description: Enable MPLS-TE functionality. + type: bool + router_address: + description: Stable IP address of the advertising router. + type: str neighbor: description: OSPF neighbors. type: list @@ -190,9 +275,16 @@ options: description: Router ID. type: str passive_interface: - description: > - Passive interfaces (VyOS 1.5+: configured via - C(protocols ospf interface <name> passive)). + description: >- + Interfaces to suppress routing updates on, via per-interface + configuration (C(protocols ospf interface <name> passive)). + type: list + elements: str + passive_interface_exclude: + description: >- + Interfaces to explicitly exclude from passive mode (via + C(protocols ospf interface <name> passive disable)) -- e.g. + when passive is otherwise applied broadly. type: list elements: str redistribute: @@ -213,6 +305,34 @@ options: route_map: description: Route map. type: str + timers: + description: Routing timers. + type: dict + suboptions: + refresh: + description: Refresh parameters. + type: dict + suboptions: + timers: + description: Refresh timer (seconds). + type: int + throttle: + description: Throttling adaptive timers. + type: dict + suboptions: + spf: + description: SPF timers. + type: dict + suboptions: + delay: + description: Delay (ms) from first change received to SPF calculation. + type: int + initial_holdtime: + description: Initial hold time (ms) between consecutive SPF calculations. + type: int + max_holdtime: + description: Maximum hold time (ms). + type: int state: description: - Desired state. @@ -226,8 +346,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). - - VyOS 1.5+ uses per-interface passive configuration rather than - the global C(passive-interface) command used in VyOS 1.4. +seealso: + - module: vyos.vyos.vyos_ospfv2 """ EXAMPLES = r""" @@ -245,10 +365,6 @@ EXAMPLES = r""" normal: true network: - address: 192.0.2.0/24 - - area_id: "3" - area_type: - nssa: - set: true - area_id: "4" area_type: stub: @@ -261,6 +377,16 @@ EXAMPLES = r""" metric: 10 passive_interface: - eth1 + max_metric: + router_lsa: + administrative: true + mpls_te: + enabled: true + router_address: 192.0.11.11 + timers: + throttle: + spf: + delay: 200 state: merged - name: Delete all OSPFv2 configuration @@ -300,618 +426,706 @@ 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", "ospf"] -def _parse_areas(raw_areas): - if not raw_areas or not isinstance(raw_areas, dict): - return [] - areas = [] - for area_id, data in sorted(raw_areas.items()): - area = {"area_id": area_id} - data = data or {} +def _derive_key_field(options_spec): + """The field identifying each entry in a named-list section is + never inferable from a generic walk alone -- but it doesn't need + to be hand-declared either: every named-list section in this + argspec already marks exactly one suboption required=True. + """ + 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] - # area-type - at = data.get("area-type", {}) - if at: - area_type = {} - if "normal" in at: - area_type["normal"] = True - if "nssa" in at: - nssa_data = at["nssa"] or {} - nssa = {"set": True} - if "default-cost" in nssa_data: - nssa["default_cost"] = int(nssa_data["default-cost"]) - if "no-summary" in nssa_data: - nssa["no_summary"] = True - if "translate" in nssa_data: - nssa["translate"] = nssa_data["translate"] - area_type["nssa"] = nssa - if "stub" in at: - stub_data = at["stub"] or {} - stub = {"set": True} - if "default-cost" in stub_data: - stub["default_cost"] = int(stub_data["default-cost"]) - if "no-summary" in stub_data: - stub["no_summary"] = True - area_type["stub"] = stub - if area_type: - area["area_type"] = area_type - - if "authentication" in data: - area["authentication"] = data["authentication"] - - if "shortcut" in data: - area["shortcut"] = data["shortcut"] - - # network - net = data.get("network") - if net: - if isinstance(net, str): - area["network"] = [{"address": net}] - elif isinstance(net, dict): - area["network"] = [{"address": a} for a in sorted(net.keys())] - elif isinstance(net, list): - area["network"] = [{"address": a} for a in sorted(net)] - - # range - rng = data.get("range", {}) - if rng and isinstance(rng, dict): - ranges = [] - for addr, rdata in sorted(rng.items()): - r = {"address": addr} - rdata = rdata or {} - if "cost" in rdata: - r["cost"] = int(rdata["cost"]) - if "not-advertise" in rdata: - r["not_advertise"] = True - if "substitute" in rdata: - r["substitute"] = rdata["substitute"] - ranges.append(r) - if ranges: - area["range"] = ranges - - areas.append(area) - return areas - - -def _parse_redistribute(raw): - if not raw or not isinstance(raw, dict): - return [] - result = [] - for rt, data in sorted(raw.items()): - entry = {"route_type": rt} - data = data or {} - if "metric" in data: - entry["metric"] = int(data["metric"]) - if "metric-type" in data: - entry["metric_type"] = int(data["metric-type"]) - if "route-map" in data: - entry["route_map"] = data["route-map"] - result.append(entry) + +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 _parse_neighbor(raw): - if not raw or not isinstance(raw, dict): - return [] - result = [] - for nb_id, data in sorted(raw.items()): - entry = {"neighbor_id": nb_id} - data = data or {} - if "poll-interval" in data: - entry["poll_interval"] = int(data["poll-interval"]) - if "priority" in data: - entry["priority"] = int(data["priority"]) - result.append(entry) - return result +def _keyed_list_from_device(raw, key_field, entry_transform=None, key_cast=None): + entry_transform = entry_transform or from_device + key_cast = key_cast or (lambda k: k) + return [ + {key_field: key_cast(key), **entry_transform(data or {})} + for key, data in sorted(to_tag_dict(raw).items()) + ] -def _parse_parameters(raw): - if not raw or not isinstance(raw, dict): - return {} - result = {} - if "router-id" in raw: - result["router_id"] = raw["router-id"] - if "abr-type" in raw: - result["abr_type"] = raw["abr-type"] - if "opaque-lsa" in raw: - result["opaque_lsa"] = True - if "rfc1583-compatibility" in raw: - result["rfc1583_compatibility"] = True - return result +# --------------------------------------------------------------------------- +# area_type -- confirmed against vyos-1x: normal (presence), nssa (a node +# with default-cost/no-summary/translate -- presence of the node itself +# is the "set" flag, matching the argspec's own "set" boolean rather +# than a separate device leaf), stub (default-cost/no-summary, +# same presence pattern). Genuine structural exception: the argspec's +# nssa/stub "set" key doesn't exist as a device leaf at all -- the +# *node's presence* IS the set flag, so "set" must be stripped out +# before the walk and re-derived on the way back. +# --------------------------------------------------------------------------- -def _parse_default_information(raw): - if not raw or not isinstance(raw, dict): +def _area_type_to_device(area_type): + if not area_type: return {} - orig = raw.get("originate", {}) or {} - result = {} - if "always" in orig: - result["always"] = True - if "metric" in orig: - result["metric"] = int(orig["metric"]) - if "metric-type" in orig: - result["metric_type"] = int(orig["metric-type"]) - if "route-map" in orig: - result["route_map"] = orig["route-map"] - if result: - return {"originate": result} - return {} + device = {} + if area_type.get("normal"): + device["normal"] = {} + nssa = area_type.get("nssa") + if nssa: + nssa_device = _kebab_fields({k: v for k, v in nssa.items() if k != "set"}) + device["nssa"] = nssa_device + stub = area_type.get("stub") + if stub: + stub_device = _kebab_fields({k: v for k, v in stub.items() if k != "set"}) + device["stub"] = stub_device + return device + + +def _area_type_from_device(data): + if not data: + return None + entry = {} + if "normal" in data: + entry["normal"] = True + if "nssa" in data: + nssa = from_device(data["nssa"] or {}) + nssa["set"] = True + entry["nssa"] = nssa + if "stub" in data: + stub = from_device(data["stub"] or {}) + stub["set"] = True + entry["stub"] = stub + return entry or None + + +# --------------------------------------------------------------------------- +# virtual_link -- confirmed against vyos-1x: keyed by address, with an +# "authentication" node (md5 tag-node keyed by key-id, or a bare +# plaintext-password leaf), plus dead-interval/hello-interval/ +# retransmit-interval/transmit-delay as plain leaves. +# --------------------------------------------------------------------------- + + +def _vlink_auth_to_device(auth): + if not auth: + return {} + device = {} + md5_list = auth.get("md5") or [] + if md5_list: + md5_device = { + str(entry["key_id"]): {"md5-key": entry["md5_key"]} + for entry in md5_list + if entry.get("key_id") is not None and entry.get("md5_key") is not None + } + if md5_device: + device["md5"] = md5_device + if auth.get("plaintext_password"): + device["plaintext-password"] = auth["plaintext_password"] + return device + + +def _vlink_auth_from_device(data): + if not data: + return None + entry = {} + md5_raw = data.get("md5") + if md5_raw: + entry["md5"] = [ + {"key_id": int(key_id), "md5_key": (kdata or {}).get("md5-key")} + for key_id, kdata in sorted(to_tag_dict(md5_raw).items()) + ] + if data.get("plaintext-password"): + entry["plaintext_password"] = data["plaintext-password"] + return entry or None + + +def _vlink_entry_to_device(rest): + exclude = {"authentication"} + device = _kebab_fields({k: v for k, v in rest.items() if k not in exclude}) + if rest.get("authentication"): + auth_device = _vlink_auth_to_device(rest["authentication"]) + if auth_device: + device["authentication"] = auth_device + return device + + +def _vlink_entry_from_device(data): + exclude = {"authentication"} + entry = from_device({k: v for k, v in data.items() if k not in exclude}) + auth = _vlink_auth_from_device(data.get("authentication")) + if auth: + entry["authentication"] = auth + return entry + + +# --------------------------------------------------------------------------- +# area -- orchestrates area_type, network, range, virtual_link, and the +# plain leaves (authentication, shortcut). +# --------------------------------------------------------------------------- + +_NETWORK_KEY = "address" +_RANGE_KEY = "address" +_VLINK_KEY = "address" + + +def _area_entry_to_device(rest): + exclude = {"area_type", "network", "range", "virtual_link"} + device = autoclean({k: v for k, v in rest.items() if k not in exclude}) + + at = _area_type_to_device(rest.get("area_type")) + if at: + device["area-type"] = at + + networks = rest.get("network") or [] + if networks: + device["network"] = {n["address"]: {} for n in networks if n.get("address")} + + ranges = rest.get("range") or [] + if ranges: + device["range"] = _keyed_list_to_device(ranges, _RANGE_KEY) + + vlinks = rest.get("virtual_link") or [] + if vlinks: + device["virtual-link"] = _keyed_list_to_device(vlinks, _VLINK_KEY, _vlink_entry_to_device) + + return device + + +def _area_entry_from_device(data): + exclude = {"area-type", "network", "range", "virtual-link"} + entry = from_device({k: v for k, v in data.items() if k not in exclude}) + + at = _area_type_from_device(data.get("area-type")) + if at: + entry["area_type"] = at + + net_raw = data.get("network") + if net_raw: + entry["network"] = [{"address": addr} for addr in sorted(to_tag_dict(net_raw))] + + range_raw = data.get("range") + if range_raw: + entry["range"] = _keyed_list_from_device(range_raw, _RANGE_KEY) + + vlink_raw = data.get("virtual-link") + if vlink_raw: + entry["virtual_link"] = _keyed_list_from_device( + vlink_raw, + _VLINK_KEY, + _vlink_entry_from_device, + ) + + return entry + +# --------------------------------------------------------------------------- +# distance -- confirmed genuine structural exception: argspec's "global" +# is a reserved-adjacent-but-legal dict key (fine as a string key, no +# Python keyword issue since it's inside dict(**{...}), unlike "as" +# elsewhere in this collection), device path is distance.global (a +# leaf) and distance.ospf.{external,inter-area,intra-area}. +# --------------------------------------------------------------------------- -def _parse_distance(raw): - if not raw or not isinstance(raw, dict): + +def _distance_to_device(dist): + if not dist: return {} - result = {} - if "global" in raw: - result["global"] = int(raw["global"]) - ospf = raw.get("ospf", {}) or {} + device = {} + if dist.get("global") is not None: + device["global"] = dist["global"] + ospf = dist.get("ospf") if ospf: - od = {} - if "external" in ospf: - od["external"] = int(ospf["external"]) - if "inter-area" in ospf: - od["inter_area"] = int(ospf["inter-area"]) - if "intra-area" in ospf: - od["intra_area"] = int(ospf["intra-area"]) - if od: - result["ospf"] = od - return result + ospf_device = _kebab_fields(ospf) + if ospf_device: + device["ospf"] = ospf_device + return device + + +def _distance_from_device(data): + if not data: + return None + entry = {} + if "global" in data: + entry["global"] = int(data["global"]) + if data.get("ospf"): + entry["ospf"] = from_device(data["ospf"]) + return entry or None + + +# --------------------------------------------------------------------------- +# timers -- confirmed genuine structural exception: argspec groups +# "refresh" and "throttle" both under one "timers" parent, but the +# device has them as two SEPARATE top-level nodes ("refresh" and +# "timers.throttle") -- not a nested nesting-insertion like most +# exceptions in this collection, but a nesting *removal*/regrouping. +# Handled at the top level in build_commands/get_running_config rather +# than as a single self-contained entry-transform, since it spans two +# different top-level device keys. +# --------------------------------------------------------------------------- + + +def _timers_to_device_refresh(timers): + """Returns the device's top-level "refresh" node contents.""" + refresh = (timers or {}).get("refresh") or {} + if refresh.get("timers") is not None: + return {"timers": refresh["timers"]} + return {} -def get_running_config(vyos): - raw = vyos.get_config(_BASE) - if not raw or not isinstance(raw, dict): - return {} - result = {} +def _timers_to_device_throttle(timers): + """Returns the device's top-level "timers" node contents (just the + throttle.spf subtree, matching confirmed scope).""" + throttle = (timers or {}).get("throttle") or {} + spf = throttle.get("spf") or {} + spf_device = _kebab_fields(spf) + if spf_device: + return {"throttle": {"spf": spf_device}} + return {} - areas = _parse_areas(raw.get("area")) - if areas: - result["areas"] = areas - ac = raw.get("auto-cost", {}) - if ac and "reference-bandwidth" in ac: - result["auto_cost"] = {"reference_bandwidth": int(ac["reference-bandwidth"])} +def _timers_from_device(refresh_raw, timers_raw): + entry = {} + if refresh_raw and refresh_raw.get("timers") is not None: + entry["refresh"] = {"timers": int(refresh_raw["timers"])} + throttle_raw = (timers_raw or {}).get("throttle") or {} + spf_raw = throttle_raw.get("spf") + if spf_raw: + entry["throttle"] = {"spf": from_device(spf_raw)} + return entry or None + + +# --------------------------------------------------------------------------- +# passive_interface / passive_interface_exclude -- confirmed against +# vyos-1x: both map onto the SAME per-interface "interface <name> +# passive" node -- presence alone means passive-enabled, +# "passive.disable" (a generic-disable-node) means explicitly +# excluded. These share one device subtree, so both are handled +# together rather than as two independent list diffs. +# --------------------------------------------------------------------------- + + +def _passive_to_device(passive_list, exclude_list): + device = {} + for iface in passive_list or []: + device[iface] = {"passive": {}} + for iface in exclude_list or []: + device[iface] = {"passive": {"disable": {}}} + return device + + +def _passive_from_device(iface_raw): + passive = [] + excluded = [] + for name, data in sorted((iface_raw or {}).items()): + data = data or {} + passive_node = data.get("passive") + if passive_node is None: + continue + if isinstance(passive_node, dict) and "disable" in passive_node: + excluded.append(name) + else: + passive.append(name) + return passive, excluded - di = _parse_default_information(raw.get("default-information", {})) - if di: - result["default_information"] = di - if "default-metric" in raw: - result["default_metric"] = int(raw["default-metric"]) +# --------------------------------------------------------------------------- +# redistribute -- fully generic once keyed by route_type; metric/ +# metric-type/route-map are all plain leaves. +# --------------------------------------------------------------------------- - dist = _parse_distance(raw.get("distance", {})) - if dist: - result["distance"] = dist +_REDISTRIBUTE_KEY = "route_type" +_NEIGHBOR_KEY = "neighbor_id" - lac = raw.get("log-adjacency-changes", {}) - if lac: - if isinstance(lac, dict) and "detail" in lac: - result["log_adjacency_changes"] = "detail" - elif lac == "detail": - result["log_adjacency_changes"] = "detail" - neighbors = _parse_neighbor(raw.get("neighbor")) +def _want_to_device(config): + config = config or {} + device = {} + + areas = config.get("areas") or [] + if areas: + device["area"] = _keyed_list_to_device(areas, "area_id", _area_entry_to_device) + + ac = config.get("auto_cost") or {} + if ac.get("reference_bandwidth") is not None: + device["auto-cost"] = {"reference-bandwidth": ac["reference_bandwidth"]} + + di = (config.get("default_information") or {}).get("originate") or {} + di_device = _kebab_fields(di) + if di_device: + device["default-information"] = {"originate": di_device} + + if config.get("default_metric") is not None: + device["default-metric"] = config["default_metric"] + + dist_device = _distance_to_device(config.get("distance")) + if dist_device: + device["distance"] = dist_device + + if config.get("log_adjacency_changes"): + device["log-adjacency-changes"] = {config["log_adjacency_changes"]: {}} + + mm = (config.get("max_metric") or {}).get("router_lsa") or {} + mm_device = _kebab_fields(mm) + if mm_device: + device["max-metric"] = {"router-lsa": mm_device} + + mpls = config.get("mpls_te") or {} + mpls_device = {} + if mpls.get("enabled"): + mpls_device["enable"] = {} + if mpls.get("router_address"): + mpls_device["router-address"] = mpls["router_address"] + if mpls_device: + device["mpls-te"] = mpls_device + + neighbors = config.get("neighbor") or [] if neighbors: - result["neighbor"] = neighbors + device["neighbor"] = _keyed_list_to_device(neighbors, _NEIGHBOR_KEY) - params = _parse_parameters(raw.get("parameters")) - if params: - result["parameters"] = params + params_device = _kebab_fields(config.get("parameters") or {}) + if params_device: + device["parameters"] = params_device - # passive interfaces — VyOS 1.5 uses interface <name> passive - iface_raw = raw.get("interface", {}) or {} - passive = sorted( - [name for name, data in iface_raw.items() if isinstance(data, dict) and "passive" in data], + iface_device = _passive_to_device( + config.get("passive_interface"), + config.get("passive_interface_exclude"), ) - if passive: - result["passive_interface"] = passive + if iface_device: + device["interface"] = iface_device - redist = _parse_redistribute(raw.get("redistribute")) + redist = config.get("redistribute") or [] if redist: - result["redistribute"] = redist + device["redistribute"] = _keyed_list_to_device(redist, _REDISTRIBUTE_KEY) - return result + refresh_device = _timers_to_device_refresh(config.get("timers")) + if refresh_device: + device["refresh"] = refresh_device + throttle_device = _timers_to_device_throttle(config.get("timers")) + if throttle_device: + device.setdefault("timers", {}).update(throttle_device) + return device -def _area_type_cmds(abase, area_type, have_at): - cmds = [] - have_at = have_at or {} - if area_type.get("normal") and not have_at.get("normal"): - cmds.append(("set", abase + ["area-type", "normal"])) - nssa = area_type.get("nssa") or {} - if nssa: - have_nssa = have_at.get("nssa") or {} - if not have_nssa: - cmds.append(("set", abase + ["area-type", "nssa"])) - if nssa.get("default_cost") and nssa["default_cost"] != have_nssa.get("default_cost"): - cmds.append( - ( - "set", - abase - + [ - "area-type", - "nssa", - "default-cost", - str(nssa["default_cost"]), - ], - ), - ) - if nssa.get("no_summary") and not have_nssa.get("no_summary"): - cmds.append(("set", abase + ["area-type", "nssa", "no-summary"])) - if nssa.get("translate") and nssa["translate"] != have_nssa.get("translate"): - cmds.append(("set", abase + ["area-type", "nssa", "translate", nssa["translate"]])) - stub = area_type.get("stub") or {} - if stub: - have_stub = have_at.get("stub") or {} - if not have_stub: - if stub.get("default_cost"): - cmds.append( - ( - "set", - abase - + [ - "area-type", - "stub", - "default-cost", - str(stub["default_cost"]), - ], - ), - ) - else: - cmds.append(("set", abase + ["area-type", "stub"])) - elif stub.get("default_cost") and stub["default_cost"] != have_stub.get("default_cost"): - cmds.append( - ( - "set", - abase - + [ - "area-type", - "stub", - "default-cost", - str(stub["default_cost"]), - ], - ), - ) - return cmds - - -def _area_cmds(area, have_area): - cmds = [] - area_id = area["area_id"] - abase = _BASE + ["area", area_id] - have_area = have_area or {} - - if area.get("area_type"): - cmds += _area_type_cmds(abase, area["area_type"], have_area.get("area_type")) - - if area.get("authentication") and area["authentication"] != have_area.get("authentication"): - cmds.append(("set", abase + ["authentication", area["authentication"]])) - - if area.get("shortcut") and area["shortcut"] != have_area.get("shortcut"): - cmds.append(("set", abase + ["shortcut", area["shortcut"]])) - - want_nets = {n["address"] for n in (area.get("network") or [])} - have_nets = {n["address"] for n in (have_area.get("network") or [])} - for addr in want_nets - have_nets: - cmds.append(("set", abase + ["network", addr])) - - want_ranges = {r["address"]: r for r in (area.get("range") or [])} - have_ranges = {r["address"]: r for r in (have_area.get("range") or [])} - for addr, rng in want_ranges.items(): - have_rng = have_ranges.get(addr, {}) - if addr not in have_ranges: - cmds.append(("set", abase + ["range", addr])) - if rng.get("cost") and rng["cost"] != have_rng.get("cost"): - cmds.append(("set", abase + ["range", addr, "cost", str(rng["cost"])])) - if rng.get("not_advertise") and not have_rng.get("not_advertise"): - cmds.append(("set", abase + ["range", addr, "not-advertise"])) - if rng.get("substitute") and rng["substitute"] != have_rng.get("substitute"): - cmds.append(("set", abase + ["range", addr, "substitute", rng["substitute"]])) - - return cmds - - -def _parameters_cmds(params, have_params): - cmds = [] - have_params = have_params or {} - pbase = _BASE + ["parameters"] - if params.get("router_id") and params["router_id"] != have_params.get("router_id"): - cmds.append(("set", pbase + ["router-id", params["router_id"]])) - if params.get("abr_type") and params["abr_type"] != have_params.get("abr_type"): - cmds.append(("set", pbase + ["abr-type", params["abr_type"]])) - if params.get("opaque_lsa") and not have_params.get("opaque_lsa"): - cmds.append(("set", pbase + ["opaque-lsa"])) - if params.get("rfc1583_compatibility") and not have_params.get("rfc1583_compatibility"): - cmds.append(("set", pbase + ["rfc1583-compatibility"])) - return cmds - - -def _redistribute_cmds(redist_list, have_redist_list): - cmds = [] - want = {r["route_type"]: r for r in (redist_list or [])} - have = {r["route_type"]: r for r in (have_redist_list or [])} - for rt, entry in want.items(): - have_entry = have.get(rt, {}) - rbase = _BASE + ["redistribute", rt] - if rt not in have: - cmds.append(("set", rbase)) - if entry.get("metric") and entry["metric"] != have_entry.get("metric"): - cmds.append(("set", rbase + ["metric", str(entry["metric"])])) - if entry.get("metric_type") and entry["metric_type"] != have_entry.get("metric_type"): - cmds.append(("set", rbase + ["metric-type", str(entry["metric_type"])])) - if entry.get("route_map") and entry["route_map"] != have_entry.get("route_map"): - cmds.append(("set", rbase + ["route-map", entry["route_map"]])) - return cmds - - -def _neighbor_cmds(neighbors, have_neighbors): - cmds = [] - want = {n["neighbor_id"]: n for n in (neighbors or [])} - have = {n["neighbor_id"]: n for n in (have_neighbors or [])} - for nb_id, entry in want.items(): - have_entry = have.get(nb_id, {}) - nbase = _BASE + ["neighbor", nb_id] - if nb_id not in have: - cmds.append(("set", nbase)) - if entry.get("priority") and entry["priority"] != have_entry.get("priority"): - cmds.append(("set", nbase + ["priority", str(entry["priority"])])) - if entry.get("poll_interval") and entry["poll_interval"] != have_entry.get("poll_interval"): - cmds.append(("set", nbase + ["poll-interval", str(entry["poll_interval"])])) - return cmds - - -def _default_info_cmds(di, have_di): - cmds = [] - have_di = have_di or {} - orig = (di or {}).get("originate") or {} - have_orig = have_di.get("originate") or {} - if not orig: - return cmds - dbase = _BASE + ["default-information", "originate"] - if orig.get("always") and not have_orig.get("always"): - cmds.append(("set", dbase + ["always"])) - if orig.get("metric") and orig["metric"] != have_orig.get("metric"): - cmds.append(("set", dbase + ["metric", str(orig["metric"])])) - if orig.get("metric_type") and orig["metric_type"] != have_orig.get("metric_type"): - cmds.append(("set", dbase + ["metric-type", str(orig["metric_type"])])) - if orig.get("route_map") and orig["route_map"] != have_orig.get("route_map"): - cmds.append(("set", dbase + ["route-map", orig["route_map"]])) - return cmds - - -def build_commands(config, have, state): - cmds = [] - if state == "deleted": - if have: - cmds.append(("delete", _BASE)) - return cmds +def get_running_config(vyos): + return vyos.get_config(_BASE) or {} - if state == "replaced": - 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 = {} - config = config or {} +def _device_to_argspec(raw): + if not raw: + return {} + entry = {} - # parameters - if config.get("parameters"): - cmds += _parameters_cmds(config["parameters"], have.get("parameters")) + area_raw = raw.get("area") + if area_raw: + entry["areas"] = _keyed_list_from_device(area_raw, "area_id", _area_entry_from_device) - # auto_cost - ac = config.get("auto_cost") or {} - have_ac = have.get("auto_cost") or {} - if ac.get("reference_bandwidth") and ac["reference_bandwidth"] != have_ac.get( - "reference_bandwidth", - ): - cmds.append( - ( - "set", - _BASE - + [ - "auto-cost", - "reference-bandwidth", - str(ac["reference_bandwidth"]), - ], - ), - ) + ac = raw.get("auto-cost") or {} + if ac.get("reference-bandwidth") is not None: + entry["auto_cost"] = {"reference_bandwidth": int(ac["reference-bandwidth"])} - # default_information - if config.get("default_information"): - cmds += _default_info_cmds( - config["default_information"], - have.get("default_information"), - ) + di_raw = (raw.get("default-information") or {}).get("originate") + if di_raw: + entry["default_information"] = {"originate": from_device(di_raw)} - # default_metric - if config.get("default_metric") and config["default_metric"] != have.get("default_metric"): - cmds.append(("set", _BASE + ["default-metric", str(config["default_metric"])])) - - # distance - dist = config.get("distance") or {} - have_dist = have.get("distance") or {} - if dist.get("global") and dist["global"] != have_dist.get("global"): - cmds.append(("set", _BASE + ["distance", "global", str(dist["global"])])) - ospf_dist = dist.get("ospf") or {} - have_ospf_dist = have_dist.get("ospf") or {} - for key, api_key in [ - ("external", "external"), - ("inter_area", "inter-area"), - ("intra_area", "intra-area"), - ]: - if ospf_dist.get(key) and ospf_dist[key] != have_ospf_dist.get(key): - cmds.append(("set", _BASE + ["distance", "ospf", api_key, str(ospf_dist[key])])) - - # log_adjacency_changes - if config.get("log_adjacency_changes") and config["log_adjacency_changes"] != have.get( - "log_adjacency_changes", - ): - cmds.append(("set", _BASE + ["log-adjacency-changes", config["log_adjacency_changes"]])) - - # neighbor - if config.get("neighbor"): - cmds += _neighbor_cmds(config["neighbor"], have.get("neighbor")) - - # redistribute - if config.get("redistribute"): - cmds += _redistribute_cmds(config["redistribute"], have.get("redistribute")) - - # passive_interface — VyOS 1.5 per-interface style - want_passive = set(config.get("passive_interface") or []) - have_passive = set(have.get("passive_interface") or []) - for iface in want_passive - have_passive: - cmds.append(("set", _BASE + ["interface", iface, "passive"])) - - # areas - have_areas = {a["area_id"]: a for a in (have.get("areas") or [])} - for area in config.get("areas") or []: - have_area = have_areas.get(area["area_id"], {}) - cmds += _area_cmds(area, have_area) - - return cmds + if "default-metric" in raw: + entry["default_metric"] = int(raw["default-metric"]) + dist = _distance_from_device(raw.get("distance")) + if dist: + entry["distance"] = dist -ARGUMENT_SPEC = dict( - config=dict( + lac = raw.get("log-adjacency-changes") + if lac: + lac_dict = to_tag_dict(lac) + if "detail" in lac_dict: + entry["log_adjacency_changes"] = "detail" + + mm_raw = (raw.get("max-metric") or {}).get("router-lsa") + if mm_raw: + entry["max_metric"] = {"router_lsa": from_device(mm_raw)} + + mpls_raw = raw.get("mpls-te") or {} + mpls_entry = {} + if "enable" in mpls_raw: + mpls_entry["enabled"] = True + if mpls_raw.get("router-address"): + mpls_entry["router_address"] = mpls_raw["router-address"] + if mpls_entry: + entry["mpls_te"] = mpls_entry + + neighbor_raw = raw.get("neighbor") + if neighbor_raw: + entry["neighbor"] = _keyed_list_from_device(neighbor_raw, _NEIGHBOR_KEY) + + params_raw = raw.get("parameters") + if params_raw: + entry["parameters"] = from_device(params_raw) + + passive, excluded = _passive_from_device(raw.get("interface")) + if passive: + entry["passive_interface"] = passive + if excluded: + entry["passive_interface_exclude"] = excluded + + redist_raw = raw.get("redistribute") + if redist_raw: + entry["redistribute"] = _keyed_list_from_device(redist_raw, _REDISTRIBUTE_KEY) + + timers = _timers_from_device(raw.get("refresh"), raw.get("timers")) + if timers: + entry["timers"] = timers + + return entry + + +def _kebab_fields(d): + """autoclean, then kebab-convert the resulting keys. + + Safe specifically because every call site below is a leaf-level + dict of schema field names (nssa/stub attributes, distance.ospf, + default_information.originate, max_metric.router_lsa, parameters, + timers.throttle.spf, virtual_link's plain fields, and range/ + neighbor/redistribute entry fields) with no further nested + tag-node-keyed structure underneath -- never an opaque value like + an area ID or interface name used as a dict key, which must stay + verbatim (confirmed real corruption risk: a blanket recursive + conversion turns "my_area" into "my-area"). + + 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 + instead reconstructed by round-tripping through this module's own + entry-transforms (needed for confirmed structural exceptions like + area-type's "set" flag or the shared interface/passive subtree), + so any field passed through unconverted stays snake_case and + dict_op has no way to recover the real device key. Confirmed as a + real bug: "default_cost" appeared in a generated delete command + instead of "default-cost". + """ + cleaned = autoclean(d) + return {k.replace("_", "-"): v for k, v in cleaned.items()} + + +def build_commands(config, raw_have, state): + raw_have = raw_have or {} + + if state == "deleted": + 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": + commands += dict_op(want, norm_have, _BASE, op="purge") + commands += dict_op(want, norm_have, _BASE, op="set") + return commands + + +_VLINK_AUTH_OPTIONS = dict( + md5=dict( + type="list", + elements="dict", + options=dict( + key_id=dict(type="int"), + md5_key=dict(type="str", no_log=True), + ), + ), + plaintext_password=dict(type="str", no_log=True), +) + +_VLINK_OPTIONS = dict( + address=dict(type="str", required=True), + authentication=dict(type="dict", options=_VLINK_AUTH_OPTIONS), + dead_interval=dict(type="int"), + hello_interval=dict(type="int"), + retransmit_interval=dict(type="int"), + transmit_delay=dict(type="int"), +) + +_AREA_OPTIONS = dict( + area_id=dict(type="str", required=True), + area_type=dict( type="dict", options=dict( - areas=dict( - type="list", - elements="dict", + normal=dict(type="bool"), + nssa=dict( + type="dict", options=dict( - area_id=dict(type="str", required=True), - area_type=dict( - type="dict", - options=dict( - normal=dict(type="bool"), - nssa=dict( - type="dict", - options=dict( - set=dict(type="bool"), - default_cost=dict(type="int"), - no_summary=dict(type="bool"), - translate=dict( - type="str", - choices=["always", "candidate", "never"], - ), - ), - ), - stub=dict( - type="dict", - options=dict( - set=dict(type="bool"), - default_cost=dict(type="int"), - no_summary=dict(type="bool"), - ), - ), - ), - ), - authentication=dict( - type="str", - choices=["plaintext-password", "md5"], - ), - network=dict( - type="list", - elements="dict", - options=dict( - address=dict(type="str", required=True), - ), - ), - range=dict( - type="list", - elements="dict", - options=dict( - address=dict(type="str", required=True), - cost=dict(type="int"), - not_advertise=dict(type="bool"), - substitute=dict(type="str"), - ), - ), - shortcut=dict(type="str", choices=["default", "disable", "enable"]), + set=dict(type="bool"), + default_cost=dict(type="int"), + no_summary=dict(type="bool"), + translate=dict(type="str", choices=["always", "candidate", "never"]), ), ), - auto_cost=dict( + stub=dict( type="dict", options=dict( - reference_bandwidth=dict(type="int"), + set=dict(type="bool"), + default_cost=dict(type="int"), + no_summary=dict(type="bool"), ), ), - default_information=dict( + ), + ), + authentication=dict(type="str", choices=["plaintext-password", "md5"]), + network=dict( + type="list", + elements="dict", + options=dict(address=dict(type="str", required=True)), + ), + range=dict( + type="list", + elements="dict", + options=dict( + address=dict(type="str", required=True), + cost=dict(type="int"), + not_advertise=dict(type="bool"), + substitute=dict(type="str"), + ), + ), + shortcut=dict(type="str", choices=["default", "disable", "enable"]), + virtual_link=dict(type="list", elements="dict", options=_VLINK_OPTIONS), +) + +_CONFIG_OPTIONS = dict( + areas=dict(type="list", elements="dict", options=_AREA_OPTIONS), + auto_cost=dict( + type="dict", + options=dict(reference_bandwidth=dict(type="int")), + ), + default_information=dict( + type="dict", + options=dict( + originate=dict( type="dict", options=dict( - originate=dict( - type="dict", - options=dict( - always=dict(type="bool"), - metric=dict(type="int"), - metric_type=dict(type="int"), - route_map=dict(type="str"), - ), - ), + always=dict(type="bool"), + metric=dict(type="int"), + metric_type=dict(type="int"), + route_map=dict(type="str"), ), ), - default_metric=dict(type="int"), - distance=dict( + ), + ), + default_metric=dict(type="int"), + distance=dict( + type="dict", + options=dict( + **{"global": dict(type="int")}, + ospf=dict( type="dict", options=dict( - **{"global": dict(type="int")}, - ospf=dict( - type="dict", - options=dict( - external=dict(type="int"), - inter_area=dict(type="int"), - intra_area=dict(type="int"), - ), - ), + external=dict(type="int"), + inter_area=dict(type="int"), + intra_area=dict(type="int"), ), ), - log_adjacency_changes=dict(type="str", choices=["detail"]), - neighbor=dict( - type="list", - elements="dict", + ), + ), + log_adjacency_changes=dict(type="str", choices=["detail"]), + max_metric=dict( + type="dict", + options=dict( + router_lsa=dict( + type="dict", options=dict( - neighbor_id=dict(type="str", required=True), - poll_interval=dict(type="int"), - priority=dict(type="int"), + administrative=dict(type="bool"), + on_shutdown=dict(type="int"), + on_startup=dict(type="int"), ), ), - parameters=dict( + ), + ), + mpls_te=dict( + type="dict", + options=dict( + enabled=dict(type="bool"), + router_address=dict(type="str"), + ), + ), + neighbor=dict( + type="list", + elements="dict", + options=dict( + neighbor_id=dict(type="str", required=True), + poll_interval=dict(type="int"), + priority=dict(type="int"), + ), + ), + parameters=dict( + type="dict", + options=dict( + abr_type=dict(type="str", choices=["cisco", "ibm", "shortcut", "standard"]), + opaque_lsa=dict(type="bool"), + rfc1583_compatibility=dict(type="bool"), + router_id=dict(type="str"), + ), + ), + passive_interface=dict(type="list", elements="str"), + passive_interface_exclude=dict(type="list", elements="str"), + redistribute=dict( + type="list", + elements="dict", + options=dict( + route_type=dict(type="str", choices=["bgp", "connected", "kernel", "rip", "static"]), + metric=dict(type="int"), + metric_type=dict(type="int"), + route_map=dict(type="str"), + ), + ), + timers=dict( + type="dict", + options=dict( + refresh=dict( type="dict", - options=dict( - abr_type=dict( - type="str", - choices=["cisco", "ibm", "shortcut", "standard"], - ), - opaque_lsa=dict(type="bool"), - rfc1583_compatibility=dict(type="bool"), - router_id=dict(type="str"), - ), + options=dict(timers=dict(type="int")), ), - passive_interface=dict(type="list", elements="str"), - redistribute=dict( - type="list", - elements="dict", + throttle=dict( + type="dict", options=dict( - route_type=dict( - type="str", - choices=["bgp", "connected", "kernel", "rip", "static"], + spf=dict( + type="dict", + options=dict( + delay=dict(type="int"), + initial_holdtime=dict(type="int"), + max_holdtime=dict(type="int"), + ), ), - metric=dict(type="int"), - metric_type=dict(type="int"), - route_map=dict(type="str"), ), ), ), ), +) + +ARGUMENT_SPEC = dict( + config=dict(type="dict", options=_CONFIG_OPTIONS), state=dict( default="merged", choices=["merged", "replaced", "deleted", "gathered"], @@ -926,23 +1140,28 @@ 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) + 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) + 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, |
