summaryrefslogtreecommitdiff
path: root/plugins/modules
diff options
context:
space:
mode:
authoromnom62 <omnom62@outlook.com>2026-05-19 08:44:50 +1000
committeromnom62 <omnom62@outlook.com>2026-05-19 08:44:50 +1000
commit60866aeb2f743a404e045ce64a2c0912c689c6a9 (patch)
tree2bbb68c0def8e9136ebf26d4ace2c527d63a7c99 /plugins/modules
parent92b7323c353f473c9d5bdb7930e9490a5dafcd41 (diff)
downloadrest.vyos-60866aeb2f743a404e045ce64a2c0912c689c6a9.tar.gz
rest.vyos-60866aeb2f743a404e045ce64a2c0912c689c6a9.zip
route_map
Diffstat (limited to 'plugins/modules')
-rw-r--r--plugins/modules/vyos_prefix_lists.py483
-rw-r--r--plugins/modules/vyos_route_maps.py699
2 files changed, 719 insertions, 463 deletions
diff --git a/plugins/modules/vyos_prefix_lists.py b/plugins/modules/vyos_prefix_lists.py
index 63fa584..5334e7a 100644
--- a/plugins/modules/vyos_prefix_lists.py
+++ b/plugins/modules/vyos_prefix_lists.py
@@ -1,6 +1,5 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
-# GNU General Public License v3.0+
from __future__ import absolute_import, division, print_function
@@ -10,20 +9,22 @@ __metaclass__ = type
DOCUMENTATION = r"""
---
module: vyos_prefix_lists
-short_description: Prefix-Lists resource module via REST API.
+short_description: Manage prefix-list configuration on VyOS devices using REST API
description:
- - Manages IPv4 and IPv6 prefix lists on VyOS via the HTTPS REST API.
+ - Manages IPv4 and IPv6 prefix lists on VyOS via the REST API.
+ - Uses REST API (C(connection=httpapi)) instead of CLI.
version_added: "1.0.0"
author:
- VyOS Community (@vyos)
+
options:
config:
- description: List of prefix-list configurations.
+ description: List of prefix-list configurations grouped by address family.
type: list
elements: dict
suboptions:
afi:
- description: Address family.
+ description: Address family identifier.
type: str
choices: [ipv4, ipv6]
required: true
@@ -37,7 +38,7 @@ options:
type: str
required: true
description:
- description: Description.
+ description: Prefix list description.
type: str
entries:
description: Prefix list rules.
@@ -49,9 +50,10 @@ options:
type: int
required: true
description:
+ description: Rule description.
type: str
action:
- description: permit or deny.
+ description: Permit or deny.
type: str
choices: [permit, deny]
ge:
@@ -63,204 +65,347 @@ options:
prefix:
description: Network prefix to match.
type: str
+
state:
+ description:
+ - Desired state of the prefix-list configuration.
+ - C(merged) adds or updates entries without removing existing ones.
+ - C(replaced) replaces each named prefix list mentioned in config.
+ - C(overridden) replaces all prefix lists for the given AFIs.
+ - C(deleted) removes prefix lists. Without config removes all.
+ - C(gathered) returns current configuration as structured data.
type: str
choices: [merged, replaced, overridden, deleted, gathered]
default: merged
- hostname:
- type: str
- required: true
- port:
- type: int
- default: 443
- api_key:
- type: str
- required: true
- no_log: true
- timeout:
- type: int
- default: 30
- verify_ssl:
- type: bool
- default: false
+
+notes:
+ - Requires C(ansible_connection=httpapi) with the VyOS httpapi plugin.
+ - C(ansible_network_os) must be set to C(vyos.rest.vyos).
+"""
+
+EXAMPLES = r"""
+- name: Merge prefix list configuration
+ vyos.rest.vyos_prefix_lists:
+ config:
+ - afi: ipv4
+ prefix_lists:
+ - name: AnsibleIPv4PrefixList
+ description: PL configured by ansible
+ entries:
+ - sequence: 2
+ action: permit
+ prefix: 92.168.10.0/26
+ le: 32
+ - sequence: 3
+ action: deny
+ prefix: 72.168.2.0/24
+ ge: 26
+ - afi: ipv6
+ prefix_lists:
+ - name: AllowIPv6Prefix
+ entries:
+ - sequence: 5
+ action: permit
+ prefix: 2001:db8:8000::/35
+ le: 37
+ state: merged
+
+- name: Delete all prefix lists
+ vyos.rest.vyos_prefix_lists:
+ state: deleted
+
+- name: Gather current prefix list configuration
+ vyos.rest.vyos_prefix_lists:
+ state: gathered
"""
RETURN = r"""
before:
+ description: Prefix list configuration before this module ran.
returned: always
type: list
+
after:
+ description: Prefix list 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 prefix list 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
+ type: bool
+
+response:
+ description: Raw API response.
+ returned: when changes are applied
+ type: dict
"""
from ansible.module_utils.basic import AnsibleModule
-from ansible_collections.vyos.rest.plugins.module_utils.vyos_rest import (
- VYOS_REST_CONNECTION_ARGSPEC,
- VyOSRestClient,
- VyOSRestError,
-)
+from ansible_collections.vyos.rest.plugins.module_utils.vyos import VyOSModule
+# AFI → API key mapping
_PL_KEY = {"ipv4": "prefix-list", "ipv6": "prefix-list6"}
-def _get(client):
- try:
- r = client.retrieve_show_config(["policy"])
- data = r.get("data") or {}
- out = []
- for afi, key in [("ipv4", "prefix-list"), ("ipv6", "prefix-list6")]:
- pl_data = data.get(key) or {}
- if not isinstance(pl_data, dict):
- continue
- pls = []
- for pl_name, pl_info in pl_data.items():
- entry = {"name": pl_name}
- if isinstance(pl_info, dict):
- if "description" in pl_info:
- entry["description"] = pl_info["description"]
- rules = []
- for seq, rdata in (pl_info.get("rule") or {}).items():
- if isinstance(rdata, dict):
- rules.append({"sequence": int(seq), **rdata})
- if rules:
- entry["entries"] = rules
- pls.append(entry)
- if pls:
- out.append({"afi": afi, "prefix_lists": pls})
- return out
- except VyOSRestError:
+# ------------------------------------------------------------
+# Parsing: API response → argspec list
+# ------------------------------------------------------------
+
+
+def get_running_config(vyos):
+ """
+ Fetch current prefix list config and return in argspec list format:
+ [{"afi": "ipv4", "prefix_lists": [...]}, {"afi": "ipv6", ...}]
+ """
+ raw = vyos.get_config(["policy"])
+ if not raw or not isinstance(raw, dict):
return []
+ result = []
+ for afi, api_key in [("ipv4", "prefix-list"), ("ipv6", "prefix-list6")]:
+ pl_data = raw.get(api_key) or {}
+ if not isinstance(pl_data, dict):
+ continue
+
+ pls = []
+ for pl_name, pl_info in sorted(pl_data.items()):
+ entry = {"name": pl_name}
+ pl_info = pl_info or {}
+
+ if pl_info.get("description"):
+ entry["description"] = pl_info["description"]
+
+ rules = []
+ for seq, rdata in sorted(
+ (pl_info.get("rule") or {}).items(),
+ key=lambda x: int(x[0]),
+ ):
+ rdata = rdata or {}
+ rule = {"sequence": int(seq)}
+ if rdata.get("action"):
+ rule["action"] = rdata["action"]
+ if rdata.get("prefix"):
+ rule["prefix"] = rdata["prefix"]
+ if rdata.get("ge") is not None:
+ rule["ge"] = int(rdata["ge"])
+ if rdata.get("le") is not None:
+ rule["le"] = int(rdata["le"])
+ if rdata.get("description"):
+ rule["description"] = rdata["description"]
+ rules.append(rule)
+
+ if rules:
+ entry["entries"] = rules
+ pls.append(entry)
+
+ if pls:
+ result.append({"afi": afi, "prefix_lists": pls})
+
+ return result
+
+
+# ------------------------------------------------------------
+# Internal normalization: argspec list → nested dicts for diffing
+# ------------------------------------------------------------
+
+
+def _normalize(config):
+ """
+ Convert argspec list to nested dict keyed by afi → pl_name → rule_seq.
+ {
+ "ipv4": {
+ "AnsibleIPv4PrefixList": {
+ "description": "...",
+ "rules": {2: {...}, 3: {...}}
+ }
+ },
+ "ipv6": {...}
+ }
+ """
+ result = {"ipv4": {}, "ipv6": {}}
+ for entry in config or []:
+ afi = entry.get("afi")
+ if afi not in result:
+ continue
+ for pl in entry.get("prefix_lists") or []:
+ name = pl["name"]
+ rules = {}
+ for r in pl.get("entries") or []:
+ seq = r["sequence"]
+ rules[seq] = {k: v for k, v in r.items() if k != "sequence" and v is not None}
+ result[afi][name] = {
+ "description": pl.get("description"),
+ "rules": rules,
+ }
+ return result
+
+
+# ------------------------------------------------------------
+# Command builders
+# ------------------------------------------------------------
+
+
+def _rule_cmds(base, seq, want_rule, have_rule, state):
+ """Build commands for a single rule entry."""
+ cmds = []
+ rbase = base + ["rule", str(seq)]
+
+ if seq not in (have_rule or {}):
+ # New rule
+ for field, api_key in [
+ ("action", "action"),
+ ("prefix", "prefix"),
+ ("description", "description"),
+ ]:
+ if want_rule.get(field):
+ cmds.append(("set", rbase + [api_key, want_rule[field]]))
+ if want_rule.get("ge") is not None:
+ cmds.append(("set", rbase + ["ge", str(want_rule["ge"])]))
+ if want_rule.get("le") is not None:
+ cmds.append(("set", rbase + ["le", str(want_rule["le"])]))
+ else:
+ # Existing rule — update changed fields
+ h = have_rule[seq]
+ for field, api_key in [
+ ("action", "action"),
+ ("prefix", "prefix"),
+ ("description", "description"),
+ ]:
+ if want_rule.get(field) and want_rule[field] != h.get(field):
+ cmds.append(("set", rbase + [api_key, want_rule[field]]))
+ for field in ("ge", "le"):
+ if want_rule.get(field) is not None and want_rule[field] != h.get(field):
+ cmds.append(("set", rbase + [field, str(want_rule[field])]))
-def _apply(client, entry, commands):
- afi = entry["afi"]
- key = _PL_KEY[afi]
- for pl in entry.get("prefix_lists") or []:
- base = ["policy", key, pl["name"]]
- client.configure_set(base)
- commands.append("set policy {k} {n}".format(k=key, n=pl["name"]))
- if pl.get("description"):
- client.configure_set(base + ["description"], pl["description"])
- for rule in pl.get("entries") or []:
- rb = base + ["rule", str(rule["sequence"])]
- client.configure_set(rb)
- commands.append(
- "set policy {k} {n} rule {s}".format(
- k=key,
- n=pl["name"],
- s=rule["sequence"],
- ),
- )
- if rule.get("action"):
- client.configure_set(rb + ["action"], rule["action"])
- commands.append(
- "set policy {k} {n} rule {s} action {a}".format(
- k=key,
- n=pl["name"],
- s=rule["sequence"],
- a=rule["action"],
- ),
- )
- if rule.get("prefix"):
- client.configure_set(rb + ["prefix"], rule["prefix"])
- if rule.get("ge") is not None:
- client.configure_set(rb + ["ge"], str(rule["ge"]))
- if rule.get("le") is not None:
- client.configure_set(rb + ["le"], str(rule["le"]))
- if rule.get("description"):
- client.configure_set(rb + ["description"], rule["description"])
+ return cmds
+
+
+def build_commands(config, have_raw, state):
+ """
+ Build command tuples to move from have → want.
+ """
+ cmds = []
+
+ if state == "deleted":
+ if not config:
+ # Delete all prefix lists for both AFIs
+ for afi, api_key in _PL_KEY.items():
+ # Only delete if something exists
+ if any(e.get("afi") == afi for e in have_raw):
+ cmds.append(("delete", ["policy", api_key]))
+ else:
+ # Delete only specified prefix lists
+ for entry in config:
+ afi = entry["afi"]
+ api_key = _PL_KEY[afi]
+ for pl in entry.get("prefix_lists") or []:
+ cmds.append(("delete", ["policy", api_key, pl["name"]]))
+ return cmds
+
+ want = _normalize(config)
+ have = _normalize(have_raw)
+
+ for afi, api_key in _PL_KEY.items():
+ want_afi = want.get(afi, {})
+ have_afi = have.get(afi, {})
+
+ if state == "overridden":
+ # Delete PLs present on device but not in want
+ for pl_name in set(have_afi) - set(want_afi):
+ cmds.append(("delete", ["policy", api_key, pl_name]))
+
+ for pl_name, want_pl in want_afi.items():
+ have_pl = have_afi.get(pl_name, {})
+ base = ["policy", api_key, pl_name]
+
+ if state == "replaced" and pl_name in have_afi:
+ # Full replacement — delete existing then re-set
+ cmds.append(("delete", ["policy", api_key, pl_name]))
+ have_pl = {}
+
+ # Description
+ if want_pl.get("description") and want_pl["description"] != have_pl.get("description"):
+ cmds.append(("set", base + ["description", want_pl["description"]]))
+
+ # Rules
+ want_rules = want_pl.get("rules", {})
+ have_rules = have_pl.get("rules", {})
+
+ if state == "replaced":
+ # have_rules is now empty after delete above
+ have_rules = {}
+
+ for seq, want_rule in want_rules.items():
+ cmds += _rule_cmds(base, seq, want_rule, have_rules, state)
+
+ # merged: leave extra have rules alone
+ # replaced: they were deleted with the parent node above
+
+ return cmds
+
+
+# ------------------------------------------------------------
+# Argument spec
+# ------------------------------------------------------------
+
+ARGUMENT_SPEC = dict(
+ config=dict(type="list", elements="dict"),
+ state=dict(
+ default="merged",
+ choices=["merged", "replaced", "overridden", "deleted", "gathered"],
+ ),
+)
+
+
+# ------------------------------------------------------------
+# Main
+# ------------------------------------------------------------
def main():
- argument_spec = dict(
- config=dict(
- type="list",
- elements="dict",
- options=dict(
- afi=dict(type="str", required=True, choices=["ipv4", "ipv6"]),
- prefix_lists=dict(
- type="list",
- elements="dict",
- options=dict(
- name=dict(type="str", required=True),
- description=dict(type="str"),
- entries=dict(
- type="list",
- elements="dict",
- options=dict(
- sequence=dict(type="int", required=True),
- description=dict(type="str"),
- action=dict(type="str", choices=["permit", "deny"]),
- ge=dict(type="int"),
- le=dict(type="int"),
- prefix=dict(type="str"),
- ),
- ),
- ),
- ),
- ),
- ),
- state=dict(
- type="str",
- default="merged",
- choices=["merged", "replaced", "overridden", "deleted", "gathered"],
- ),
- )
- argument_spec.update(VYOS_REST_CONNECTION_ARGSPEC)
- module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True)
- client = VyOSRestClient(module)
+
+ module = AnsibleModule(ARGUMENT_SPEC, supports_check_mode=True)
+
+ vyos = VyOSModule(module)
+
state = module.params["state"]
config = module.params.get("config") or []
- commands = []
- changed = False
- before = _get(client)
+
+ have = get_running_config(vyos)
if state == "gathered":
- module.exit_json(changed=False, gathered=before, before=before, commands=[])
+ module.exit_json(changed=False, gathered=have)
+
+ commands = build_commands(config, have, state)
+
if module.check_mode:
- module.exit_json(changed=True, before=before, commands=["(check mode)"])
-
- try:
- if state == "deleted" and not config:
- for afi, key in [("ipv4", "prefix-list"), ("ipv6", "prefix-list6")]:
- try:
- client.configure_delete(["policy", key])
- commands.append("delete policy {k}".format(k=key))
- except VyOSRestError:
- pass
- changed = True
- elif state == "deleted" and config:
- for entry in config:
- key = _PL_KEY[entry["afi"]]
- for pl in entry.get("prefix_lists") or []:
- try:
- client.configure_delete(["policy", key, pl["name"]])
- commands.append("delete policy {k} {n}".format(k=key, n=pl["name"]))
- changed = True
- except VyOSRestError:
- pass
- elif state in ("merged", "replaced", "overridden"):
- if state in ("replaced", "overridden"):
- for entry in config:
- key = _PL_KEY[entry["afi"]]
- for pl in entry.get("prefix_lists") or []:
- try:
- client.configure_delete(["policy", key, pl["name"]])
- except VyOSRestError:
- pass
- for entry in config:
- _apply(client, entry, commands)
- changed = True
- except VyOSRestError as exc:
- module.fail_json(msg=str(exc))
+ module.exit_json(changed=bool(commands), commands=commands, before=have)
+
+ if commands:
+ response = vyos.apply_commands(commands)
+ saved = vyos.save_config()
+ module.exit_json(
+ changed=True,
+ before=have,
+ after=get_running_config(vyos),
+ commands=commands,
+ saved=saved,
+ response=response,
+ )
- after = _get(client) if changed else before
- module.exit_json(changed=changed, before=before, after=after, commands=commands)
+ module.exit_json(changed=False, before=have, after=have, commands=[])
if __name__ == "__main__":
diff --git a/plugins/modules/vyos_route_maps.py b/plugins/modules/vyos_route_maps.py
index d13a88d..5bb1916 100644
--- a/plugins/modules/vyos_route_maps.py
+++ b/plugins/modules/vyos_route_maps.py
@@ -1,6 +1,5 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
-# GNU General Public License v3.0+
from __future__ import absolute_import, division, print_function
@@ -10,12 +9,14 @@ __metaclass__ = type
DOCUMENTATION = r"""
---
module: vyos_route_maps
-short_description: Route Map resource module via REST API.
+short_description: Manage route-map configuration on VyOS devices using REST API
description:
- - Manages route maps on VyOS via the HTTPS REST API.
+ - Manages route maps on VyOS via the REST API.
+ - Uses REST API (C(connection=httpapi)) instead of CLI.
version_added: "1.0.0"
author:
- VyOS Community (@vyos)
+
options:
config:
description: List of route-map configurations.
@@ -25,351 +26,461 @@ options:
route_map:
description: Route map name.
type: str
+ required: true
entries:
description: Route map rules.
type: list
elements: dict
suboptions:
sequence:
- description: Rule number (1-65535).
+ description: Rule sequence number (1-65535).
type: int
+ required: true
action:
- description: permit or deny.
+ description: Permit or deny.
type: str
- choices: [permit, deny]
description:
+ description: Rule description.
type: str
call:
description: Call another route map.
type: str
continue_sequence:
- description: Continue at a different sequence.
+ description: Continue at a different sequence number.
type: int
match:
- description: Match conditions.
+ description: Match conditions (passed through to VyOS API).
type: dict
- suboptions:
- prefix_list:
- type: str
- prefix_list6:
- type: str
- interface:
- type: str
- metric:
- type: int
- origin:
- type: str
- ip:
- type: dict
- suboptions:
- nexthop_address:
- type: str
- nexthop_prefix_list:
- type: str
- ipv6:
- type: dict
- suboptions:
- nexthop_address:
- type: str
set:
- description: Route parameters to set.
+ description: Route parameters to set (passed through to VyOS API).
type: dict
- suboptions:
- aggregator:
- type: dict
- suboptions:
- as:
- type: str
- ip:
- type: str
- as_path_prepend:
- type: str
- atomic_aggregate:
- type: bool
- bgp_extcommunity_rt:
- type: str
- community:
- type: dict
- suboptions:
- value:
- type: str
- ip_next_hop:
- type: str
- ipv6_next_hop:
- type: dict
- suboptions:
- ip_type:
- type: str
- choices: [global, local]
- value:
- type: str
- large_community:
- type: str
- local_preference:
- type: int
- metric:
- type: str
- metric_type:
- type: str
- origin:
- type: str
- originator_id:
- type: str
- src:
- type: str
- tag:
- type: str
- weight:
- type: int
+
state:
+ description:
+ - Desired state of the route-map configuration.
+ - C(merged) adds or updates entries without removing existing ones.
+ - C(replaced) replaces each named route map mentioned in config.
+ - C(overridden) replaces all route maps.
+ - C(deleted) removes route maps. Without config removes all.
+ - C(gathered) returns current configuration as structured data.
type: str
choices: [merged, replaced, overridden, deleted, gathered]
default: merged
- hostname:
- type: str
- required: true
- port:
- type: int
- default: 443
- api_key:
- type: str
- required: true
- no_log: true
- timeout:
- type: int
- default: 30
- verify_ssl:
- type: bool
- default: false
+
+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"""
+- name: Merge route map configuration
+ vyos.rest.vyos_route_maps:
+ config:
+ - route_map: RM-TEST-EXPORT-POLICY
+ entries:
+ - sequence: 10
+ action: permit
+ match:
+ peer: 192.0.2.32
+ set:
+ metric: "5"
+ as_path_exclude: "111"
+ aggregator:
+ as: 100
+ state: merged
+
+- name: Delete all route maps
+ vyos.rest.vyos_route_maps:
+ state: deleted
+
+- name: Delete a specific route map
+ vyos.rest.vyos_route_maps:
+ config:
+ - route_map: RM-TEST-EXPORT-POLICY
+ state: deleted
+
+- name: Gather current route map configuration
+ vyos.rest.vyos_route_maps:
+ state: gathered
"""
RETURN = r"""
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
+ type: bool
+
+response:
+ description: Raw API response.
+ returned: when changes are applied
+ type: dict
"""
from ansible.module_utils.basic import AnsibleModule
-from ansible_collections.vyos.rest.plugins.module_utils.vyos_rest import (
- VYOS_REST_CONNECTION_ARGSPEC,
- VyOSRestClient,
- VyOSRestError,
-)
+from ansible_collections.vyos.rest.plugins.module_utils.vyos import VyOSModule
+
+
+_BASE = ["policy", "route-map"]
+
+# Argspec key → API path segments under "set"
+_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"],
+}
+# Argspec key → API path segments under "match"
+_MATCH_MAP = {
+ "interface": ["interface"],
+ "metric": ["metric"],
+ "origin": ["origin"],
+ "peer": ["peer"],
+ "protocol": ["protocol"],
+}
-def _get(client):
- try:
- r = client.retrieve_show_config(["policy", "route-map"])
- data = r.get("data") or {}
- return [{"route_map": name, "raw": rdata} for name, rdata in data.items()]
- except VyOSRestError:
+
+# ------------------------------------------------------------
+# Parsing: API response → argspec list
+# ------------------------------------------------------------
+
+
+def get_running_config(vyos):
+ """
+ Fetch current route map config in argspec list format.
+
+ API returns: {"route-map": {"RM-NAME": {"rule": {"10": {...}}}}}
+ Note the extra "route-map" nesting level that must be unwrapped.
+ """
+ raw = vyos.get_config(["policy", "route-map"])
+ if not raw or not isinstance(raw, dict):
+ return []
+
+ # API wraps everything under a "route-map" key — unwrap it
+ rm_data = raw.get("route-map", raw)
+ if not isinstance(rm_data, dict):
return []
+ 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"])
+ # match and set returned as raw API dicts
+ 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
+
+
+# ------------------------------------------------------------
+# Command builders
+# ------------------------------------------------------------
+
+
+def _match_cmds(rbase, match):
+ """Convert match dict to API path commands."""
+ cmds = []
+ 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])]))
+
+ if match.get("prefix_list"):
+ cmds.append(("set", mbase + ["ip", "address", "prefix-list", match["prefix_list"]]))
+ if match.get("prefix_list6"):
+ cmds.append(("set", mbase + ["ipv6", "address", "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"]]))
+ if ip.get("nexthop_prefix_list"):
+ cmds.append(("set", mbase + ["ip", "nexthop", "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"]]))
+
+ return cmds
+
+
+def _set_cmds(rbase, setv):
+ """Convert set dict to API path commands."""
+ cmds = []
+ if not setv:
+ return cmds
+
+ sbase = rbase + ["set"]
-def _apply(client, rm_cfg, commands):
- rm_name = rm_cfg["route_map"]
- for rule in rm_cfg.get("entries") or []:
- seq = str(rule["sequence"])
- base = ["policy", "route-map", rm_name, "rule", seq]
- client.configure_set(base)
- commands.append("set policy route-map {n} rule {s}".format(n=rm_name, s=seq))
-
- if rule.get("action"):
- client.configure_set(base + ["action"], rule["action"])
- commands.append(
- "set policy route-map {n} rule {s} action {a}".format(
- n=rm_name,
- s=seq,
- a=rule["action"],
- ),
- )
- if rule.get("description"):
- client.configure_set(base + ["description"], rule["description"])
- if rule.get("call"):
- client.configure_set(base + ["call"], rule["call"])
- if rule.get("continue_sequence"):
- client.configure_set(base + ["continue"], str(rule["continue_sequence"]))
-
- # Match conditions
+ for key, path_suffix in _SET_MAP.items():
+ if setv.get(key) is not None:
+ cmds.append(("set", sbase + path_suffix + [str(setv[key])]))
+
+ 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"]]))
+
+ 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)]))
+
+ 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_api_set(setv):
+ """
+ Convert argspec set dict to the nested API shape for comparison with have.
+
+ API stores:
+ as_path_exclude "111" → {"as-path": {"exclude": "111"}}
+ metric "5" → {"metric": "5"}
+ aggregator.as 100 → {"aggregator": {"as": "100"}}
+ """
+ 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):
+ """
+ Convert argspec match dict to API shape for comparison with have.
+ Flat fields map directly; prefix_list nests under ip.address.
+ """
+ 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):
+ """
+ Build commands for a single rule, comparing against have_rule to skip
+ fields that are already correctly configured (idempotency).
+ """
+ cmds = []
+ seq = str(rule["sequence"])
+ rbase = _BASE + [rm_name, "rule", seq]
+
+ # Scalar fields
+ 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"])]))
+
+ # match — convert want to API shape, compare top-level keys against have
+ 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:
+ # rebuild from original argspec for changed keys only
match = rule.get("match") or {}
- if match.get("prefix_list"):
- client.configure_set(
- base + ["match", "ip", "address", "prefix-list"],
- match["prefix_list"],
- )
- if match.get("interface"):
- client.configure_set(base + ["match", "interface"], match["interface"])
- if match.get("metric") is not None:
- client.configure_set(base + ["match", "metric"], str(match["metric"]))
- ip_match = match.get("ip") or {}
- if ip_match.get("nexthop_address"):
- client.configure_set(
- base + ["match", "ip", "nexthop", "address"],
- ip_match["nexthop_address"],
- )
-
- # Set actions
- setv = rule.get("set") or {}
- if setv.get("ip_next_hop"):
- client.configure_set(base + ["set", "ip-next-hop"], setv["ip_next_hop"])
- commands.append(
- "set policy route-map {n} rule {s} set ip-next-hop {nh}".format(
- n=rm_name,
- s=seq,
- nh=setv["ip_next_hop"],
- ),
- )
- if setv.get("local_preference") is not None:
- client.configure_set(
- base + ["set", "local-preference"],
- str(setv["local_preference"]),
- )
- if setv.get("metric"):
- client.configure_set(base + ["set", "metric"], str(setv["metric"]))
- if setv.get("origin"):
- client.configure_set(base + ["set", "origin"], setv["origin"])
- if setv.get("weight") is not None:
- client.configure_set(base + ["set", "weight"], str(setv["weight"]))
- if setv.get("as_path_prepend"):
- client.configure_set(base + ["set", "as-path-prepend"], setv["as_path_prepend"])
- comm = setv.get("community") or {}
- if comm.get("value"):
- client.configure_set(base + ["set", "community", comm["value"]])
- if setv.get("tag"):
- client.configure_set(base + ["set", "tag"], setv["tag"])
- if setv.get("src"):
- client.configure_set(base + ["set", "src"], setv["src"])
- nh6 = setv.get("ipv6_next_hop") or {}
- if nh6.get("value"):
- ip_type = nh6.get("ip_type", "global")
- client.configure_set(base + ["set", "ipv6-next-hop", ip_type], nh6["value"])
+ 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)
+
+ # set — convert want to API shape, compare top-level keys against have
+ want_set_api = _want_to_api_set(rule.get("set"))
+ have_set = have_rule.get("set") or {}
+ if want_set_api != have_set:
+ # something changed — regenerate all set commands
+ # (simpler than surgical per-key diff for deeply nested structures)
+ cmds += _set_cmds(rbase, rule.get("set"))
+
+ return cmds
+
+
+def build_commands(config, have_raw, state):
+ """Build command tuples to move from have → want."""
+ cmds = []
+
+ if state == "deleted":
+ if not config:
+ if have_raw:
+ cmds.append(("delete", _BASE))
+ else:
+ for rm in config:
+ cmds.append(("delete", _BASE + [rm["route_map"]]))
+ return cmds
+
+ have_map = {e["route_map"]: e for e in have_raw}
+
+ 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:
+ cmds.append(("delete", _BASE + [rm_name]))
+ have_rm = {}
+
+ # Index have entries by sequence for O(1) lookup
+ 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)
+
+ return cmds
+
+
+# ------------------------------------------------------------
+# Argument spec — minimal, VyOS API validates field values
+# ------------------------------------------------------------
+
+ARGUMENT_SPEC = dict(
+ config=dict(type="list", elements="dict"),
+ state=dict(
+ default="merged",
+ choices=["merged", "replaced", "overridden", "deleted", "gathered"],
+ ),
+)
+
+
+# ------------------------------------------------------------
+# Main
+# ------------------------------------------------------------
def main():
- set_spec = dict(
- type="dict",
- options=dict(
- aggregator=dict(type="dict", options=dict(as_=dict(type="str"), ip=dict(type="str"))),
- as_path_prepend=dict(type="str"),
- atomic_aggregate=dict(type="bool"),
- bgp_extcommunity_rt=dict(type="str"),
- community=dict(type="dict", options=dict(value=dict(type="str"))),
- ip_next_hop=dict(type="str"),
- ipv6_next_hop=dict(
- type="dict",
- options=dict(
- ip_type=dict(type="str", choices=["global", "local"]),
- value=dict(type="str"),
- ),
- ),
- large_community=dict(type="str"),
- local_preference=dict(type="int"),
- metric=dict(type="str"),
- metric_type=dict(type="str"),
- origin=dict(type="str"),
- originator_id=dict(type="str"),
- src=dict(type="str"),
- tag=dict(type="str"),
- weight=dict(type="int"),
- ),
- )
- match_spec = dict(
- type="dict",
- options=dict(
- prefix_list=dict(type="str"),
- prefix_list6=dict(type="str"),
- interface=dict(type="str"),
- metric=dict(type="int"),
- origin=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"))),
- ),
- )
-
- argument_spec = dict(
- config=dict(
- type="list",
- elements="dict",
- options=dict(
- route_map=dict(type="str"),
- entries=dict(
- type="list",
- elements="dict",
- options=dict(
- sequence=dict(type="int"),
- action=dict(type="str", choices=["permit", "deny"]),
- description=dict(type="str"),
- call=dict(type="str"),
- continue_sequence=dict(type="int"),
- match=match_spec,
- set=set_spec,
- ),
- ),
- ),
- ),
- state=dict(
- type="str",
- default="merged",
- choices=["merged", "replaced", "overridden", "deleted", "gathered"],
- ),
- )
- argument_spec.update(VYOS_REST_CONNECTION_ARGSPEC)
- module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True)
- client = VyOSRestClient(module)
+
+ module = AnsibleModule(ARGUMENT_SPEC, supports_check_mode=True)
+
+ vyos = VyOSModule(module)
+
state = module.params["state"]
config = module.params.get("config") or []
- commands = []
- changed = False
- before = _get(client)
+
+ have = get_running_config(vyos)
if state == "gathered":
- module.exit_json(changed=False, gathered=before, before=before, commands=[])
+ module.exit_json(changed=False, gathered=have)
+
+ commands = build_commands(config, have, state)
+
if module.check_mode:
- module.exit_json(changed=True, before=before, commands=["(check mode)"])
-
- try:
- if state == "deleted" and not config:
- try:
- client.configure_delete(["policy", "route-map"])
- commands.append("delete policy route-map")
- changed = True
- except VyOSRestError:
- pass
- elif state == "deleted" and config:
- for rm in config:
- try:
- client.configure_delete(["policy", "route-map", rm["route_map"]])
- commands.append("delete policy route-map {n}".format(n=rm["route_map"]))
- changed = True
- except VyOSRestError:
- pass
- elif state in ("merged", "replaced", "overridden"):
- if state in ("replaced", "overridden"):
- for rm in config:
- try:
- client.configure_delete(["policy", "route-map", rm["route_map"]])
- except VyOSRestError:
- pass
- for rm in config:
- _apply(client, rm, commands)
- changed = True
- except VyOSRestError as exc:
- module.fail_json(msg=str(exc))
+ module.exit_json(changed=bool(commands), commands=commands, before=have)
+
+ if commands:
+ response = vyos.apply_commands(commands)
+ saved = vyos.save_config()
+ module.exit_json(
+ changed=True,
+ before=have,
+ after=get_running_config(vyos),
+ commands=commands,
+ saved=saved,
+ response=response,
+ )
- after = _get(client) if changed else before
- module.exit_json(changed=changed, before=before, after=after, commands=commands)
+ module.exit_json(changed=False, before=have, after=have, commands=[])
if __name__ == "__main__":