summaryrefslogtreecommitdiff
path: root/plugins/modules/vyos_vrf.py
diff options
context:
space:
mode:
Diffstat (limited to 'plugins/modules/vyos_vrf.py')
-rw-r--r--plugins/modules/vyos_vrf.py1028
1 files changed, 1028 insertions, 0 deletions
diff --git a/plugins/modules/vyos_vrf.py b/plugins/modules/vyos_vrf.py
new file mode 100644
index 0000000..3aba832
--- /dev/null
+++ b/plugins/modules/vyos_vrf.py
@@ -0,0 +1,1028 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+# 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
+
+
+__metaclass__ = type
+
+DOCUMENTATION = r"""
+---
+module: vyos_vrf
+short_description: Manage VRF configuration on VyOS devices using REST API
+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, static routes) is
+ managed inline with focused scope (core fields only).
+version_added: "1.0.0"
+author:
+ - VyOS Community (@vyos)
+options:
+ config:
+ description: VRF configuration.
+ type: dict
+ suboptions:
+ bind_to_all:
+ description:
+ - Enable binding services to all VRFs.
+ - >-
+ Whether omitting this option preserves the current device
+ setting depends on C(state). With C(merged), and with
+ C(deleted) when specific C(instances) are named, omission
+ leaves it untouched. With C(replaced) or C(overridden),
+ omission deletes an existing setting, since those states
+ replace everything not explicitly present in C(config).
+ With C(deleted) and no C(instances) given, the entire VRF
+ configuration (including this setting) is removed.
+ - Only set this explicitly (C(true) or C(false)) when you want
+ this module to manage it under C(merged) or C(deleted) with
+ named instances.
+ type: bool
+ instances:
+ description: List of VRF instances.
+ type: list
+ elements: dict
+ suboptions:
+ name:
+ description: VRF instance name.
+ type: str
+ required: true
+ description:
+ description: VRF description.
+ type: str
+ disable:
+ description: Administratively disable this VRF.
+ type: bool
+ default: false
+ table_id:
+ description:
+ - Routing table ID associated with this VRF.
+ - The device enforces the valid range and rejects an invalid
+ value with its own error message.
+ - VyOS does not support changing an existing VRF's table ID
+ in place -- it must be deleted and recreated. C(state=merged)
+ and C(state=replaced) fail with a clear error if this
+ differs from the current device value, rather than
+ silently deleting and recreating the VRF. Use
+ C(state=overridden) (which deletes and recreates the VRF,
+ re-applying every other desired field, since it already
+ replaces everything to match C(config)), or explicitly run
+ C(state=deleted) followed by C(state=merged)/C(replaced)
+ as separate tasks.
+ type: int
+ vni:
+ description: Virtual Network Identifier.
+ type: int
+ address_family:
+ description: Address family configuration.
+ type: list
+ elements: dict
+ suboptions:
+ afi:
+ description: Address family identifier.
+ type: str
+ required: true
+ choices: [ipv4, ipv6]
+ disable_forwarding:
+ description: Disable IP forwarding for this address family.
+ type: bool
+ default: false
+ nht_no_resolve_via_default:
+ description: Disable next-hop resolution via default route.
+ type: bool
+ default: false
+ route_maps:
+ description: Route maps applied per protocol.
+ type: list
+ elements: dict
+ suboptions:
+ protocol:
+ description: Protocol to which the route map applies.
+ type: str
+ required: true
+ choices: [any, babel, bgp, eigrp, isis, ospf, rip, static]
+ rm_name:
+ description: Route map name.
+ type: str
+ required: true
+ protocols:
+ description: Protocol configuration within this VRF instance.
+ type: dict
+ suboptions:
+ bgp:
+ 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 (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.
+ 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]
+"""
+
+EXAMPLES = r"""
+- name: Merge VRF instances
+ vyos.rest.vyos_vrf:
+ config:
+ bind_to_all: true
+ instances:
+ - 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: Delete specific VRF
+ vyos.rest.vyos_vrf:
+ config:
+ instances:
+ - name: vrf1
+ state: deleted
+
+- name: Delete all VRF configuration
+ vyos.rest.vyos_vrf:
+ state: deleted
+
+- name: Gather current VRF configuration
+ vyos.rest.vyos_vrf:
+ state: gathered
+"""
+
+RETURN = r"""
+before:
+ description: VRF configuration before this module ran.
+ returned: state is not gathered
+ type: dict
+after:
+ description: VRF configuration after this module ran.
+ returned: when changed
+ type: dict
+commands:
+ description: List of API command tuples sent to the device, or that
+ would be sent (in check mode).
+ returned: state is not gathered
+ type: list
+gathered:
+ description: Current VRF configuration as structured data.
+ returned: when state is gathered
+ type: dict
+saved:
+ description: Whether the config was saved after changes.
+ returned: when changed
+ type: bool
+"""
+
+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"]
+
+# ---------------------------------------------------------------------------
+# 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",
+ "bind_to_all": "bind-to-all",
+ "router_id": "router-id",
+}
+
+
+# ---------------------------------------------------------------------------
+# 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_from_unused = _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_to_unused, 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 _routes_to_device(routes):
+ """Split routes by address family -- VyOS requires IPv4 static
+ routes under "route" and IPv6 under "route6" as genuinely separate
+ device subtrees (confirmed against VyOS's own interface-definitions
+ schema: static-route.xml.i and static-route6.xml.i are distinct
+ includes, not a single shared "route" path for both families).
+ """
+ device = {}
+ for entry in routes or []:
+ dest = entry.get("dest")
+ if not dest:
+ continue
+ container = "route6" if ":" in dest else "route"
+ route_device = _route_to_device({k: v for k, v in entry.items() if k != "dest"})
+ device.setdefault(container, {})[dest] = route_device
+ return device
+
+
+def _routes_from_device(raw):
+ """Inverse of _routes_to_device -- merge the route and route6
+ device subtrees back into a single argspec routes list."""
+ entries = []
+ for container in ("route", "route6"):
+ raw_container = (raw or {}).get(container)
+ if raw_container:
+ entries += _keyed_list_from_device(raw_container, "dest", _route_from_device)
+ return sorted(entries, key=lambda e: e["dest"])
+
+
+def _proto_to_device(proto_config, proto_key):
+ """argspec protocol config -> device protocol dict."""
+ if proto_key == "static":
+ return _routes_to_device((proto_config or {}).get("routes"))
+ 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."""
+ if proto_key == "static":
+ routes = _routes_from_device(raw)
+ return {"routes": routes} if routes else {}
+ 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", "route6"],
+}
+
+
+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, {})
+
+ # VyOS's routing table ID cannot be modified in place once assigned --
+ # confirmed via VyOS's own official documentation ("A routing table ID
+ # can not be modified once it is assigned. It can only be changed by
+ # deleting and re-adding the VRF instance") and its source
+ # (ConfigError: "VRF ... table id modification not possible!"). This
+ # module never does that destructive delete-and-recreate silently
+ # under merged/replaced -- only overridden's own explicit contract
+ # ("replace everything to match want, destructively if needed")
+ # covers it; merged/replaced fail loudly instead, so a table_id
+ # change is always something the user explicitly asked for via the
+ # right state, not a surprise this module decided on their behalf.
+ recreated_vrfs = set()
+ for vrf_name, vrf_want in (want.get("name") or {}).items():
+ vrf_have = (norm_have.get("name") or {}).get(vrf_name) or {}
+ want_table = vrf_want.get("table")
+ have_table = vrf_have.get("table")
+ if want_table is None or have_table is None or str(want_table) == str(have_table):
+ continue
+ if state == "overridden":
+ cmds.append(("delete", _BASE + ["name", vrf_name]))
+ norm_have["name"][vrf_name] = {}
+ recreated_vrfs.add(vrf_name)
+ else:
+ raise ValueError(
+ "VRF '{name}': table_id cannot be changed in place under "
+ "state={state} -- VyOS does not support modifying an "
+ "existing VRF's routing table. Use state=overridden, or "
+ "explicitly remove and recreate it with separate "
+ "state=deleted and state=merged/replaced tasks.".format(
+ name=vrf_name,
+ state=state,
+ ),
+ )
+
+ 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"]))
+ elif config.get("bind_to_all") is False and "bind-to-all" in norm_have:
+ # merged never runs a purge pass, so _spec_to_device's blanket
+ # "val is False: continue" skip means an explicit bind_to_all:
+ # false is otherwise indistinguishable from omission by the
+ # time dict_op sees "want" -- confirmed real bug: the device's
+ # bind-to-all setting silently stayed enabled with no delete
+ # command generated and changed=false reported.
+ 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 {}
+ if vrf_name in recreated_vrfs:
+ raw_vrf = {}
+ else:
+ 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") or vrf_name in recreated_vrfs:
+ 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(
+ type="dict",
+ options=dict(
+ bind_to_all=dict(type="bool"),
+ instances=dict(
+ type="list",
+ elements="dict",
+ options=dict(
+ name=dict(type="str", required=True),
+ description=dict(type="str"),
+ disable=dict(type="bool", default=False),
+ table_id=dict(type="int"),
+ vni=dict(type="int"),
+ address_family=dict(
+ type="list",
+ elements="dict",
+ options=dict(
+ 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(
+ type="list",
+ elements="dict",
+ options=dict(
+ protocol=dict(
+ type="str",
+ required=True,
+ choices=[
+ "any",
+ "babel",
+ "bgp",
+ "eigrp",
+ "isis",
+ "ospf",
+ "rip",
+ "static",
+ ],
+ ),
+ rm_name=dict(type="str", required=True),
+ ),
+ ),
+ ),
+ ),
+ protocols=dict(
+ type="dict",
+ options=dict(
+ bgp=dict(
+ type="dict",
+ options=dict(
+ system_as=dict(type="int"),
+ neighbor=dict(
+ type="list",
+ elements="dict",
+ options=dict(
+ address=dict(type="str", required=True),
+ remote_as=dict(type="int"),
+ description=dict(type="str"),
+ ),
+ ),
+ ),
+ ),
+ ospf=dict(
+ type="dict",
+ options=dict(
+ areas=dict(
+ type="list",
+ elements="dict",
+ options=dict(
+ area_id=dict(type="str", required=True),
+ networks=dict(type="list", elements="str"),
+ ),
+ ),
+ parameters=dict(
+ type="dict",
+ options=dict(
+ router_id=dict(type="str"),
+ ),
+ ),
+ ),
+ ),
+ static=dict(
+ type="dict",
+ options=dict(
+ routes=dict(
+ type="list",
+ elements="dict",
+ options=dict(
+ dest=dict(type="str", required=True),
+ next_hops=dict(type="list", elements="str"),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ state=dict(
+ type="str",
+ default="merged",
+ choices=["merged", "replaced", "overridden", "deleted", "gathered"],
+ ),
+)
+
+
+def _init_specs():
+ """Initialize module-level spec references and entry overrides.
+ Called once at import time via _init_specs(). Avoids module-level
+ subscript expressions that confuse ansible-doc's AST walker.
+ """
+ top = ARGUMENT_SPEC["config"]["options"]
+ instance_opts = top["instances"]["options"]
+ proto_opts = instance_opts["protocols"]["options"]
+ neighbor_opts = proto_opts["bgp"]["options"]["neighbor"]["options"]
+
+ global _TOP_OPTIONS, _INSTANCE_OPTIONS, _PROTO_OPTIONS
+
+ _TOP_OPTIONS = top
+ _INSTANCE_OPTIONS = instance_opts
+ _PROTO_OPTIONS = proto_opts
+
+ def _neighbor_entry_to_device(rest):
+ return _spec_to_device(rest, neighbor_opts)
+
+ def _neighbor_entry_from_device(d):
+ return _device_to_spec(d, neighbor_opts)
+
+ _ENTRY_OVERRIDES["neighbor"] = (_neighbor_entry_to_device, _neighbor_entry_from_device)
+
+
+_TOP_OPTIONS = {}
+_INSTANCE_OPTIONS = {}
+_PROTO_OPTIONS = {}
+_init_specs()
+
+
+# ---------------------------------------------------------------------------
+# main
+# ---------------------------------------------------------------------------
+
+
+def main():
+ module = AnsibleModule(argument_spec=ARGUMENT_SPEC, supports_check_mode=True)
+ vyos = VyOSModule(module)
+ state = module.params["state"]
+ config = module.params.get("config") or {}
+
+ raw_have = get_running_config(vyos)
+ have = _device_to_argspec(raw_have)
+ _enrich_with_protocols(have.get("instances"), raw_have)
+
+ if state == "gathered":
+ module.exit_json(changed=False, gathered=have)
+
+ try:
+ cmds = build_commands(config, raw_have, state)
+ except ValueError as exc:
+ module.fail_json(msg=str(exc))
+
+ if module.check_mode:
+ module.exit_json(changed=bool(cmds), commands=cmds, before=have)
+
+ if cmds:
+ response = vyos.apply_commands(cmds)
+ saved = vyos.save_config()
+ raw_after = get_running_config(vyos)
+ after = _device_to_argspec(raw_after)
+ _enrich_with_protocols(after.get("instances"), raw_after)
+ module.exit_json(
+ changed=True,
+ before=have,
+ after=after,
+ commands=cmds,
+ saved=saved,
+ response=response,
+ )
+
+ module.exit_json(changed=False, before=have, after=have, commands=[])
+
+
+if __name__ == "__main__":
+ main()