diff options
| author | omnom62 <omnom62@outlook.com> | 2026-09-11 05:59:29 +1000 |
|---|---|---|
| committer | omnom62 <omnom62@outlook.com> | 2026-09-11 05:59:29 +1000 |
| commit | 4e223fe6fa25dd76afece85f4ac7db2057c94aa9 (patch) | |
| tree | f03df9c905852016303b74624bd899790693ee75 /plugins/modules | |
| parent | eeee4f7174df121fb66792f067cb23f8f09f61d6 (diff) | |
| download | rest.vyos-4e223fe6fa25dd76afece85f4ac7db2057c94aa9.tar.gz rest.vyos-4e223fe6fa25dd76afece85f4ac7db2057c94aa9.zip | |
T8989: vyos_vrf module unit tests
Diffstat (limited to 'plugins/modules')
| -rw-r--r-- | plugins/modules/vyos_vrf.py | 1149 |
1 files changed, 606 insertions, 543 deletions
diff --git a/plugins/modules/vyos_vrf.py b/plugins/modules/vyos_vrf.py index 3a71bf7..2a76737 100644 --- a/plugins/modules/vyos_vrf.py +++ b/plugins/modules/vyos_vrf.py @@ -15,8 +15,8 @@ description: - Manages Virtual Routing and Forwarding (VRF) instances on VyOS devices via the REST API. - Supports merged, replaced, overridden, deleted, and gathered states. - - Protocol configuration within VRFs (BGP, OSPFv2, OSPFv3, static routes) - is managed inline using the same logic as the dedicated protocol modules. + - Protocol configuration within VRFs (BGP, OSPFv2, static routes) is + managed inline with focused scope (core fields only). version_added: "1.0.0" author: - VyOS Community (@vyos) @@ -60,7 +60,7 @@ options: description: Address family identifier. type: str required: true - choices: ['ipv4', 'ipv6'] + choices: [ipv4, ipv6] disable_forwarding: description: Disable IP forwarding for this address family. type: bool @@ -78,15 +78,7 @@ options: description: Protocol to which the route map applies. type: str required: true - choices: - - any - - babel - - bgp - - eigrp - - isis - - ospf - - rip - - static + choices: [any, babel, bgp, eigrp, isis, ospf, rip, static] rm_name: description: Route map name. type: str @@ -96,58 +88,111 @@ options: type: dict suboptions: bgp: - description: BGP protocol configuration. Same options as vyos_bgp_global. + description: BGP protocol configuration (core fields only). type: dict + suboptions: + system_as: + description: BGP autonomous system number. + type: int + neighbor: + description: BGP neighbors. + type: list + elements: dict + suboptions: + address: + description: Neighbor IP address. + type: str + required: true + remote_as: + description: Neighbor AS number. + type: int + description: + description: Neighbor description. + type: str ospf: - description: OSPFv2 protocol configuration. Same options as vyos_ospfv2. - type: dict - ospfv3: - description: OSPFv3 protocol configuration. Same options as vyos_ospfv3. + description: OSPFv2 protocol configuration (core fields only). type: dict + suboptions: + areas: + description: OSPF areas. + type: list + elements: dict + suboptions: + area_id: + description: OSPF area identifier. + type: str + required: true + networks: + description: Networks in this area. + type: list + elements: str + parameters: + description: OSPF parameters. + type: dict + suboptions: + router_id: + description: OSPF router ID. + type: str static: - description: Static routes configuration. Same options as vyos_static_routes. - type: list - elements: dict + description: Static routes configuration. + type: dict + suboptions: + routes: + description: Static routes. + type: list + elements: dict + suboptions: + dest: + description: Destination prefix. + type: str + required: true + next_hops: + description: Next-hop IP addresses. + type: list + elements: str state: description: Desired state of the VRF configuration. type: str default: merged - choices: - - merged - - replaced - - overridden - - deleted - - gathered + choices: [merged, replaced, overridden, deleted, gathered] """ EXAMPLES = r""" -- name: Merge VRF with BGP +- name: Merge VRF instances vyos.rest.vyos_vrf: config: + bind_to_all: true instances: - - name: vrf-blue - table_id: 100 + - name: vrf1 + description: Red VRF + table_id: 101 + vni: 501 protocols: bgp: system_as: 65001 neighbor: - address: 10.0.0.1 remote_as: 65002 + ospf: + areas: + - area_id: "0" + networks: + - 10.0.0.0/24 + parameters: + router_id: 10.0.0.1 + static: + routes: + - dest: 192.168.10.0/24 + next_hops: + - 10.0.0.254 state: merged -- name: Merge VRF with OSPFv2 +- name: Delete specific VRF vyos.rest.vyos_vrf: config: instances: - - name: vrf-red - table_id: 101 - protocols: - ospf: - areas: - - area_id: "0" - network: - - address: 10.1.0.0/24 - state: merged + - name: vrf1 + state: deleted - name: Delete all VRF configuration vyos.rest.vyos_vrf: @@ -184,14 +229,510 @@ saved: from ansible.module_utils.basic import AnsibleModule from ansible_collections.vyos.rest.plugins.module_utils.vyos import ( VyOSModule, + autoclean, + cast_by_spec, dict_op, + from_device, + to_tag_dict, ) _BASE = ["vrf"] -_AFI_MAP = {"ipv4": "ip", "ipv6": "ipv6"} -_AFI_REVERSE = {"ip": "ipv4", "ipv6": "ipv6"} +# --------------------------------------------------------------------------- +# Field name renames: argspec key -> device key. +# Only entries that cannot be derived by mechanical snake_case <-> kebab-case +# conversion, or where the device uses a completely different name. +# +# table_id -> table argspec uses _id suffix, device does not +# system_as -> system-as hyphen in middle (mechanical would also work, +# declared here for explicitness) +# remote_as -> remote-as same +# rm_name -> route-map completely different device leaf name +# next_hops -> next-hop plural vs singular + hyphen +# areas -> area device uses singular tag-node name +# routes -> route device uses singular tag-node name +# --------------------------------------------------------------------------- +_DEVICE_RENAMES = { + "table_id": "table", + "system_as": "system-as", + "remote_as": "remote-as", + "rm_name": "route-map", + "next_hops": "next-hop", + "areas": "area", + "routes": "route", +} + + +# --------------------------------------------------------------------------- +# Generic helpers — same pattern as vyos_snmp_server +# --------------------------------------------------------------------------- + + +def _derive_key_field(options_spec): + """Derive the tag-node key field — the one required=True suboption.""" + required = [k for k, spec in options_spec.items() if spec.get("required")] + if len(required) != 1: + raise ValueError( + "expected exactly one required suboption, found: {0}".format(required), + ) + return required[0] + + +def _keyed_list_to_device(items, key_field, entry_transform=None): + """Convert argspec list of dicts to device tag-node dict keyed by key_field.""" + 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} + entry = entry_transform(rest) if entry_transform else autoclean(rest) + result[item[key_field]] = entry + return result + + +def _keyed_list_from_device(raw, key_field, entry_transform=None): + """Convert device tag-node dict to argspec list of dicts with key_field.""" + return [ + ( + {key_field: key, **entry_transform(data or {})} + if entry_transform + else {key_field: key, **from_device(data or {})} + ) + for key, data in sorted(to_tag_dict(raw).items()) + ] + + +# --------------------------------------------------------------------------- +# Structural entry overrides: sections where the device layout requires +# something beyond a field rename. Each entry: +# argspec_key -> (to_device_fn, from_device_fn) +# The to/from functions receive/return the entry dict WITHOUT the key field. +# --------------------------------------------------------------------------- + +# neighbor entries: fields use _DEVICE_RENAMES (remote_as -> remote-as), +# so we need _spec_to_device recursion, not plain autoclean. +# Defined after _spec_to_device is declared — see _ENTRY_OVERRIDES assignment. + + +# area entries: networks is a plain sorted list leaf, not a tag node. +def _area_to_device(rest): + d = {} + if rest.get("networks"): + d["network"] = sorted(rest["networks"]) + return d + + +def _area_from_device(raw): + raw = raw or {} + entry = {} + networks_raw = raw.get("network") + if networks_raw: + if isinstance(networks_raw, str): + entry["networks"] = [networks_raw] + elif isinstance(networks_raw, list): + entry["networks"] = sorted(networks_raw) + elif isinstance(networks_raw, dict): + entry["networks"] = sorted(networks_raw.keys()) + return entry + + +# route entries: next-hop is a tag node keyed by address, value is presence {}. +def _route_to_device(rest): + d = {} + if rest.get("next_hops"): + d["next-hop"] = {nh: {} for nh in rest["next_hops"]} + return d + + +def _route_from_device(raw): + raw = raw or {} + entry = {} + next_hops_raw = raw.get("next-hop") or {} + if isinstance(next_hops_raw, dict): + nhs = sorted(next_hops_raw.keys()) + if nhs: + entry["next_hops"] = nhs + elif isinstance(next_hops_raw, str): + entry["next_hops"] = [next_hops_raw] + return entry + + +# --------------------------------------------------------------------------- +# Generic recursive walkers — driven by ARGUMENT_SPEC + _DEVICE_RENAMES +# + _ENTRY_OVERRIDES. Same pattern as vyos_snmp_server. +# --------------------------------------------------------------------------- + + +def _spec_to_device(value, options_spec): + """Recursively convert argspec dict to device dict.""" + if not isinstance(value, dict): + return value + result = {} + for arg_key, sub_spec in options_spec.items(): + val = value.get(arg_key) + if val is None or val is False: + continue + device_key = _DEVICE_RENAMES.get(arg_key, arg_key) + sub_type = sub_spec.get("type") + sub_options = sub_spec.get("options") + if sub_type == "dict" and sub_options: + converted = _spec_to_device(val, sub_options) + if converted: + result[device_key] = converted + elif sub_type == "list" and sub_options: + key_field = _derive_key_field(sub_options) + entry_to, _ = _ENTRY_OVERRIDES.get(arg_key, (None, None)) + entry_transform = entry_to or ( + lambda rest, spec=sub_options: _spec_to_device(rest, spec) + ) + result[device_key] = _keyed_list_to_device(val, key_field, entry_transform) + elif val is True: + result[device_key] = {} + elif sub_type == "list": + result[device_key] = list(val) + else: + result[device_key] = val + return result + + +def _device_to_spec(raw, options_spec): + """Recursively convert device dict to argspec dict.""" + if not raw or not isinstance(raw, dict): + return {} + have_idx = {k.replace("-", "_"): k for k in raw} + result = {} + for arg_key, sub_spec in options_spec.items(): + device_key = _DEVICE_RENAMES.get(arg_key, arg_key) + orig_key = device_key if device_key in raw else have_idx.get(arg_key) + if orig_key is None: + continue + raw_val = raw[orig_key] + sub_type = sub_spec.get("type") + sub_options = sub_spec.get("options") + if sub_type == "dict" and sub_options: + converted = _device_to_spec(raw_val, sub_options) + if converted: + result[arg_key] = converted + elif sub_type == "list" and sub_options: + key_field = _derive_key_field(sub_options) + _, entry_from = _ENTRY_OVERRIDES.get(arg_key, (None, None)) + entry_transform = entry_from or (lambda d, spec=sub_options: _device_to_spec(d, spec)) + entries = _keyed_list_from_device(raw_val, key_field, entry_transform) + if entries: + result[arg_key] = entries + elif sub_type == "list": + if raw_val: + result[arg_key] = sorted(to_tag_dict(raw_val).keys()) + elif isinstance(raw_val, dict) and not raw_val: + result[arg_key] = True + else: + result[arg_key] = raw_val + return result + + +# Entry overrides — defined after _spec_to_device so neighbor can use it. +# neighbor uses _spec_to_device recursion to apply _DEVICE_RENAMES +# (remote_as -> remote-as) inside each neighbor entry. +_ENTRY_OVERRIDES = { + "areas": (_area_to_device, _area_from_device), + "routes": (_route_to_device, _route_from_device), +} +# neighbor: use generic _spec_to_device with neighbor sub-options. +# Cannot declare inline above because _spec_to_device not yet defined. +# Assigned after ARGUMENT_SPEC is defined (see bottom of file). + + +# --------------------------------------------------------------------------- +# VRF address_family — bespoke because device uses ip/ipv6 as keys +# (not a standard tag node with argspec-named keys) +# nht_no_resolve_via_default maps to nested nht.no-resolve-via-default +# --------------------------------------------------------------------------- + +_AFI_TO_DEVICE = {"ipv4": "ip", "ipv6": "ipv6"} +_AFI_FROM_DEVICE = {"ip": "ipv4", "ipv6": "ipv6"} + + +def _af_to_device(af): + """Single address_family entry -> device ip/ipv6 subtree.""" + d = {} + if af.get("disable_forwarding"): + d["disable-forwarding"] = {} + if af.get("nht_no_resolve_via_default"): + d["nht"] = {"no-resolve-via-default": {}} + for rm in af.get("route_maps") or []: + proto = rm.get("protocol") + rm_name = rm.get("rm_name") + if proto and rm_name: + d.setdefault("protocol", {})[proto] = {"route-map": rm_name} + return d + + +def _af_from_device(afi_key, raw): + """Device ip/ipv6 subtree -> single address_family entry.""" + raw = raw or {} + entry = {"afi": _AFI_FROM_DEVICE.get(afi_key, afi_key)} + if "disable-forwarding" in raw: + entry["disable_forwarding"] = True + nht = raw.get("nht") or {} + if isinstance(nht, dict) and "no-resolve-via-default" in nht: + entry["nht_no_resolve_via_default"] = True + proto_raw = raw.get("protocol") or {} + if isinstance(proto_raw, dict): + rms = [ + {"protocol": p, "rm_name": (d or {}).get("route-map")} + for p, d in proto_raw.items() + if (d or {}).get("route-map") + ] + if rms: + entry["route_maps"] = rms + return entry + + +# --------------------------------------------------------------------------- +# VRF instance converters +# --------------------------------------------------------------------------- + + +def _instance_to_device(inst): + """argspec instance -> device VRF entry (non-protocol fields).""" + d = _spec_to_device( + {k: v for k, v in inst.items() if k not in ("name", "address_family", "protocols")}, + _INSTANCE_OPTIONS, + ) + for af in inst.get("address_family") or []: + afi = af.get("afi") + if afi: + dev_key = _AFI_TO_DEVICE.get(afi, afi) + af_data = _af_to_device(af) + if af_data: + d[dev_key] = af_data + return d + + +def _instance_from_device(name, raw): + """Device VRF entry -> argspec instance (non-protocol fields).""" + raw_base = {k: v for k, v in raw.items() if k not in ("ip", "ipv6", "protocols")} + inst = {"name": name} + d = _device_to_spec(raw_base, _INSTANCE_OPTIONS) + cast_by_spec(d, _INSTANCE_OPTIONS) + inst.update({k: v for k, v in d.items() if k not in ("address_family", "protocols")}) + afs = [_af_from_device(key, raw[key]) for key in ("ip", "ipv6") if key in raw and raw[key]] + if afs: + inst["address_family"] = afs + return inst + + +# --------------------------------------------------------------------------- +# Top-level config converters +# --------------------------------------------------------------------------- + + +def _config_to_device(config): + """Top-level argspec config -> device dict.""" + config = config or {} + result = _spec_to_device( + {k: v for k, v in config.items() if k != "instances"}, + _TOP_OPTIONS, + ) + name_dict = { + inst["name"]: _instance_to_device(inst) + for inst in config.get("instances") or [] + if inst.get("name") + } + if name_dict: + result["name"] = name_dict + return result + + +def _device_to_argspec(raw): + """Device raw config -> argspec (non-protocol fields).""" + raw = raw or {} + result = _device_to_spec( + {k: v for k, v in raw.items() if k != "name"}, + _TOP_OPTIONS, + ) + name_raw = raw.get("name") or {} + if isinstance(name_raw, dict): + instances = [ + _instance_from_device(vrf_name, vrf_data or {}) + for vrf_name, vrf_data in sorted(name_raw.items()) + ] + if instances: + result["instances"] = instances + return result + + +# --------------------------------------------------------------------------- +# Protocol converters — generic walkers with protocol sub-specs +# --------------------------------------------------------------------------- + + +def _proto_to_device(proto_config, proto_key): + """argspec protocol config -> device protocol dict.""" + result = _spec_to_device(proto_config or {}, _PROTO_OPTIONS[proto_key]["options"]) + return result + + +def _proto_from_device(raw, proto_key): + """Device protocol dict -> argspec protocol config.""" + result = _device_to_spec(raw or {}, _PROTO_OPTIONS[proto_key]["options"]) + cast_by_spec(result, _PROTO_OPTIONS[proto_key]["options"]) + return result + + +# --------------------------------------------------------------------------- +# Protocol dispatch — single source of truth. +# Adding a new protocol: one entry in _PROTO_HANDLERS + argspec only. +# --------------------------------------------------------------------------- + +_PROTO_HANDLERS = ["bgp", "ospf", "static"] + +# Tag-node containers per protocol (device key name) — used for placeholder seeding. +_PROTO_TAG_CONTAINERS = { + "bgp": ["neighbor"], + "ospf": ["area"], + "static": ["route"], +} + + +def _protocols_from_device(raw_vrf): + """Extract and convert all protocol configs from raw VRF device data.""" + proto_raw = (raw_vrf or {}).get("protocols") or {} + result = {} + for proto_key in _PROTO_HANDLERS: + if proto_raw.get(proto_key): + converted = _proto_from_device(proto_raw[proto_key], proto_key) + if converted: + result[proto_key] = converted + return result or None + + +def _seed_tag_node_placeholders(want, have, proto_key): + """Seed empty placeholders for new tag-node entries so dict_op uses + verbatim keys rather than guessing a kebab-case translation.""" + for container in _PROTO_TAG_CONTAINERS.get(proto_key, []): + want_entries = want.get(container) or {} + if isinstance(want_entries, dict): + have.setdefault(container, {}) + for entry_key in want_entries: + have[container].setdefault(entry_key, {}) + + +def _protocol_commands(vrf_name, protocols, raw_proto, state): + """Generate protocol commands for a VRF instance.""" + cmds = [] + for proto_key in _PROTO_HANDLERS: + want_proto = (protocols or {}).get(proto_key) + raw_have_proto = (raw_proto or {}).get(proto_key) or {} + + if want_proto is None and state not in ("overridden", "replaced"): + continue + + if want_proto is None: + if raw_have_proto: + cmds.append(("delete", _BASE + ["name", vrf_name, "protocols", proto_key])) + continue + + proto_base = _BASE + ["name", vrf_name, "protocols", proto_key] + want = _proto_to_device(want_proto, proto_key) + norm_have = _proto_to_device( + _proto_from_device(raw_have_proto, proto_key), + proto_key, + ) + _seed_tag_node_placeholders(want, norm_have, proto_key) + + if state in ("overridden", "replaced"): + cmds += dict_op(want, norm_have, proto_base, op="purge") + cmds += dict_op(want, norm_have, proto_base, op="set") + return cmds + + +# --------------------------------------------------------------------------- +# Running config +# --------------------------------------------------------------------------- + + +def get_running_config(vyos): + try: + return vyos.get_config(_BASE) or {} + except Exception as exc: + if "Configuration under specified path is empty" in str(exc): + return {} + raise + + +# --------------------------------------------------------------------------- +# Build commands +# --------------------------------------------------------------------------- + + +def build_commands(config, raw_have, state): + raw_have = raw_have or {} + config = config or {} + cmds = [] + + if state == "deleted": + instances = config.get("instances") or [] + if not instances: + return [("delete", _BASE)] if raw_have else [] + for inst in instances: + vrf_name = inst.get("name") + if vrf_name and (raw_have.get("name") or {}).get(vrf_name) is not None: + cmds.append(("delete", _BASE + ["name", vrf_name])) + if "bind_to_all" in config and config["bind_to_all"] is False and "bind-to-all" in raw_have: + cmds.append(("delete", _BASE + ["bind-to-all"])) + return cmds + + want = _config_to_device(config) + norm_have = _config_to_device(_device_to_argspec(raw_have)) + + # Seed placeholders for new VRF instances (verbatim tag-node keys) + for vrf_name in want.get("name") or {}: + norm_have.setdefault("name", {}).setdefault(vrf_name, {}) + + if state == "overridden": + cmds += dict_op(want, norm_have, _BASE, op="purge") + elif state == "replaced": + for vrf_name, vrf_want in (want.get("name") or {}).items(): + vrf_have = (norm_have.get("name") or {}).get(vrf_name) or {} + cmds += dict_op(vrf_want, vrf_have, _BASE + ["name", vrf_name], op="purge") + if "bind-to-all" not in want and "bind-to-all" in norm_have: + cmds.append(("delete", _BASE + ["bind-to-all"])) + + cmds += dict_op(want, norm_have, _BASE, op="set") + + # Protocol commands per VRF instance + for inst in config.get("instances") or []: + vrf_name = inst.get("name") + if not vrf_name: + continue + protocols = inst.get("protocols") or {} + raw_vrf = (raw_have.get("name") or {}).get(vrf_name) or {} + raw_proto = raw_vrf.get("protocols") or {} + if protocols or state in ("overridden", "replaced"): + cmds += _protocol_commands(vrf_name, protocols, raw_proto, state) + + return cmds + + +# --------------------------------------------------------------------------- +# Enrich have/after with protocol data +# --------------------------------------------------------------------------- + + +def _enrich_with_protocols(instances, raw_have): + """Add protocol data to each instance dict in-place.""" + for inst in instances or []: + raw_vrf = (raw_have.get("name") or {}).get(inst["name"]) or {} + protocols = _protocols_from_device(raw_vrf) + if protocols: + inst["protocols"] = protocols + + +# --------------------------------------------------------------------------- +# ARGUMENT_SPEC +# --------------------------------------------------------------------------- ARGUMENT_SPEC = dict( config=dict( @@ -211,11 +752,7 @@ ARGUMENT_SPEC = dict( type="list", elements="dict", options=dict( - afi=dict( - type="str", - required=True, - choices=["ipv4", "ipv6"], - ), + afi=dict(type="str", required=True, choices=["ipv4", "ipv6"]), disable_forwarding=dict(type="bool", default=False), nht_no_resolve_via_default=dict(type="bool", default=False), route_maps=dict( @@ -286,10 +823,7 @@ ARGUMENT_SPEC = dict( elements="dict", options=dict( dest=dict(type="str", required=True), - next_hops=dict( - type="list", - elements="str", - ), + next_hops=dict(type="list", elements="str"), ), ), ), @@ -307,480 +841,23 @@ ARGUMENT_SPEC = dict( ), ) +# Initialize spec references after ARGUMENT_SPEC is defined +_TOP_OPTIONS = ARGUMENT_SPEC["config"]["options"] +_INSTANCE_OPTIONS = _TOP_OPTIONS["instances"]["options"] +_PROTO_OPTIONS = _INSTANCE_OPTIONS["protocols"]["options"] + +# Add neighbor entry override now that ARGUMENT_SPEC is defined. +# Uses _spec_to_device recursion so remote_as -> remote-as rename is applied. +_NEIGHBOR_OPTIONS = _PROTO_OPTIONS["bgp"]["options"]["neighbor"]["options"] +_ENTRY_OVERRIDES["neighbor"] = ( + lambda rest: _spec_to_device(rest, _NEIGHBOR_OPTIONS), + lambda d: _device_to_spec(d, _NEIGHBOR_OPTIONS), +) -def _af_to_device(af): - result = {} - if af.get("disable_forwarding"): - result["disable-forwarding"] = {} - if af.get("nht_no_resolve_via_default"): - result["nht"] = {"no-resolve-via-default": {}} - if af.get("route_maps"): - protocol = {} - for rm in af["route_maps"]: - if rm.get("protocol") and rm.get("rm_name"): - protocol[rm["protocol"]] = {"route-map": rm["rm_name"]} - if protocol: - result["protocol"] = protocol - return result - - -def _af_from_device(afi_key, raw): - if not raw or not isinstance(raw, dict): - return None - entry = {"afi": _AFI_REVERSE.get(afi_key, afi_key)} - if "disable-forwarding" in raw: - entry["disable_forwarding"] = True - nht = raw.get("nht", {}) - if isinstance(nht, dict) and "no-resolve-via-default" in nht: - entry["nht_no_resolve_via_default"] = True - proto_raw = raw.get("protocol", {}) - if proto_raw and isinstance(proto_raw, dict): - route_maps = [] - for proto, proto_data in proto_raw.items(): - if isinstance(proto_data, dict): - rm_name = proto_data.get("route-map") - if rm_name: - route_maps.append({"protocol": proto, "rm_name": rm_name}) - if route_maps: - entry["route_maps"] = route_maps - return entry - - -def _instance_to_device(instance): - result = {} - if instance.get("description"): - result["description"] = instance["description"] - if instance.get("disable"): - result["disable"] = {} - if instance.get("table_id") is not None: - result["table"] = str(instance["table_id"]) - if instance.get("vni") is not None: - result["vni"] = str(instance["vni"]) - for af in instance.get("address_family") or []: - afi = af.get("afi") - if not afi: - continue - device_afi_key = _AFI_MAP.get(afi, afi) - af_data = _af_to_device(af) - if af_data: - result[device_afi_key] = af_data - return result - - -def _instance_from_device(name, raw): - if not raw or not isinstance(raw, dict): - return {"name": name} - instance = {"name": name} - if raw.get("description"): - instance["description"] = raw["description"] - if "disable" in raw: - instance["disable"] = True - if raw.get("table"): - instance["table_id"] = int(raw["table"]) - if raw.get("vni"): - instance["vni"] = int(raw["vni"]) - address_family = [] - for afi_key in ("ip", "ipv6"): - if afi_key in raw: - af_entry = _af_from_device(afi_key, raw[afi_key]) - if af_entry: - address_family.append(af_entry) - if address_family: - instance["address_family"] = address_family - # protocols are handled separately in build_commands / gathered - return instance - - -def _want_to_device(config): - config = config or {} - result = {} - if config.get("bind_to_all"): - result["bind-to-all"] = {} - instances = config.get("instances") or [] - if instances: - name_dict = {} - for inst in instances: - vrf_name = inst.get("name") - if not vrf_name: - continue - name_dict[vrf_name] = _instance_to_device(inst) - if name_dict: - result["name"] = name_dict - return result - - -def _device_to_argspec(raw): - if not raw or not isinstance(raw, dict): - return {} - result = {} - if "bind-to-all" in raw: - result["bind_to_all"] = True - name_raw = raw.get("name", {}) - if name_raw and isinstance(name_raw, dict): - instances = [] - for vrf_name, vrf_data in sorted(name_raw.items()): - instances.append(_instance_from_device(vrf_name, vrf_data or {})) - if instances: - result["instances"] = instances - return result - - -def _bgp_from_device(raw): - """Convert BGP gathered data from device format to argspec format.""" - if not raw or not isinstance(raw, dict): - return {} - result = {} - if raw.get("system-as"): - result["system_as"] = int(raw["system-as"]) - neighbors_raw = raw.get("neighbor", {}) - if neighbors_raw and isinstance(neighbors_raw, dict): - neighbors = [] - for addr, data in sorted(neighbors_raw.items()): - entry = {"address": addr} - data = data or {} - if data.get("remote-as"): - entry["remote_as"] = int(data["remote-as"]) - if data.get("description"): - entry["description"] = data["description"] - neighbors.append(entry) - if neighbors: - result["neighbor"] = neighbors - return result - - -def _bgp_build_commands(vrf_name, want_bgp, have_bgp, state): - """Generate BGP commands for a VRF instance using dict_op.""" - bgp_base = _BASE + ["name", vrf_name, "protocols", "bgp"] - want_bgp = want_bgp or {} - have_bgp = have_bgp or {} - - want = {} - if want_bgp.get("system_as") is not None: - want["system_as"] = want_bgp["system_as"] - if want_bgp.get("neighbor"): - want["neighbor"] = { - n["address"]: {k: v for k, v in n.items() if k != "address"} - for n in want_bgp["neighbor"] - if n.get("address") - } - - have = {} - if have_bgp.get("system-as"): - have["system_as"] = int(have_bgp["system-as"]) - if have_bgp.get("neighbor"): - have["neighbor"] = { - addr: { - "remote_as": int(data["remote-as"]) if (data or {}).get("remote-as") else None, - "description": (data or {}).get("description"), - } - for addr, data in (have_bgp.get("neighbor") or {}).items() - } - - # Seed neighbor placeholders for new entries - for addr in want.get("neighbor") or {}: - have.setdefault("neighbor", {}).setdefault(addr, {}) - - commands = [] - if state in ("overridden", "replaced"): - commands += dict_op(want, have, bgp_base, op="purge") - commands += dict_op(want, have, bgp_base, op="set") - return commands - - -def _ospf_from_device(raw): - """Convert OSPFv2 config from device format to argspec.""" - if not raw or not isinstance(raw, dict): - return {} - result = {} - areas_raw = raw.get("area", {}) - if areas_raw and isinstance(areas_raw, dict): - areas = [] - for area_id, area_data in sorted(areas_raw.items()): - entry = {"area_id": str(area_id)} - area_data = area_data or {} - networks_raw = area_data.get("network") - if networks_raw: - if isinstance(networks_raw, str): - entry["networks"] = [networks_raw] - elif isinstance(networks_raw, dict): - entry["networks"] = sorted(networks_raw.keys()) - else: - entry["networks"] = list(networks_raw) - areas.append(entry) - if areas: - result["areas"] = areas - params_raw = raw.get("parameters", {}) - if params_raw and isinstance(params_raw, dict): - params = {} - if params_raw.get("router-id"): - params["router_id"] = params_raw["router-id"] - if params: - result["parameters"] = params - return result - - -def _ospf_build_commands(vrf_name, want_ospf, have_ospf, state): - """Generate OSPFv2 commands for a VRF instance using dict_op.""" - ospf_base = _BASE + ["name", vrf_name, "protocols", "ospf"] - want_ospf = want_ospf or {} - have_ospf = have_ospf or {} - - want = {} - if want_ospf.get("areas"): - want["area"] = {} - for a in want_ospf["areas"]: - area_id = a.get("area_id") - if not area_id: - continue - area_entry = {} - if a.get("networks"): - area_entry["network"] = sorted(a["networks"]) - want["area"][str(area_id)] = area_entry - if want_ospf.get("parameters", {}).get("router_id"): - want["parameters"] = {"router_id": want_ospf["parameters"]["router_id"]} - - have = {} - areas_raw = have_ospf.get("area", {}) - if areas_raw and isinstance(areas_raw, dict): - have["area"] = {} - for area_id, area_data in areas_raw.items(): - area_data = area_data or {} - area_entry = {} - networks_raw = area_data.get("network") - if networks_raw: - if isinstance(networks_raw, str): - area_entry["network"] = [networks_raw] - elif isinstance(networks_raw, list): - area_entry["network"] = sorted(networks_raw) - elif isinstance(networks_raw, dict): - area_entry["network"] = sorted(networks_raw.keys()) - have["area"][str(area_id)] = area_entry - params_raw = have_ospf.get("parameters", {}) - if params_raw and isinstance(params_raw, dict) and params_raw.get("router-id"): - have["parameters"] = {"router_id": params_raw["router-id"]} - - # Seed area placeholders - for area_id in want.get("area") or {}: - have.setdefault("area", {}).setdefault(area_id, {}) - - commands = [] - if state in ("overridden", "replaced"): - commands += dict_op(want, have, ospf_base, op="purge") - commands += dict_op(want, have, ospf_base, op="set") - return commands - - -def _static_from_device(raw): - """Convert static routes config from device format to argspec.""" - if not raw or not isinstance(raw, dict): - return {} - routes_raw = raw.get("route", {}) - if not routes_raw or not isinstance(routes_raw, dict): - return {} - routes = [] - for dest, route_data in sorted(routes_raw.items()): - entry = {"dest": dest} - route_data = route_data or {} - next_hops_raw = route_data.get("next-hop", {}) - if next_hops_raw and isinstance(next_hops_raw, dict): - entry["next_hops"] = sorted(next_hops_raw.keys()) - routes.append(entry) - return {"routes": routes} if routes else {} - - -def _static_build_commands(vrf_name, want_static, have_static, state): - """Generate static route commands for a VRF instance using dict_op.""" - static_base = _BASE + ["name", vrf_name, "protocols", "static"] - want_static = want_static or {} - have_static = have_static or {} - - want = {} - if want_static.get("routes"): - want["route"] = {} - for r in want_static["routes"]: - dest = r.get("dest") - if not dest: - continue - route_entry = {} - if r.get("next_hops"): - route_entry["next-hop"] = {nh: {} for nh in r["next_hops"]} - want["route"][dest] = route_entry - - have = {} - routes_raw = have_static.get("route", {}) - if routes_raw and isinstance(routes_raw, dict): - have["route"] = {} - for dest, route_data in routes_raw.items(): - route_data = route_data or {} - route_entry = {} - next_hops_raw = route_data.get("next-hop", {}) - if next_hops_raw and isinstance(next_hops_raw, dict): - route_entry["next-hop"] = {k: {} for k in next_hops_raw} - have["route"][dest] = route_entry - - # Seed route placeholders - for dest in want.get("route") or {}: - have.setdefault("route", {}).setdefault(dest, {}) - - commands = [] - if state in ("overridden", "replaced"): - commands += dict_op(want, have, static_base, op="purge") - commands += dict_op(want, have, static_base, op="set") - return commands - - -def _protocols_from_device(raw_vrf): - """Extract and convert protocol configs from raw VRF device data.""" - proto_raw = (raw_vrf or {}).get("protocols", {}) - if not proto_raw: - return None - result = {} - if proto_raw.get("bgp"): - bgp = _bgp_from_device(proto_raw["bgp"]) - if bgp: - result["bgp"] = bgp - if proto_raw.get("ospf"): - ospf = _ospf_from_device(proto_raw["ospf"]) - if ospf: - result["ospf"] = ospf - if proto_raw.get("static"): - static = _static_from_device(proto_raw["static"]) - if static: - result["static"] = static - return result or None - - -def get_running_config(vyos): - try: - return vyos.get_config(_BASE) or {} - except Exception as exc: - if "Configuration under specified path is empty" in str(exc): - return {} - raise - - -def build_commands(config, raw_have, state): - raw_have = raw_have or {} - config = config or {} - all_commands = [] - - if state == "deleted": - instances = (config.get("instances") or []) if config else [] - if not instances: - return [("delete", _BASE)] if raw_have else [] - for inst in instances: - vrf_name = inst.get("name") - if vrf_name and raw_have.get("name", {}).get(vrf_name) is not None: - all_commands.append(("delete", _BASE + ["name", vrf_name])) - if ( - "bind_to_all" in (config or {}) - and config["bind_to_all"] is False - and "bind-to-all" in raw_have - ): - all_commands.append(("delete", _BASE + ["bind-to-all"])) - return all_commands - - want = _want_to_device(config) - norm_have = _want_to_device(_device_to_argspec(raw_have)) - - # Seed placeholders for new VRF instances - want_names = want.get("name", {}) - have_names = norm_have.setdefault("name", {}) - for vrf_name in want_names: - if vrf_name not in have_names: - have_names[vrf_name] = {} - - if state == "overridden": - all_commands += dict_op(want, norm_have, _BASE, op="purge") - elif state == "replaced": - for vrf_name, vrf_want in (want.get("name") or {}).items(): - vrf_have = (norm_have.get("name") or {}).get(vrf_name, {}) - all_commands += dict_op( - vrf_want, - vrf_have, - _BASE + ["name", vrf_name], - op="purge", - ) - if "bind-to-all" not in want and "bind-to-all" in norm_have: - all_commands.append(("delete", _BASE + ["bind-to-all"])) - - all_commands += dict_op(want, norm_have, _BASE, op="set") - - # Protocol commands — per VRF instance - for inst in config.get("instances") or []: - vrf_name = inst.get("name") - protocols = inst.get("protocols") or {} - if not vrf_name or not protocols: - continue - - raw_vrf = (raw_have.get("name") or {}).get(vrf_name, {}) - raw_proto = raw_vrf.get("protocols", {}) - - # BGP - if protocols.get("bgp") is not None or state in ("overridden", "replaced"): - if protocols.get("bgp") is None and state in ("overridden", "replaced"): - if raw_proto.get("bgp"): - all_commands.append(("delete", _BASE + ["name", vrf_name, "protocols", "bgp"])) - elif protocols.get("bgp") is not None: - all_commands += _bgp_build_commands( - vrf_name, - protocols["bgp"], - raw_proto.get("bgp", {}), - state, - ) - # OSPFv2 - if protocols.get("ospf") is not None or state in ("overridden", "replaced"): - if protocols.get("ospf") is None and state in ("overridden", "replaced"): - if raw_proto.get("ospf"): - all_commands.append(("delete", _BASE + ["name", vrf_name, "protocols", "ospf"])) - elif protocols.get("ospf") is not None: - all_commands += _ospf_build_commands( - vrf_name, - protocols["ospf"], - raw_proto.get("ospf", {}), - state, - ) - # Static routes - if protocols.get("static") is not None or state in ("overridden", "replaced"): - if protocols.get("static") is None and state in ("overridden", "replaced"): - if raw_proto.get("static"): - all_commands.append( - ("delete", _BASE + ["name", vrf_name, "protocols", "static"]), - ) - elif protocols.get("static") is not None: - all_commands += _static_build_commands( - vrf_name, - protocols["static"], - raw_proto.get("static", {}), - state, - ) - return all_commands - - want = _want_to_device(config) - norm_have = _want_to_device(_device_to_argspec(raw_have)) - - # Seed placeholders for new VRF instances - want_names = want.get("name", {}) - have_names = norm_have.setdefault("name", {}) - for vrf_name in want_names: - if vrf_name not in have_names: - have_names[vrf_name] = {} - - if state == "overridden": - all_commands += dict_op(want, norm_have, _BASE, op="purge") - elif state == "replaced": - for vrf_name, vrf_want in (want.get("name") or {}).items(): - vrf_have = (norm_have.get("name") or {}).get(vrf_name, {}) - all_commands += dict_op( - vrf_want, - vrf_have, - _BASE + ["name", vrf_name], - op="purge", - ) - if "bind-to-all" not in want and "bind-to-all" in norm_have: - all_commands.append(("delete", _BASE + ["bind-to-all"])) - all_commands += dict_op(want, norm_have, _BASE, op="set") +# --------------------------------------------------------------------------- +# main +# --------------------------------------------------------------------------- def main(): @@ -791,41 +868,27 @@ def main(): raw_have = get_running_config(vyos) have = _device_to_argspec(raw_have) - for inst in have.get("instances") or []: - raw_vrf = (raw_have.get("name") or {}).get(inst["name"], {}) - protocols = _protocols_from_device(raw_vrf) - if protocols: - inst["protocols"] = protocols + _enrich_with_protocols(have.get("instances"), raw_have) if state == "gathered": - for inst in have.get("instances") or []: - vrf_name = inst["name"] - raw_vrf = (raw_have.get("name") or {}).get(vrf_name, {}) - protocols = _protocols_from_device(raw_vrf) - if protocols: - inst["protocols"] = protocols module.exit_json(changed=False, gathered=have) - commands = build_commands(config, raw_have, state) + cmds = 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(cmds), commands=cmds, before=have) - if commands: - response = vyos.apply_commands(commands) + if cmds: + response = vyos.apply_commands(cmds) saved = vyos.save_config() raw_after = get_running_config(vyos) after = _device_to_argspec(raw_after) - for inst in after.get("instances") or []: - raw_vrf = (raw_after.get("name") or {}).get(inst["name"], {}) - protocols = _protocols_from_device(raw_vrf) - if protocols: - inst["protocols"] = protocols + _enrich_with_protocols(after.get("instances"), raw_after) module.exit_json( changed=True, before=have, after=after, - commands=commands, + commands=cmds, saved=saved, response=response, ) |
