summaryrefslogtreecommitdiff
path: root/plugins/modules/vyos_route_maps.py
diff options
context:
space:
mode:
Diffstat (limited to 'plugins/modules/vyos_route_maps.py')
-rw-r--r--plugins/modules/vyos_route_maps.py842
1 files changed, 607 insertions, 235 deletions
diff --git a/plugins/modules/vyos_route_maps.py b/plugins/modules/vyos_route_maps.py
index 607d1ab..347c58d 100644
--- a/plugins/modules/vyos_route_maps.py
+++ b/plugins/modules/vyos_route_maps.py
@@ -13,6 +13,12 @@ short_description: Manage route-map configuration on VyOS devices using REST API
description:
- Manages route maps on VyOS via the REST API.
- Uses REST API (C(connection=httpapi)) instead of CLI.
+ - >-
+ Covers the commonly used match/set fields (as documented below). VyOS's
+ route-map schema is considerably larger than this (EVPN attributes,
+ extended communities, RPKI matching, on-match goto/next, route-source,
+ source-peer, source-vrf, and more) -- those are not modeled by this
+ module and are a real, documented limitation, not an oversight.
version_added: "1.0.0"
author:
- VyOS Community (@vyos)
@@ -39,6 +45,7 @@ options:
action:
description: Permit or deny.
type: str
+ choices: [permit, deny]
description:
description: Rule description.
type: str
@@ -49,11 +56,166 @@ options:
description: Continue at a different sequence number.
type: int
match:
- description: Match conditions (passed through to VyOS API).
+ description: Match conditions.
type: dict
+ suboptions:
+ interface:
+ description: Interface to match.
+ type: str
+ metric:
+ description: Metric of route to match.
+ type: int
+ origin:
+ description: BGP origin code to match.
+ type: str
+ choices: [egp, igp, incomplete]
+ peer:
+ description: Peer address to match.
+ type: str
+ protocol:
+ description: Match protocol via which the route was learnt.
+ type: str
+ choices:
+ [
+ babel, bgp, connected, isis, kernel, ospf, ospfv3,
+ rip, ripng, static, table, vnc,
+ ]
+ prefix_list:
+ description: IPv4 prefix-list to match.
+ type: str
+ prefix_list6:
+ description: IPv6 prefix-list to match.
+ type: str
+ ip:
+ description: IPv4 next-hop match parameters.
+ type: dict
+ suboptions:
+ nexthop_address:
+ description: IPv4 next-hop address to match.
+ type: str
+ nexthop_prefix_list:
+ description: IPv4 next-hop prefix-list to match.
+ type: str
+ ipv6:
+ description: IPv6 next-hop match parameters.
+ type: dict
+ suboptions:
+ nexthop_address:
+ description: IPv6 next-hop address to match.
+ type: str
set:
- description: Route parameters to set (passed through to VyOS API).
+ description: Route parameters to set.
type: dict
+ suboptions:
+ metric:
+ description: Metric of route.
+ type: int
+ metric_type:
+ description: Metric type.
+ type: str
+ origin:
+ description: BGP origin code to set.
+ type: str
+ choices: [egp, igp, incomplete]
+ originator_id:
+ description: BGP originator ID.
+ type: str
+ src:
+ description: Source address for route.
+ type: str
+ tag:
+ description: Route tag value.
+ type: int
+ weight:
+ description: BGP weight.
+ type: int
+ distance:
+ description: Locally significant administrative distance.
+ type: int
+ table:
+ description: Non-main kernel routing table.
+ type: int
+ local_preference:
+ description: BGP local preference.
+ type: int
+ ip_next_hop:
+ description: IPv4 next-hop address to set.
+ type: str
+ atomic_aggregate:
+ description: Set the BGP atomic aggregate attribute.
+ type: bool
+ as_path_exclude:
+ description: AS number(s) to remove from the as-path attribute.
+ type: str
+ as_path_prepend:
+ description: AS number(s) to prepend to the as-path attribute.
+ type: str
+ as_path_prepend_last_as:
+ description: Number of times to prepend the last AS number in the as-path.
+ type: int
+ aggregator:
+ description: BGP aggregator attribute.
+ type: dict
+ suboptions:
+ as_:
+ description: AS number of an aggregation.
+ type: int
+ aliases: [as]
+ ip:
+ description: IP address of an aggregation.
+ type: str
+ community:
+ description: BGP community attribute.
+ type: dict
+ suboptions:
+ add:
+ description: Communities to add to a prefix.
+ type: list
+ elements: str
+ replace:
+ description: Communities to set for a prefix.
+ type: list
+ elements: str
+ none:
+ description: Completely remove the communities attribute from a prefix.
+ type: bool
+ delete:
+ description: Remove communities defined in a list from a prefix.
+ type: str
+ large_community:
+ description: BGP large community attribute.
+ type: dict
+ suboptions:
+ add:
+ description: Large communities to add to a prefix.
+ type: list
+ elements: str
+ replace:
+ description: Large communities to set for a prefix.
+ type: list
+ elements: str
+ none:
+ description: Completely remove the large-community attribute from a prefix.
+ type: bool
+ delete:
+ description: Remove large communities defined in a list from a prefix.
+ type: str
+ ipv6_next_hop:
+ description: IPv6 next-hop to set.
+ type: dict
+ suboptions:
+ global:
+ description: Nexthop IPv6 global address.
+ type: str
+ local:
+ description: Nexthop IPv6 local address.
+ type: str
+ peer_address:
+ description: Use the peer address (BGP only) as the nexthop.
+ type: bool
+ prefer_global:
+ description: Prefer the global address as the nexthop.
+ type: bool
state:
description:
@@ -70,7 +232,6 @@ options:
notes:
- Requires C(ansible_connection=httpapi) with the VyOS httpapi plugin.
- C(ansible_network_os) must be set to C(vyos.rest.vyos).
- - Input validation is delegated to the VyOS API.
"""
EXAMPLES = r"""
@@ -84,10 +245,10 @@ EXAMPLES = r"""
match:
peer: 192.0.2.32
set:
- metric: "5"
+ metric: 5
as_path_exclude: "111"
aggregator:
- as: 100
+ as_: 100
state: merged
- name: Delete all route maps
@@ -110,320 +271,526 @@ before:
description: Route map configuration before this module ran.
returned: always
type: list
-
after:
description: Route map configuration after this module ran.
returned: when changed
type: list
-
commands:
description: List of API command tuples sent to the device.
returned: always
type: list
-
gathered:
description: Current route map configuration as structured data.
returned: when state is gathered
type: list
-
saved:
description: Whether the config was saved after changes.
- returned: when changes are applied
+ returned: when changed
type: bool
-
response:
description: Raw API response.
- returned: when changes are applied
+ returned: always
type: dict
"""
from ansible.module_utils.basic import AnsibleModule
-from ansible_collections.vyos.rest.plugins.module_utils.vyos import VyOSModule
+from ansible_collections.vyos.rest.plugins.module_utils.vyos import (
+ VyOSModule,
+ autoclean,
+ cast_by_spec,
+ dict_op,
+ from_device,
+ to_tag_dict,
+)
_BASE = ["policy", "route-map"]
-_SET_MAP = {
- "as_path_prepend": ["as-path", "prepend"],
- "as_path_exclude": ["as-path", "exclude"],
- "as_path_prepend_last_as": ["as-path", "prepend-last-as"],
- "ip_next_hop": ["ip-next-hop"],
- "local_preference": ["local-preference"],
- "metric": ["metric"],
- "metric_type": ["metric-type"],
- "origin": ["origin"],
- "originator_id": ["originator-id"],
- "src": ["src"],
- "tag": ["tag"],
- "weight": ["weight"],
- "distance": ["distance"],
- "table": ["table"],
-}
-
-_MATCH_MAP = {
- "interface": ["interface"],
- "metric": ["metric"],
- "origin": ["origin"],
- "peer": ["peer"],
- "protocol": ["protocol"],
-}
+def _derive_key_field(options_spec):
+ """The field identifying each entry in a named-list section is
+ never inferable from a generic walk alone -- but it doesn't need
+ to be hand-declared either: both named-list sections in this
+ argspec (route maps, rules) already mark exactly one suboption
+ required=True. Deriving it here means the key field is asserted to
+ exist by the argspec itself, not duplicated in a place that could
+ drift out of sync with it.
+ """
+ required = [k for k, spec in options_spec.items() if spec.get("required")]
+ if len(required) != 1:
+ raise ValueError(
+ "expected exactly one required suboption to serve as the key field, "
+ "found: {0}".format(required),
+ )
+ return required[0]
+
+
+def _keyed_list_to_device(items, key_field, entry_transform=None):
+ """A list of dicts, each identified by key_field's value, becomes a
+ device dict keyed by that value -- the one structural mechanic
+ every named-list section in this module needs. entry_transform
+ supplies whatever else is genuinely irreducible for a given section
+ (a nested reshape) -- defaulting to the generic recursive walker.
+ """
+ entry_transform = entry_transform or autoclean
+ result = {}
+ for item in items or []:
+ if not item.get(key_field):
+ continue
+ rest = {k: v for k, v in item.items() if k != key_field}
+ result[str(item[key_field])] = entry_transform(rest)
+ return result
-def get_running_config(vyos):
- raw = vyos.get_config(["policy", "route-map"])
- if not raw or not isinstance(raw, dict):
- return []
- rm_data = raw.get("route-map", raw)
- if not isinstance(rm_data, dict):
- return []
+def _keyed_list_from_device(raw, key_field, entry_transform=None, key_cast=None):
+ entry_transform = entry_transform or from_device
+ key_cast = key_cast or (lambda k: k)
+ return [
+ {key_field: key_cast(key), **entry_transform(data or {})}
+ for key, data in sorted(to_tag_dict(raw).items())
+ ]
- result = []
- for rm_name, rm_info in sorted(rm_data.items()):
- entry = {"route_map": rm_name, "entries": []}
- rm_info = rm_info or {}
-
- for seq, rule_data in sorted(
- (rm_info.get("rule") or {}).items(),
- key=lambda x: int(x[0]),
- ):
- rule_data = rule_data or {}
- rule = {"sequence": int(seq)}
- if rule_data.get("action"):
- rule["action"] = rule_data["action"]
- if rule_data.get("description"):
- rule["description"] = rule_data["description"]
- if rule_data.get("call"):
- rule["call"] = rule_data["call"]
- if rule_data.get("continue"):
- rule["continue_sequence"] = int(rule_data["continue"])
- if rule_data.get("match"):
- rule["match"] = rule_data["match"]
- if rule_data.get("set"):
- rule["set"] = rule_data["set"]
- entry["entries"].append(rule)
-
- result.append(entry)
- return result
+# ---------------------------------------------------------------------------
+# match.prefix_list / match.prefix_list6 -- confirmed genuine structural
+# insertions: the argspec has these as flat fields, but the device
+# nests them two levels down (ip.address.prefix-list /
+# ipv6.address.prefix-list).
+#
+# match.ip.nexthop_address / nexthop_prefix_list, match.ipv6.
+# nexthop_address -- confirmed the device nests these ONE level
+# deeper than the argspec (ip.nexthop.address / ip.nexthop.prefix-list),
+# under a "nexthop" node the argspec doesn't have (commented out in
+# vyos-1x itself, T3304/T3976, since a plain leaf there would collide
+# with the node).
+# ---------------------------------------------------------------------------
-def _match_cmds(rbase, match):
- cmds = []
+def _match_to_device(match):
if not match:
- return cmds
-
- mbase = rbase + ["match"]
-
- for key, path_suffix in _MATCH_MAP.items():
- if match.get(key) is not None:
- cmds.append(("set", mbase + path_suffix + [str(match[key])]))
+ return {}
+ exclude = {"prefix_list", "prefix_list6", "ip", "ipv6"}
+ device = autoclean({k: v for k, v in match.items() if k not in exclude})
if match.get("prefix_list"):
- cmds.append(("set", mbase + ["ip", "address", "prefix-list", match["prefix_list"]]))
+ device.setdefault("ip", {}).setdefault("address", {})["prefix-list"] = match["prefix_list"]
if match.get("prefix_list6"):
- cmds.append(("set", mbase + ["ipv6", "address", "prefix-list", match["prefix_list6"]]))
+ ipv6_addr = device.setdefault("ipv6", {}).setdefault("address", {})
+ ipv6_addr["prefix-list"] = match["prefix_list6"]
ip = match.get("ip") or {}
if ip.get("nexthop_address"):
- cmds.append(("set", mbase + ["ip", "nexthop", "address", ip["nexthop_address"]]))
+ device.setdefault("ip", {}).setdefault("nexthop", {})["address"] = ip["nexthop_address"]
if ip.get("nexthop_prefix_list"):
- cmds.append(("set", mbase + ["ip", "nexthop", "prefix-list", ip["nexthop_prefix_list"]]))
+ ip_nh = device.setdefault("ip", {}).setdefault("nexthop", {})
+ ip_nh["prefix-list"] = ip["nexthop_prefix_list"]
ipv6 = match.get("ipv6") or {}
if ipv6.get("nexthop_address"):
- cmds.append(("set", mbase + ["ipv6", "nexthop", "address", ipv6["nexthop_address"]]))
+ device.setdefault("ipv6", {}).setdefault("nexthop", {})["address"] = ipv6["nexthop_address"]
- return cmds
+ return device
-def _set_cmds(rbase, setv):
- cmds = []
- if not setv:
- return cmds
+def _match_from_device(data):
+ if not data:
+ return {}
+ ip_raw = data.get("ip") or {}
+ ipv6_raw = data.get("ipv6") or {}
+ exclude = {"ip", "ipv6"}
+ entry = from_device({k: v for k, v in data.items() if k not in exclude})
+
+ prefix_list = (ip_raw.get("address") or {}).get("prefix-list")
+ if prefix_list:
+ entry["prefix_list"] = prefix_list
+ prefix_list6 = (ipv6_raw.get("address") or {}).get("prefix-list")
+ if prefix_list6:
+ entry["prefix_list6"] = prefix_list6
+
+ ip_nexthop = ip_raw.get("nexthop") or {}
+ ip_sub = {}
+ if ip_nexthop.get("address"):
+ ip_sub["nexthop_address"] = ip_nexthop["address"]
+ if ip_nexthop.get("prefix-list"):
+ ip_sub["nexthop_prefix_list"] = ip_nexthop["prefix-list"]
+ if ip_sub:
+ entry["ip"] = ip_sub
+
+ ipv6_nexthop = ipv6_raw.get("nexthop") or {}
+ if ipv6_nexthop.get("address"):
+ entry["ipv6"] = {"nexthop_address": ipv6_nexthop["address"]}
+
+ return entry
+
+
+# ---------------------------------------------------------------------------
+# set.as_path_* -- confirmed structural collapse: three flat argspec
+# keys (as_path_exclude/prepend/prepend_last_as) collapse onto one
+# nested device node (as-path.{exclude,prepend,prepend-last-as}) with
+# different sub-key names -- no mechanical transform gets from
+# "as_path_exclude" to that shape.
+#
+# set.community / set.large_community / set.ipv6_next_hop are fully
+# generic once modeled as real nested dicts (confirmed against schema:
+# community/large-community are add/replace/none/delete nodes;
+# ipv6-next-hop is global/local/peer-address/prefer-global) -- no
+# entry-transform needed for them at all, the top-level community_to_
+# device call handles them via ordinary recursion.
+# ---------------------------------------------------------------------------
+_AS_PATH_FIELDS = {
+ "as_path_exclude": "exclude",
+ "as_path_prepend": "prepend",
+ "as_path_prepend_last_as": "prepend-last-as",
+}
- sbase = rbase + ["set"]
- for key, path_suffix in _SET_MAP.items():
- if setv.get(key) is not None:
- cmds.append(("set", sbase + path_suffix + [str(setv[key])]))
+# Both renames in this module are position-specific -- confirmed
+# against vyos-1x: "as" and "continue" are Python keywords and can't
+# be used as dict() kwargs at all, so "as_"/"continue_sequence" are
+# unavoidable argspec names, renamed to the device's real leaf names
+# "as"/"continue". Neither fits a shared flat rename map: "as_" is
+# nested inside "aggregator" specifically, and "continue_sequence" is
+# a rule-level field, not a set-level one -- each is handled directly
+# at its own point below instead.
- if setv.get("atomic_aggregate"):
- cmds.append(("set", sbase + ["atomic-aggregate"]))
- comm = setv.get("community") or {}
- if comm.get("value"):
- cmds.append(("set", sbase + ["community", comm["value"]]))
+def _set_to_device(setv):
+ if not setv:
+ return {}
+ exclude = set(_AS_PATH_FIELDS) | {"aggregator"}
+ device = autoclean({k: v for k, v in setv.items() if k not in exclude})
+
+ as_path = {
+ device_key: setv[arg_key]
+ for arg_key, device_key in _AS_PATH_FIELDS.items()
+ if setv.get(arg_key) is not None
+ }
+ if as_path:
+ device["as-path"] = as_path
+
+ agg = setv.get("aggregator")
+ if agg:
+ agg_device = autoclean({k: v for k, v in agg.items() if k != "as_"})
+ if agg.get("as_") is not None:
+ agg_device["as"] = agg["as_"]
+ if agg_device:
+ device["aggregator"] = agg_device
+
+ return device
+
+
+def _set_from_device(data):
+ if not data:
+ return {}
+ as_path_raw = data.get("as-path") or {}
+ agg_raw = data.get("aggregator") or {}
+ exclude = {"as-path", "aggregator"}
+ entry = from_device({k: v for k, v in data.items() if k not in exclude})
+
+ for arg_key, device_key in _AS_PATH_FIELDS.items():
+ if as_path_raw.get(device_key) is not None:
+ entry[arg_key] = as_path_raw[device_key]
+ if "as_path_prepend_last_as" in entry:
+ entry["as_path_prepend_last_as"] = int(entry["as_path_prepend_last_as"])
+
+ if agg_raw:
+ agg_entry = from_device({k: v for k, v in agg_raw.items() if k != "as"})
+ if agg_raw.get("as") is not None:
+ agg_entry["as_"] = int(agg_raw["as"])
+ if agg_entry:
+ entry["aggregator"] = agg_entry
+
+ return entry
+
+
+# ---------------------------------------------------------------------------
+# Rules (keyed by sequence) and route maps (keyed by name) -- both are
+# named-list sections like any other in this collection, so they go
+# through the same _keyed_list_to_device/_keyed_list_from_device
+# mechanic as everything else, with key_field derived from ARGSPEC
+# rather than hand-declared, instead of the hand-rolled loops this had
+# before. _ROUTE_MAP_KEY/_RULE_KEY are derived after ARGUMENT_SPEC is
+# built (near the bottom of this file) since they need it to exist.
+# ---------------------------------------------------------------------------
+
+
+def _rule_entry_to_device(rest):
+ exclude = {"match", "set", "continue_sequence"}
+ device = autoclean({k: v for k, v in rest.items() if k not in exclude})
+ if rest.get("continue_sequence") is not None:
+ device["continue"] = rest["continue_sequence"]
+ if rest.get("match"):
+ m = _match_to_device(rest["match"])
+ if m:
+ device["match"] = m
+ if rest.get("set"):
+ s = _set_to_device(rest["set"])
+ if s:
+ device["set"] = s
+ return device
+
+
+def _rule_entry_from_device(data):
+ data = dict(data or {})
+ continue_raw = data.pop("continue", None)
+ match_raw = data.pop("match", None)
+ set_raw = data.pop("set", None)
+ entry = from_device(data)
+ if continue_raw is not None:
+ entry["continue_sequence"] = int(continue_raw)
+ match = _match_from_device(match_raw)
+ if match:
+ entry["match"] = match
+ setv = _set_from_device(set_raw)
+ if setv:
+ entry["set"] = setv
+ return entry
+
+
+def _route_map_entry_to_device(rest):
+ entries = rest.get("entries") or []
+ if not entries:
+ return {}
+ return {"rule": _keyed_list_to_device(entries, _RULE_KEY, _rule_entry_to_device)}
- large_comm = setv.get("large_community")
- if large_comm is not None:
- cmds.append(("set", sbase + ["large-community", str(large_comm)]))
- agg = setv.get("aggregator") or {}
- agg_as = agg.get("as") or agg.get("as_")
- if agg_as and agg.get("ip"):
- cmds.append(("set", sbase + ["aggregator", "as", str(agg_as), "address", agg["ip"]]))
- elif agg_as:
- cmds.append(("set", sbase + ["aggregator", "as", str(agg_as)]))
+def _route_map_entry_from_device(data):
+ rule_raw = (data or {}).get("rule")
+ if not rule_raw:
+ return {"entries": []}
+ entries = _keyed_list_from_device(rule_raw, _RULE_KEY, _rule_entry_from_device, key_cast=int)
+ # _keyed_list_from_device sorts by the raw device key as a string,
+ # which orders sequence numbers wrong across a digit-count boundary
+ # (e.g. "10" < "9" lexicographically) -- re-sort numerically now
+ # that key_cast has already converted each key to a real int.
+ return {"entries": sorted(entries, key=lambda e: e[_RULE_KEY])}
- nh6 = setv.get("ipv6_next_hop") or {}
- if nh6.get("value"):
- ip_type = nh6.get("ip_type") or "global"
- cmds.append(("set", sbase + ["ipv6-next-hop", ip_type, nh6["value"]]))
- return cmds
+def _want_to_device(config):
+ with_entries = [rm for rm in (config or []) if rm.get("entries")]
+ return _keyed_list_to_device(with_entries, _ROUTE_MAP_KEY, _route_map_entry_to_device)
-def _want_to_api_set(setv):
- if not setv:
- return {}
- api = {}
- for key, path in _SET_MAP.items():
- if setv.get(key) is not None:
- d = api
- for p in path[:-1]:
- d = d.setdefault(p, {})
- d[path[-1]] = str(setv[key])
- agg = setv.get("aggregator") or {}
- agg_as = agg.get("as") or agg.get("as_")
- if agg_as:
- api.setdefault("aggregator", {})["as"] = str(agg_as)
- large_comm = setv.get("large_community")
- if large_comm is not None:
- api["large-community"] = {str(large_comm): {}}
- return api
-
-
-def _want_to_api_match(match):
- if not match:
- return {}
- api = {}
- for key in _MATCH_MAP:
- if match.get(key) is not None:
- api[key] = str(match[key])
- if match.get("prefix_list"):
- api.setdefault("ip", {}).setdefault("address", {})["prefix-list"] = match["prefix_list"]
- if match.get("prefix_list6"):
- api.setdefault("ipv6", {}).setdefault("address", {})["prefix-list"] = match["prefix_list6"]
- return api
-
-
-def _rule_cmds(rm_name, rule, have_rule, state="merged"):
- cmds = []
- seq = str(rule["sequence"])
- rbase = _BASE + [rm_name, "rule", seq]
-
- if rule.get("action") and rule["action"] != have_rule.get("action"):
- cmds.append(("set", rbase + ["action", rule["action"]]))
- if rule.get("description") and rule["description"] != have_rule.get("description"):
- cmds.append(("set", rbase + ["description", rule["description"]]))
- if rule.get("call") and rule["call"] != have_rule.get("call"):
- cmds.append(("set", rbase + ["call", rule["call"]]))
- if rule.get("continue_sequence") is not None and rule["continue_sequence"] != have_rule.get(
- "continue_sequence",
- ):
- cmds.append(("set", rbase + ["continue", str(rule["continue_sequence"])]))
-
- want_match_api = _want_to_api_match(rule.get("match"))
- have_match = have_rule.get("match") or {}
- changed_match = {k: v for k, v in want_match_api.items() if have_match.get(k) != v}
- if changed_match:
- match = rule.get("match") or {}
- changed_keys = set(changed_match.keys())
- partial_match = {
- k: v
- for k, v in match.items()
- if k in changed_keys
- or (k == "prefix_list" and "ip" in changed_keys)
- or (k == "prefix_list6" and "ipv6" in changed_keys)
- }
- if not partial_match:
- partial_match = match
- cmds += _match_cmds(rbase, partial_match)
-
- want_set_api = _want_to_api_set(rule.get("set"))
- have_set = have_rule.get("set") or {}
- if state in ("replaced", "overridden"):
- if want_set_api != have_set:
- cmds += _set_cmds(rbase, rule.get("set"))
- else:
- have_subset = {k: have_set[k] for k in want_set_api if k in have_set}
- if want_set_api != have_subset:
- cmds += _set_cmds(rbase, rule.get("set"))
-
- return cmds
-
-
-def build_commands(config, have_raw, state):
- cmds = []
+def get_running_config(vyos):
+ raw = vyos.get_config(_BASE) or {}
+ if isinstance(raw, dict) and "route-map" in raw:
+ return raw["route-map"] or {}
+ return raw
+
+
+def _device_to_argspec(raw):
+ if not raw:
+ return []
+ return _keyed_list_from_device(raw, _ROUTE_MAP_KEY, _route_map_entry_from_device)
+
+
+def _seed_route_map_placeholders(want, have):
+ """dict_op's fallback guesses a translated device key whenever a
+ want key is missing from have entirely (a brand-new route map or
+ rule). That guess is correct for a schema field name but wrong for
+ a route-map name, which is an opaque value that may legitimately
+ contain an underscore (confirmed against vyos-1x: "Name of
+ route-map can only contain alpha-numeric letters, hyphen and
+ underscores") -- confirmed as a real bug via direct reproduction,
+ the same class found in vyos_snmp_server's "admin_user" case:
+ "my_route_map" was silently becoming "my-route-map" in the
+ generated command on first creation.
+
+ Seeds an empty placeholder into have (mutated in place) for every
+ route-map name present in want but not yet in have, using the
+ exact verbatim value -- dict_op's own unmodified exact-match lookup
+ then finds it directly and never reaches its guessing fallback.
+ Also seeds each rule's own tag-node level with None (not {}), since
+ a rule with no other fields set is a presence-only entry -- seeding
+ {} there would make dict_op think it already matches and skip
+ emitting the needed set command (the same mistake caught and fixed
+ once already this session).
+ """
+ for rm_name, rm_val in (want or {}).items():
+ rm_have = have.setdefault(rm_name, {})
+ if not isinstance(rm_have, dict):
+ continue
+ rule_want = (rm_val or {}).get("rule") or {}
+ if rule_want:
+ rule_have = rm_have.setdefault("rule", {})
+ if isinstance(rule_have, dict):
+ for seq in rule_want:
+ if seq not in rule_have:
+ rule_have[seq] = None
+
+
+def build_commands(config, raw_have, state):
+ raw_have = raw_have or {}
+ config = config or []
if state == "deleted":
if not config:
- if have_raw:
- cmds.append(("delete", _BASE))
- else:
- for rm in config:
+ return [("delete", _BASE)] if raw_have else []
+ cmds = []
+ for rm in config:
+ if rm.get("route_map") in raw_have:
cmds.append(("delete", _BASE + [rm["route_map"]]))
return cmds
- have_map = {e["route_map"]: e for e in have_raw}
+ want = _want_to_device(config)
+ norm_have = _want_to_device(_device_to_argspec(raw_have))
+ _seed_route_map_placeholders(want, norm_have)
+ commands = []
if state == "overridden":
- want_names = {rm["route_map"] for rm in config}
- for name in set(have_map) - want_names:
- cmds.append(("delete", _BASE + [name]))
-
- for rm in config:
- rm_name = rm["route_map"]
- have_rm = have_map.get(rm_name, {})
-
- if state == "replaced" and rm_name in have_map:
- # Only delete and rebuild if something actually differs
- have_entries = {str(r["sequence"]): r for r in (have_rm.get("entries") or [])}
- want_seqs = {str(r["sequence"]) for r in (rm.get("entries") or [])}
- extra_seqs = set(have_entries) - want_seqs
- test_cmds = []
- for rule in rm.get("entries") or []:
- have_rule = have_entries.get(str(rule["sequence"]), {})
- test_cmds += _rule_cmds(rm_name, rule, have_rule, state)
- if test_cmds or extra_seqs:
- cmds.append(("delete", _BASE + [rm_name]))
- have_rm = {}
- else:
- continue # already matches — idempotent
-
- have_entries = {str(r["sequence"]): r for r in (have_rm.get("entries") or [])}
-
- for rule in rm.get("entries") or []:
- have_rule = have_entries.get(str(rule["sequence"]), {})
- cmds += _rule_cmds(rm_name, rule, have_rule, state)
-
- return cmds
+ commands += dict_op(want, norm_have, _BASE, op="purge")
+ elif state == "replaced":
+ want_names = {rm.get("route_map") for rm in config if rm.get("route_map")}
+ for name in want_names:
+ section_want = want.get(name, {})
+ section_have = norm_have.get(name, {})
+ commands += dict_op(section_want, section_have, _BASE + [name], op="purge")
+ commands += dict_op(want, norm_have, _BASE, op="set")
+ return commands
ARGUMENT_SPEC = dict(
- config=dict(type="list", elements="dict"),
+ config=dict(
+ type="list",
+ elements="dict",
+ options=dict(
+ route_map=dict(type="str", required=True),
+ entries=dict(
+ type="list",
+ elements="dict",
+ options=dict(
+ sequence=dict(type="int", required=True),
+ action=dict(type="str", choices=["permit", "deny"]),
+ description=dict(type="str"),
+ call=dict(type="str"),
+ continue_sequence=dict(type="int"),
+ match=dict(
+ type="dict",
+ options=dict(
+ interface=dict(type="str"),
+ metric=dict(type="int"),
+ origin=dict(type="str", choices=["egp", "igp", "incomplete"]),
+ peer=dict(type="str"),
+ protocol=dict(
+ type="str",
+ choices=[
+ "babel",
+ "bgp",
+ "connected",
+ "isis",
+ "kernel",
+ "ospf",
+ "ospfv3",
+ "rip",
+ "ripng",
+ "static",
+ "table",
+ "vnc",
+ ],
+ ),
+ prefix_list=dict(type="str"),
+ prefix_list6=dict(type="str"),
+ ip=dict(
+ type="dict",
+ options=dict(
+ nexthop_address=dict(type="str"),
+ nexthop_prefix_list=dict(type="str"),
+ ),
+ ),
+ ipv6=dict(
+ type="dict",
+ options=dict(
+ nexthop_address=dict(type="str"),
+ ),
+ ),
+ ),
+ ),
+ set=dict(
+ type="dict",
+ options=dict(
+ metric=dict(type="int"),
+ metric_type=dict(type="str"),
+ origin=dict(type="str", choices=["egp", "igp", "incomplete"]),
+ originator_id=dict(type="str"),
+ src=dict(type="str"),
+ tag=dict(type="int"),
+ weight=dict(type="int"),
+ distance=dict(type="int"),
+ table=dict(type="int"),
+ local_preference=dict(type="int"),
+ ip_next_hop=dict(type="str"),
+ atomic_aggregate=dict(type="bool"),
+ as_path_exclude=dict(type="str"),
+ as_path_prepend=dict(type="str"),
+ as_path_prepend_last_as=dict(type="int"),
+ aggregator=dict(
+ type="dict",
+ options=dict(
+ as_=dict(type="int", aliases=["as"]),
+ ip=dict(type="str"),
+ ),
+ ),
+ community=dict(
+ type="dict",
+ options=dict(
+ add=dict(type="list", elements="str"),
+ replace=dict(type="list", elements="str"),
+ none=dict(type="bool"),
+ delete=dict(type="str"),
+ ),
+ ),
+ large_community=dict(
+ type="dict",
+ options=dict(
+ add=dict(type="list", elements="str"),
+ replace=dict(type="list", elements="str"),
+ none=dict(type="bool"),
+ delete=dict(type="str"),
+ ),
+ ),
+ ipv6_next_hop=dict(
+ type="dict",
+ options={
+ "global": dict(type="str"),
+ "local": dict(type="str"),
+ "peer_address": dict(type="bool"),
+ "prefer_global": dict(type="bool"),
+ },
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
state=dict(
default="merged",
choices=["merged", "replaced", "overridden", "deleted", "gathered"],
),
)
+_ENTRY_OPTIONS = ARGUMENT_SPEC["config"]["options"]["entries"]["options"]
+_ROUTE_MAP_KEY = _derive_key_field(ARGUMENT_SPEC["config"]["options"])
+_RULE_KEY = _derive_key_field(_ENTRY_OPTIONS)
+
def main():
module = AnsibleModule(ARGUMENT_SPEC, supports_check_mode=True)
-
vyos = VyOSModule(module)
state = module.params["state"]
config = module.params.get("config") or []
- have = get_running_config(vyos)
+ raw_have = get_running_config(vyos)
+ have = _device_to_argspec(raw_have)
+ for rm in have:
+ for entry in rm.get("entries") or []:
+ cast_by_spec(entry, _ENTRY_OPTIONS)
if state == "gathered":
module.exit_json(changed=False, gathered=have)
- commands = build_commands(config, have, state)
+ commands = build_commands(config, raw_have, state)
if module.check_mode:
module.exit_json(changed=bool(commands), commands=commands, before=have)
@@ -431,10 +798,15 @@ def main():
if commands:
response = vyos.apply_commands(commands)
saved = vyos.save_config()
+ after_raw = get_running_config(vyos)
+ after = _device_to_argspec(after_raw)
+ for rm in after:
+ for entry in rm.get("entries") or []:
+ cast_by_spec(entry, _ENTRY_OPTIONS)
module.exit_json(
changed=True,
before=have,
- after=get_running_config(vyos),
+ after=after,
commands=commands,
saved=saved,
response=response,