summaryrefslogtreecommitdiff
path: root/plugins/modules
diff options
context:
space:
mode:
Diffstat (limited to 'plugins/modules')
-rw-r--r--plugins/modules/vyos_prefix_lists.py357
-rw-r--r--plugins/modules/vyos_static_routes.py467
2 files changed, 824 insertions, 0 deletions
diff --git a/plugins/modules/vyos_prefix_lists.py b/plugins/modules/vyos_prefix_lists.py
new file mode 100644
index 0000000..f3df68f
--- /dev/null
+++ b/plugins/modules/vyos_prefix_lists.py
@@ -0,0 +1,357 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import absolute_import, division, print_function
+
+
+__metaclass__ = type
+
+DOCUMENTATION = r"""
+---
+module: vyos_prefix_lists
+short_description: Manage prefix-list configuration on VyOS devices using REST API
+description:
+ - 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 grouped by address family.
+ type: list
+ elements: dict
+ suboptions:
+ afi:
+ description: Address family identifier.
+ type: str
+ choices: [ipv4, ipv6]
+ required: true
+ prefix_lists:
+ description: Named prefix lists.
+ type: list
+ elements: dict
+ suboptions:
+ name:
+ description: Prefix list name.
+ type: str
+ required: true
+ description:
+ description: Prefix list description.
+ type: str
+ entries:
+ description: Prefix list rules.
+ type: list
+ elements: dict
+ suboptions:
+ sequence:
+ description: Rule sequence number.
+ type: int
+ required: true
+ description:
+ description: Rule description.
+ type: str
+ action:
+ description: Permit or deny.
+ type: str
+ choices: [permit, deny]
+ ge:
+ description: Minimum prefix length.
+ type: int
+ le:
+ description: Maximum prefix length.
+ type: int
+ 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
+
+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 import VyOSModule
+
+
+_PL_KEY = {"ipv4": "prefix-list", "ipv6": "prefix-list6"}
+
+
+def get_running_config(vyos):
+ 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
+
+
+def _normalize(config):
+ 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
+
+
+def _rule_cmds(base, seq, want_rule, have_rules, state):
+ cmds = []
+ rbase = base + ["rule", str(seq)]
+
+ if seq not in (have_rules or {}):
+ 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:
+ h = have_rules[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])]))
+
+ return cmds
+
+
+def build_commands(config, have_raw, state):
+ cmds = []
+
+ if state == "deleted":
+ if not config:
+ for afi, api_key in _PL_KEY.items():
+ if any(e.get("afi") == afi for e in have_raw):
+ cmds.append(("delete", ["policy", api_key]))
+ else:
+ 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":
+ 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]
+ want_rules = want_pl.get("rules", {})
+ have_rules = have_pl.get("rules", {})
+
+ if state == "replaced" and pl_name in have_afi:
+ # Only delete and rebuild if something actually differs
+ want_desc = want_pl.get("description")
+ have_desc = have_pl.get("description")
+ extra_seqs = set(have_rules.keys()) - set(want_rules.keys())
+ test_cmds = []
+ for seq, want_rule in want_rules.items():
+ test_cmds += _rule_cmds(base, seq, want_rule, have_rules, state)
+ if test_cmds or extra_seqs or want_desc != have_desc:
+ cmds.append(("delete", ["policy", api_key, pl_name]))
+ have_pl = {}
+ have_rules = {}
+ else:
+ continue # already matches — idempotent
+
+ # Description
+ if want_pl.get("description") and want_pl["description"] != have_pl.get("description"):
+ cmds.append(("set", base + ["description", want_pl["description"]]))
+
+ # Rules
+ for seq, want_rule in want_rules.items():
+ cmds += _rule_cmds(base, seq, want_rule, have_rules, state)
+
+ return cmds
+
+
+ARGUMENT_SPEC = dict(
+ config=dict(type="list", elements="dict"),
+ state=dict(
+ default="merged",
+ choices=["merged", "replaced", "overridden", "deleted", "gathered"],
+ ),
+)
+
+
+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)
+
+ if state == "gathered":
+ module.exit_json(changed=False, gathered=have)
+
+ commands = build_commands(config, have, state)
+
+ if module.check_mode:
+ 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,
+ )
+
+ module.exit_json(changed=False, before=have, after=have, commands=[])
+
+
+if __name__ == "__main__":
+ main()
diff --git a/plugins/modules/vyos_static_routes.py b/plugins/modules/vyos_static_routes.py
new file mode 100644
index 0000000..3276588
--- /dev/null
+++ b/plugins/modules/vyos_static_routes.py
@@ -0,0 +1,467 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import absolute_import, division, print_function
+
+
+__metaclass__ = type
+
+DOCUMENTATION = r"""
+---
+module: vyos_static_routes
+short_description: Manage static routes on VyOS devices via REST API.
+description:
+ - Manages IPv4 and IPv6 static routes on VyOS devices using the HTTPS REST API.
+ - >-
+ Covers blackhole routes (distance) and next-hop routes (distance,
+ disable, outgoing interface). VyOS's static-route schema is
+ considerably larger than this -- reject routes (an ICMP-unreachable
+ counterpart to blackhole), a top-level per-route interface (a route
+ resolved via an outgoing interface with no next-hop address at
+ all), route tags, route descriptions, ECMP segment weighting, VRF
+ leaking, and BFD monitoring on next-hops are not modeled here.
+ That is a real, documented limitation, not an oversight.
+version_added: "1.0.0"
+author:
+ - VyOS Community (@vyos)
+options:
+ config:
+ description: List of static route configurations grouped by address family.
+ type: list
+ elements: dict
+ suboptions:
+ afi:
+ description: Address family indicator.
+ type: str
+ choices: [ipv4, ipv6]
+ required: true
+ routes:
+ description: List of static route entries.
+ type: list
+ elements: dict
+ suboptions:
+ dest:
+ description: Destination prefix in CIDR notation.
+ type: str
+ required: true
+ blackhole_config:
+ description: Blackhole route configuration (silently discard matching packets).
+ type: dict
+ suboptions:
+ distance:
+ description: Administrative distance (1-255).
+ type: int
+ next_hops:
+ description: List of next-hop addresses.
+ type: list
+ elements: dict
+ suboptions:
+ forward_router_address:
+ description: Next-hop IP address.
+ type: str
+ required: true
+ admin_distance:
+ description: Administrative distance for this next-hop (1-255).
+ type: int
+ enabled:
+ description: Whether this next-hop is enabled.
+ type: bool
+ default: true
+ interface:
+ description: Outgoing interface name.
+ type: str
+ state:
+ description:
+ - C(merged) - Add routes without removing existing ones.
+ - C(replaced) - Replace each named route (by afi + dest) exactly as specified.
+ - C(overridden) - Replace the entire static route table.
+ - C(deleted) - Remove listed or all static routes.
+ - C(gathered) - Read static routes from device without changes.
+ type: str
+ choices: [merged, replaced, overridden, deleted, gathered]
+ default: merged
+seealso:
+ - module: vyos.vyos.vyos_static_routes
+"""
+
+EXAMPLES = r"""
+- name: Merge IPv4 and IPv6 static routes
+ vyos.rest.vyos_static_routes:
+ config:
+ - afi: ipv4
+ routes:
+ - dest: 192.0.2.0/24
+ next_hops:
+ - forward_router_address: 10.0.0.1
+ - dest: 203.0.113.0/24
+ blackhole_config:
+ distance: 200
+ - afi: ipv6
+ routes:
+ - dest: 2001:db8::/32
+ next_hops:
+ - forward_router_address: 2001:db8::1
+ state: merged
+
+- name: Delete all static routes
+ vyos.rest.vyos_static_routes:
+ state: deleted
+
+- name: Gather current static routes
+ vyos.rest.vyos_static_routes:
+ state: gathered
+"""
+
+RETURN = r"""
+before:
+ description: Static route configuration before this module ran.
+ returned: always
+ type: list
+after:
+ description: Static route 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 static route 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 import (
+ VyOSModule,
+ autoclean,
+ cast_by_spec,
+ dict_op,
+ from_device,
+ to_tag_dict,
+)
+
+
+_BASE = ["protocols", "static"]
+_ROUTE_KEY = {"ipv4": "route", "ipv6": "route6"}
+
+
+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: every named-list section in this
+ argspec (routes, next_hops) already marks exactly one suboption
+ required=True.
+ """
+ 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 = entry_transform or (lambda rest: rest)
+ 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 _keyed_list_from_device(raw, key_field, entry_transform=None):
+ entry_transform = entry_transform or (lambda d: d or {})
+ return [
+ {key_field: key, **entry_transform(data or {})}
+ for key, data in sorted(to_tag_dict(raw).items())
+ ]
+
+
+# ---------------------------------------------------------------------------
+# blackhole_config -- confirmed against vyos-1x: the device node has
+# ONLY "distance" (1-255) and "tag" (not modeled, see module scope
+# note). There is no "type" leaf anywhere in the schema -- the
+# original module's blackhole_config.type field was a genuine,
+# confirmed hallucination and has been removed.
+# ---------------------------------------------------------------------------
+
+
+def _blackhole_to_device(bc):
+ return autoclean(bc or {})
+
+
+def _blackhole_from_device(data):
+ if data is None:
+ return None
+ return from_device(data if isinstance(data, dict) else {})
+
+
+# ---------------------------------------------------------------------------
+# next_hops -- confirmed against vyos-1x: keyed by the next-hop
+# address, with disable (presence), distance (1-255), and interface
+# (plain leaf) as children. segments/vrf/bfd are real but out of scope
+# (see module docstring).
+# ---------------------------------------------------------------------------
+
+
+# admin_distance -> distance is a plain rename (not mechanical kebab).
+# enabled -> disable is a genuine, irreducible boolean inversion: the
+# device tracks presence of "disable" for an OFF next-hop, the
+# argspec tracks an "enabled" bool defaulting True -- autoclean's own
+# bool handling (True -> presence) doesn't fit an inverted, default-
+# true flag, so it's handled explicitly rather than forced through
+# the generic path. "interface" is a plain, direct-match leaf and
+# goes through autoclean/from_device like everywhere else.
+_NEXT_HOP_RENAMES = {"admin_distance": "distance"}
+
+
+def _next_hop_entry_to_device(rest):
+ exclude = set(_NEXT_HOP_RENAMES) | {"enabled"}
+ device = autoclean({k: v for k, v in rest.items() if k not in exclude})
+ for arg_key, device_key in _NEXT_HOP_RENAMES.items():
+ if rest.get(arg_key) is not None:
+ device[device_key] = rest[arg_key]
+ if rest.get("enabled") is False:
+ device["disable"] = {}
+ return device
+
+
+def _next_hop_entry_from_device(data):
+ exclude = set(_NEXT_HOP_RENAMES.values()) | {"disable"}
+ entry = from_device({k: v for k, v in data.items() if k not in exclude})
+ for arg_key, device_key in _NEXT_HOP_RENAMES.items():
+ if data.get(device_key) is not None:
+ entry[arg_key] = int(data[device_key])
+ if "disable" in data:
+ entry["enabled"] = False
+ return entry
+
+
+def _route_entry_to_device(rest):
+ device = {}
+ bc = rest.get("blackhole_config")
+ if bc is not None:
+ device["blackhole"] = _blackhole_to_device(bc)
+ next_hops = rest.get("next_hops") or []
+ if next_hops:
+ device["next-hop"] = _keyed_list_to_device(
+ next_hops,
+ "forward_router_address",
+ _next_hop_entry_to_device,
+ )
+ return device
+
+
+def _route_entry_from_device(data):
+ entry = {}
+ bh = _blackhole_from_device(data.get("blackhole"))
+ if bh is not None:
+ entry["blackhole_config"] = bh
+ nh_raw = data.get("next-hop")
+ if nh_raw:
+ entry["next_hops"] = _keyed_list_from_device(
+ nh_raw,
+ "forward_router_address",
+ _next_hop_entry_from_device,
+ )
+ return entry
+
+
+def _want_to_device(config):
+ result = {}
+ for entry in config or []:
+ afi = entry.get("afi")
+ route_key = _ROUTE_KEY.get(afi)
+ if not route_key:
+ continue
+ routes = entry.get("routes") or []
+ if not routes:
+ continue
+ result[route_key] = _keyed_list_to_device(routes, "dest", _route_entry_to_device)
+ return result
+
+
+def get_running_config(vyos):
+ return vyos.get_config(_BASE) or {}
+
+
+def _device_to_argspec(raw):
+ if not raw:
+ return []
+ result = []
+ for afi, route_key in _ROUTE_KEY.items():
+ raw_routes = raw.get(route_key)
+ if not raw_routes:
+ continue
+ routes = _keyed_list_from_device(raw_routes, "dest", _route_entry_from_device)
+ if routes:
+ result.append({"afi": afi, "routes": routes})
+ return result
+
+
+def build_commands(config, raw_have, state):
+ raw_have = raw_have or {}
+ config = config or []
+
+ if state == "deleted":
+ if not config:
+ return [("delete", _BASE)] if raw_have else []
+ cmds = []
+ for entry in config:
+ route_key = _ROUTE_KEY.get(entry.get("afi"))
+ if not route_key:
+ continue
+ have_routes = raw_have.get(route_key) or {}
+ routes = entry.get("routes") or []
+ if not routes:
+ if route_key in raw_have:
+ cmds.append(("delete", _BASE + [route_key]))
+ continue
+ for route in routes:
+ dest = route.get("dest")
+ if dest and dest in have_routes:
+ cmds.append(("delete", _BASE + [route_key, dest]))
+ return cmds
+
+ want = _want_to_device(config)
+ norm_have = _want_to_device(_device_to_argspec(raw_have))
+
+ commands = []
+ if state == "overridden":
+ commands += dict_op(want, norm_have, _BASE, op="purge")
+ elif state == "replaced":
+ # Scoped per individual route (afi + dest), matching every
+ # other module's "replaced only touches what's named" semantic
+ # -- not per address-family, which would incorrectly behave
+ # like overridden for the whole AFI. This also directly fixes
+ # the confirmed bug in the original implementation: dict_op's
+ # purge naturally detects and clears any omitted attribute
+ # (a next-hop's distance/interface, a route's blackhole
+ # entirely), rather than the original's hand-rolled "does
+ # anything differ" heuristic, which only inspected generated
+ # set-commands and therefore could never notice an omitted
+ # (cleared) value, since clearing never produced a command in
+ # the first place.
+ for entry in config:
+ route_key = _ROUTE_KEY.get(entry.get("afi"))
+ if not route_key:
+ continue
+ for route in entry.get("routes") or []:
+ dest = route.get("dest")
+ if not dest:
+ continue
+ section_want = (want.get(route_key) or {}).get(dest, {})
+ section_have = (norm_have.get(route_key) or {}).get(dest, {})
+ commands += dict_op(
+ section_want,
+ section_have,
+ _BASE + [route_key, dest],
+ op="purge",
+ )
+ commands += dict_op(want, norm_have, _BASE, op="set")
+ return commands
+
+
+_NEXT_HOP_OPTIONS = dict(
+ forward_router_address=dict(type="str", required=True),
+ admin_distance=dict(type="int"),
+ enabled=dict(type="bool", default=True),
+ interface=dict(type="str"),
+)
+
+_ROUTE_OPTIONS = dict(
+ dest=dict(type="str", required=True),
+ blackhole_config=dict(
+ type="dict",
+ options=dict(
+ distance=dict(type="int"),
+ ),
+ ),
+ next_hops=dict(
+ type="list",
+ elements="dict",
+ options=_NEXT_HOP_OPTIONS,
+ ),
+)
+
+ARGUMENT_SPEC = dict(
+ config=dict(
+ type="list",
+ elements="dict",
+ options=dict(
+ afi=dict(type="str", required=True, choices=["ipv4", "ipv6"]),
+ routes=dict(
+ type="list",
+ elements="dict",
+ options=_ROUTE_OPTIONS,
+ ),
+ ),
+ ),
+ state=dict(
+ type="str",
+ default="merged",
+ choices=["merged", "replaced", "overridden", "deleted", "gathered"],
+ ),
+)
+
+
+def main():
+ module = AnsibleModule(ARGUMENT_SPEC, supports_check_mode=True)
+ vyos = VyOSModule(module)
+
+ state = module.params["state"]
+ config = module.params.get("config") or []
+
+ raw_have = get_running_config(vyos)
+ have = _device_to_argspec(raw_have)
+ for entry in have:
+ for route in entry.get("routes") or []:
+ cast_by_spec(route, _ROUTE_OPTIONS)
+
+ if state == "gathered":
+ module.exit_json(changed=False, gathered=have)
+
+ commands = build_commands(config, raw_have, state)
+
+ if module.check_mode:
+ module.exit_json(changed=bool(commands), commands=commands, before=have)
+
+ if commands:
+ response = vyos.apply_commands(commands)
+ saved = vyos.save_config()
+ after_raw = get_running_config(vyos)
+ after = _device_to_argspec(after_raw)
+ for entry in after:
+ for route in entry.get("routes") or []:
+ cast_by_spec(route, _ROUTE_OPTIONS)
+ module.exit_json(
+ changed=True,
+ before=have,
+ after=after,
+ commands=commands,
+ saved=saved,
+ response=response,
+ )
+
+ module.exit_json(changed=False, before=have, after=have, commands=[])
+
+
+if __name__ == "__main__":
+ main()