summaryrefslogtreecommitdiff
path: root/plugins/modules
diff options
context:
space:
mode:
authoromnom62 <omnom62@outlook.com>2026-09-11 05:59:29 +1000
committeromnom62 <omnom62@outlook.com>2026-09-11 05:59:29 +1000
commit4e223fe6fa25dd76afece85f4ac7db2057c94aa9 (patch)
treef03df9c905852016303b74624bd899790693ee75 /plugins/modules
parenteeee4f7174df121fb66792f067cb23f8f09f61d6 (diff)
downloadrest.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.py1149
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,
)