summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authoromnom62 <75066712+omnom62@users.noreply.github.com>2026-08-21 23:54:29 +1000
committerGitHub <noreply@github.com>2026-08-21 08:54:29 -0500
commit885b9462480712210ddeea9ca4a4b7a52e9ef587 (patch)
treea994ef61f704d41bdab770c86a21481a5d95d2b1
parent6dc9d721513dca5a573c7ee01b74d703ff41f95f (diff)
downloadrest.vyos-885b9462480712210ddeea9ca4a4b7a52e9ef587.tar.gz
rest.vyos-885b9462480712210ddeea9ca4a4b7a52e9ef587.zip
T8989: static_routes module
* T8989 static_routes * T8989 static_routes * T8989 added changelog fragment for static routes module * t8989 static route changelog * t8989 static_routes module * T8989: static_routes SIT overridden and rtt * T8989: SIT updated * T8989: vyos_static_routes dict_op refactor
-rw-r--r--README.md7
-rw-r--r--changelogs/fragments/t8989_static_routes.yml3
-rw-r--r--docs/vyos.rest.vyos_static_routes_module.rst429
-rw-r--r--plugins/module_utils/vyos.py449
-rw-r--r--plugins/modules/vyos_static_routes.py467
-rw-r--r--tests/integration/targets/vyos_static_routes/aliases1
-rw-r--r--tests/integration/targets/vyos_static_routes/defaults/main.yaml3
-rw-r--r--tests/integration/targets/vyos_static_routes/tasks/httpapi.yaml21
-rw-r--r--tests/integration/targets/vyos_static_routes/tasks/main.yaml5
-rw-r--r--tests/integration/targets/vyos_static_routes/tests/httpapi.yaml21
-rw-r--r--tests/integration/targets/vyos_static_routes/tests/httpapi/_populate_config.yaml19
-rw-r--r--tests/integration/targets/vyos_static_routes/tests/httpapi/_remove_config.yaml5
-rw-r--r--tests/integration/targets/vyos_static_routes/tests/httpapi/deleted.yaml28
-rw-r--r--tests/integration/targets/vyos_static_routes/tests/httpapi/gathered.yaml20
-rw-r--r--tests/integration/targets/vyos_static_routes/tests/httpapi/merged.yaml37
-rw-r--r--tests/integration/targets/vyos_static_routes/tests/httpapi/overridden.yaml34
-rw-r--r--tests/integration/targets/vyos_static_routes/tests/httpapi/replaced.yaml35
-rw-r--r--tests/integration/targets/vyos_static_routes/tests/httpapi/rtt.yaml71
-rw-r--r--tests/integration/targets/vyos_static_routes/vars/main.yaml1
-rw-r--r--tests/unit/fixtures/static_routes_running.json21
-rw-r--r--tests/unit/modules/test_vyos_static_routes.py365
21 files changed, 1947 insertions, 95 deletions
diff --git a/README.md b/README.md
index 647c33a..b479eef 100644
--- a/README.md
+++ b/README.md
@@ -80,11 +80,18 @@ Name | Description
[vyos.rest.vyos_configure](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_configure_module.rst)|Send raw set/delete commands to a VyOS device via REST API.
[vyos.rest.vyos_hostname](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_hostname_module.rst)|Manage the system hostname on a VyOS device via the REST API.
[vyos.rest.vyos_lldp_global](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_lldp_global_module.rst)|Manage LLDP global configuration on VyOS via REST API.
+<<<<<<< HEAD
[vyos.rest.vyos_logging_global](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_logging_global_module.rst)|Manage syslog configuration on VyOS devices using REST API
[vyos.rest.vyos_ntp_global](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_ntp_global_module.rst)|Manage NTP configuration on VyOS devices using REST API
[vyos.rest.vyos_prefix_lists](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_prefix_lists_module.rst)|Manage prefix-list configuration on VyOS devices using REST API
[vyos.rest.vyos_route_maps](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_route_maps_module.rst)|Manage route-map configuration on VyOS devices using REST API
[vyos.rest.vyos_snmp_server](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_snmp_server_module.rst)|Manage SNMP server configuration on VyOS devices using REST API
+[vyos.rest.vyos_logging_global](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_logging_global_module.rst)|Manage syslog configuration on VyOS devices using REST API.
+[vyos.rest.vyos_ntp_global](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_ntp_global_module.rst)|Manage NTP configuration on VyOS devices using REST API.
+[vyos.rest.vyos_prefix_lists](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_prefix_lists_module.rst)|⚠️ Manage prefix-list configuration on VyOS devices using REST API. *(not yet available)*
+[vyos.rest.vyos_route_maps](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_route_maps_module.rst)|Manage route-map configuration on VyOS devices using REST API.
+[vyos.rest.vyos_snmp_server](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_snmp_server_module.rst)|Manage SNMP server configuration on VyOS devices using REST API.
+[vyos.rest.vyos_static_routes](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_static_routes_module.rst)|Manage static routes on VyOS devices via REST API.
<!--end collection content-->
diff --git a/changelogs/fragments/t8989_static_routes.yml b/changelogs/fragments/t8989_static_routes.yml
new file mode 100644
index 0000000..ef877d8
--- /dev/null
+++ b/changelogs/fragments/t8989_static_routes.yml
@@ -0,0 +1,3 @@
+---
+minor_changes:
+ - vyos_statc_routes - Added static_routes module, documenatation and tests..
diff --git a/docs/vyos.rest.vyos_static_routes_module.rst b/docs/vyos.rest.vyos_static_routes_module.rst
new file mode 100644
index 0000000..353cf2d
--- /dev/null
+++ b/docs/vyos.rest.vyos_static_routes_module.rst
@@ -0,0 +1,429 @@
+.. _vyos.rest.vyos_static_routes_module:
+
+
+****************************
+vyos.rest.vyos_static_routes
+****************************
+
+**Manage static routes on VyOS devices via REST API.**
+
+
+Version added: 1.0.0
+
+.. contents::
+ :local:
+ :depth: 1
+
+
+Synopsis
+--------
+- 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.
+
+
+
+
+Parameters
+----------
+
+.. raw:: html
+
+ <table border=0 cellpadding=0 class="documentation-table">
+ <tr>
+ <th colspan="4">Parameter</th>
+ <th>Choices/<font color="blue">Defaults</font></th>
+ <th width="100%">Comments</th>
+ </tr>
+ <tr>
+ <td colspan="4">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>config</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">list</span>
+ / <span style="color: purple">elements=dictionary</span>
+ </div>
+ </td>
+ <td>
+ </td>
+ <td>
+ <div>List of static route configurations grouped by address family.</div>
+ </td>
+ </tr>
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td colspan="3">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>afi</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">string</span>
+ / <span style="color: red">required</span>
+ </div>
+ </td>
+ <td>
+ <ul style="margin: 0; padding: 0"><b>Choices:</b>
+ <li>ipv4</li>
+ <li>ipv6</li>
+ </ul>
+ </td>
+ <td>
+ <div>Address family indicator.</div>
+ </td>
+ </tr>
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td colspan="3">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>routes</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">list</span>
+ / <span style="color: purple">elements=dictionary</span>
+ </div>
+ </td>
+ <td>
+ </td>
+ <td>
+ <div>List of static route entries.</div>
+ </td>
+ </tr>
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td colspan="2">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>blackhole_config</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">dictionary</span>
+ </div>
+ </td>
+ <td>
+ </td>
+ <td>
+ <div>Blackhole route configuration (silently discard matching packets).</div>
+ </td>
+ </tr>
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>distance</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">integer</span>
+ </div>
+ </td>
+ <td>
+ </td>
+ <td>
+ <div>Administrative distance (1-255).</div>
+ </td>
+ </tr>
+
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td colspan="2">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>dest</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">string</span>
+ / <span style="color: red">required</span>
+ </div>
+ </td>
+ <td>
+ </td>
+ <td>
+ <div>Destination prefix in CIDR notation.</div>
+ </td>
+ </tr>
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td colspan="2">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>next_hops</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">list</span>
+ / <span style="color: purple">elements=dictionary</span>
+ </div>
+ </td>
+ <td>
+ </td>
+ <td>
+ <div>List of next-hop addresses.</div>
+ </td>
+ </tr>
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>admin_distance</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">integer</span>
+ </div>
+ </td>
+ <td>
+ </td>
+ <td>
+ <div>Administrative distance for this next-hop (1-255).</div>
+ </td>
+ </tr>
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>enabled</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">boolean</span>
+ </div>
+ </td>
+ <td>
+ <ul style="margin: 0; padding: 0"><b>Choices:</b>
+ <li>no</li>
+ <li><div style="color: blue"><b>yes</b>&nbsp;&larr;</div></li>
+ </ul>
+ </td>
+ <td>
+ <div>Whether this next-hop is enabled.</div>
+ </td>
+ </tr>
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>forward_router_address</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">string</span>
+ / <span style="color: red">required</span>
+ </div>
+ </td>
+ <td>
+ </td>
+ <td>
+ <div>Next-hop IP address.</div>
+ </td>
+ </tr>
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>interface</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">string</span>
+ </div>
+ </td>
+ <td>
+ </td>
+ <td>
+ <div>Outgoing interface name.</div>
+ </td>
+ </tr>
+
+
+
+ <tr>
+ <td colspan="4">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>state</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">string</span>
+ </div>
+ </td>
+ <td>
+ <ul style="margin: 0; padding: 0"><b>Choices:</b>
+ <li><div style="color: blue"><b>merged</b>&nbsp;&larr;</div></li>
+ <li>replaced</li>
+ <li>overridden</li>
+ <li>deleted</li>
+ <li>gathered</li>
+ </ul>
+ </td>
+ <td>
+ <div><code>merged</code> - Add routes without removing existing ones.</div>
+ <div><code>replaced</code> - Replace each named route (by afi + dest) exactly as specified.</div>
+ <div><code>overridden</code> - Replace the entire static route table.</div>
+ <div><code>deleted</code> - Remove listed or all static routes.</div>
+ <div><code>gathered</code> - Read static routes from device without changes.</div>
+ </td>
+ </tr>
+ </table>
+ <br/>
+
+
+
+See Also
+--------
+
+.. seealso::
+
+ :ref:`vyos.vyos.vyos_static_routes_module`
+ The official documentation on the **vyos.vyos.vyos_static_routes** module.
+
+
+Examples
+--------
+
+.. code-block:: yaml
+
+ - 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 Values
+-------------
+Common return values are documented `here <https://docs.ansible.com/ansible/latest/reference_appendices/common_return_values.html#common-return-values>`_, the following are the fields unique to this module:
+
+.. raw:: html
+
+ <table border=0 cellpadding=0 class="documentation-table">
+ <tr>
+ <th colspan="1">Key</th>
+ <th>Returned</th>
+ <th width="100%">Description</th>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="return-"></div>
+ <b>after</b>
+ <a class="ansibleOptionLink" href="#return-" title="Permalink to this return value"></a>
+ <div style="font-size: small">
+ <span style="color: purple">list</span>
+ </div>
+ </td>
+ <td>when changed</td>
+ <td>
+ <div>Static route configuration after this module ran.</div>
+ <br/>
+ </td>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="return-"></div>
+ <b>before</b>
+ <a class="ansibleOptionLink" href="#return-" title="Permalink to this return value"></a>
+ <div style="font-size: small">
+ <span style="color: purple">list</span>
+ </div>
+ </td>
+ <td>always</td>
+ <td>
+ <div>Static route configuration before this module ran.</div>
+ <br/>
+ </td>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="return-"></div>
+ <b>commands</b>
+ <a class="ansibleOptionLink" href="#return-" title="Permalink to this return value"></a>
+ <div style="font-size: small">
+ <span style="color: purple">list</span>
+ </div>
+ </td>
+ <td>always</td>
+ <td>
+ <div>List of API command tuples sent to the device.</div>
+ <br/>
+ </td>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="return-"></div>
+ <b>gathered</b>
+ <a class="ansibleOptionLink" href="#return-" title="Permalink to this return value"></a>
+ <div style="font-size: small">
+ <span style="color: purple">list</span>
+ </div>
+ </td>
+ <td>when state is gathered</td>
+ <td>
+ <div>Current static route configuration as structured data.</div>
+ <br/>
+ </td>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="return-"></div>
+ <b>response</b>
+ <a class="ansibleOptionLink" href="#return-" title="Permalink to this return value"></a>
+ <div style="font-size: small">
+ <span style="color: purple">dictionary</span>
+ </div>
+ </td>
+ <td>when changes are applied</td>
+ <td>
+ <div>Raw API response.</div>
+ <br/>
+ </td>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="return-"></div>
+ <b>saved</b>
+ <a class="ansibleOptionLink" href="#return-" title="Permalink to this return value"></a>
+ <div style="font-size: small">
+ <span style="color: purple">boolean</span>
+ </div>
+ </td>
+ <td>when changes are applied</td>
+ <td>
+ <div>Whether the config was saved after changes.</div>
+ <br/>
+ </td>
+ </tr>
+ </table>
+ <br/><br/>
+
+
+Status
+------
+
+
+Authors
+~~~~~~~
+
+- VyOS Community (@vyos)
diff --git a/plugins/module_utils/vyos.py b/plugins/module_utils/vyos.py
index 82670e7..a37a0c2 100644
--- a/plugins/module_utils/vyos.py
+++ b/plugins/module_utils/vyos.py
@@ -5,15 +5,6 @@ Provides ``get_config()``, ``apply_commands()``, and ``save_config()``
on top of ``VyOSRestClient``, so resource modules can work with simple
``("set", path)`` / ``("delete", path)`` command tuples rather than
calling the REST client directly.
-
-Version-adaptive paths
-----------------------
-Some VyOS config paths changed between minor versions (e.g. the NTP
-``allow-client address`` node was removed in 1.5+). ``apply_commands``
-handles this transparently: if a ``set`` command is rejected by the device
-it retries with the last path segment removed (one level up), covering
-the most common schema simplifications. A ``delete`` that fails is
-treated as a no-op (already absent).
"""
from __future__ import absolute_import, division, print_function
@@ -27,30 +18,361 @@ from ansible_collections.vyos.rest.plugins.module_utils.vyos_rest import (
)
-class VyOSModule:
- """Thin wrapper around VyOSRestClient for resource modules.
+# ---------------------------------------------------------------------------
+# Legacy dynamic config utilities (used by Wave 1-3 modules)
+# ---------------------------------------------------------------------------
+
+
+def _kebab_to_snake(s):
+ """Convert kebab-case string to snake_case."""
+ return s.replace("-", "_")
+
+
+def _snake_to_kebab(s):
+ """Convert snake_case string to kebab-case."""
+ return s.replace("_", "-")
+
+
+def normalize(raw):
+ """Recursively normalize an API response dict to snake_case keys."""
+ if isinstance(raw, dict):
+ return {_kebab_to_snake(k): normalize(v) for k, v in raw.items()}
+ if isinstance(raw, list):
+ return [normalize(v) for v in raw]
+ return raw
+
+
+def denormalize_path(path):
+ """Convert a snake_case path list to kebab-case for the API."""
+ return [_snake_to_kebab(p) for p in path]
+
+
+def _diff_value(want_val, have_val, path, cmds, delete_missing):
+ if isinstance(want_val, dict):
+ if not want_val:
+ if have_val is None:
+ cmds.append(("set", denormalize_path(path)))
+ else:
+ have_dict = have_val if isinstance(have_val, dict) else {}
+ _diff_dict(want_val, have_dict, path, cmds, delete_missing)
+ elif isinstance(want_val, list):
+ have_set = set(have_val) if isinstance(have_val, list) else set()
+ for item in want_val:
+ if item not in have_set:
+ cmds.append(("set", denormalize_path(path + [str(item)])))
+ if delete_missing:
+ want_set = set(str(i) for i in want_val)
+ for item in have_val or []:
+ if str(item) not in want_set:
+ cmds.append(("delete", denormalize_path(path + [str(item)])))
+ else:
+ if want_val != have_val:
+ cmds.append(("set", denormalize_path(path + [str(want_val)])))
+
+
+def _diff_dict(want, have, path, cmds, delete_missing):
+ for key, want_val in want.items():
+ _diff_value(want_val, have.get(key), path + [key], cmds, delete_missing)
+ if delete_missing:
+ for key in have:
+ if key not in want:
+ cmds.append(("delete", denormalize_path(path + [key])))
+
+
+def diff_configs(want, have, base_path, delete_missing=False):
+ """Diff two normalized config dicts and return API command tuples.
Args:
- module: AnsibleModule instance.
+ want (dict): Desired configuration (snake_case keys).
+ have (dict): Current configuration (snake_case keys).
+ base_path (list): Base API path for commands.
+ delete_missing (bool): Generate delete commands for keys in
+ ``have`` absent from ``want``.
+
+ Returns:
+ list: Tuples of ``("set", path)`` or ``("delete", path)``.
"""
+ cmds = []
+ _diff_dict(want, have, base_path, cmds, delete_missing)
+ return cmds
+
+
+# ---------------------------------------------------------------------------
+# Generic dict diff engine (used by Wave 4+ modules)
+#
+# Design principles:
+# - want uses snake_case (from YAML/argspec)
+# - have uses kebab-case (from device API)
+# - Conversion between - and _ happens here, once, in the core
+# - Modules only need _BASE path — no key mapping anywhere
+# - want is the reference dataset — drives all operations
+# ---------------------------------------------------------------------------
+
+
+def owned_config(have, argspec):
+ """Filter raw device config to only keys owned by this module.
+
+ Ownership is declared by the module's argspec — the single source
+ of truth for what this module manages. Keys in have not present in
+ argspec (after normalization) are excluded from before/after output.
+
+ Args:
+ have (dict): Raw device config (kebab-case keys).
+ argspec (dict): Module argument_spec dict.
+
+ Returns:
+ dict: Filtered have with only module-owned keys.
+ """
+ owned = set(argspec.keys()) - {"state"}
+ return {k: v for k, v in have.items() if k.replace("-", "_") in owned}
+
+
+# ---------------------------------------------------------------------------
+# Generic, field-name-agnostic helpers shared by every dict_op-based module.
+#
+# Key-case translation (snake_case <-> kebab-case) is dict_op's own job on
+# the want/have side fed to it directly; these helpers only handle what
+# dict_op *can't* infer on its own: Python bool <-> device presence-node,
+# tag-node string/list collapse, argspec-driven type casting for the
+# public have/gathered output, and keeping one module's dict_op calls from
+# reaching into a subtree owned by another module sharing the same root.
+# ---------------------------------------------------------------------------
+
+
+def autoclean(d):
+ """want-side cleanup: drop None/False, True -> presence node ({}),
+ recurse into dicts. Keys are left exactly as given -- dict_op does
+ the snake_case/kebab-case translation itself when it builds paths.
+ """
+ if not isinstance(d, dict):
+ return d
+ result = {}
+ for k, v in d.items():
+ if v is None or v is False:
+ continue
+ if v is True:
+ result[k] = {}
+ elif isinstance(v, dict):
+ cleaned = autoclean(v)
+ if cleaned:
+ result[k] = cleaned
+ else:
+ result[k] = v
+ return result
+
+
+def from_device(d):
+ """have-side inverse of autoclean, for building the public argspec
+ output: kebab-case -> snake_case keys, presence node -> True, recurse.
+ """
+ if not isinstance(d, dict):
+ return d
+ result = {}
+ for k, v in d.items():
+ snake_k = k.replace("-", "_")
+ if isinstance(v, dict):
+ result[snake_k] = True if not v else from_device(v)
+ else:
+ result[snake_k] = v
+ return result
+
+
+def to_tag_dict(value):
+ """Coerce a VyOS tag-node value (bare str/list, or already a dict)
+ to the {key: {}} shape dict_op always expects for a dict-typed key.
+ """
+ if not value:
+ return {}
+ if isinstance(value, dict):
+ return value
+ if isinstance(value, str):
+ return {value: {}}
+ if isinstance(value, list):
+ return {str(v): {} for v in value}
+ return {}
+
+
+def normalize_have(raw, tag_keys=()):
+ """Coerce the given tag-node keys' subtrees so dict_op only ever sees
+ dicts for them -- VyOS's REST API collapses a single-child tag node to
+ a plain string (or a list for multiple), the same class of quirk
+ dict_op itself already corrects for ordinary list leaves. Every other
+ key is already a plain scalar/dict leaf and passes through untouched.
+ """
+ if not raw or not isinstance(raw, dict):
+ return {}
+ result = {}
+ for k, v in raw.items():
+ if isinstance(v, dict):
+ result[k] = normalize_have(v, tag_keys)
+ elif isinstance(v, (list, str)) and v:
+ result[k] = to_tag_dict(v) if k in tag_keys else v
+ else:
+ result[k] = v
+ return result
+
+
+def cast_by_spec(entry, options):
+ """Cast have-side string leaves to their ARGUMENT_SPEC-declared type.
+
+ Entirely argspec-driven -- no per-field name knowledge. This is what
+ lets from_device() stay purely structural (kebab->snake only) while
+ the public have/gathered output still reports ints as ints, without
+ a hand-maintained list of "which leaves happen to be numeric".
+ Handles list-of-dicts (elements="dict") and scalar-element lists
+ (elements="int"/etc, including VyOS's single-value collapse) alike.
+ """
+ if not isinstance(entry, dict):
+ return entry
+ for key, spec in (options or {}).items():
+ if key not in entry or entry[key] is None:
+ continue
+ spec_type = spec.get("type")
+ if spec_type == "int":
+ entry[key] = int(entry[key])
+ elif spec_type == "dict":
+ cast_by_spec(entry[key], spec.get("options"))
+ elif spec_type == "list":
+ val = entry[key]
+ if not isinstance(val, list):
+ val = [val]
+ elements = spec.get("elements")
+ if elements == "dict":
+ for item in val:
+ cast_by_spec(item, spec.get("options"))
+ elif elements == "int":
+ val = [int(v) for v in val]
+ entry[key] = val
+ return entry
+
+
+def scope_to_spec(have, options, exclude=()):
+ """Filter have's keys down to ones a module's own argspec actually
+ declares (in kebab-case form), so dict_op purge/set calls never touch
+ a subtree owned by a different module sharing the same device-tree
+ root (e.g. a neighbor's nested address-family, owned by a sibling
+ *_address_family module, is invisible to a module whose argspec never
+ declared it) -- this protects against any such foreign subtree, present
+ or future, without hardcoding its name.
+ """
+ if not isinstance(have, dict):
+ return {}
+ owned = {k.replace("_", "-") for k in (options or {}) if k not in exclude}
+ return {k: v for k, v in have.items() if k in owned}
+
+
+def dict_op(want, have, base_path, op="set"):
+ """Generic dict diff engine for VyOS REST API.
+
+ Compares want (snake_case, from YAML) against have (kebab-case, from
+ device) and generates API command tuples. All key normalization between
+ snake_case and kebab-case happens here — modules never need to convert.
+
+ Set operations on the two datasets:
+ op="set" want - have present: apply what is missing
+ op="delete" want ∩ have absent: remove what exists
+ op="purge" have - want replaced: remove what is extra
+
+ Args:
+ want (dict): Desired config, snake_case keys (from YAML/argspec).
+ have (dict): Current config, kebab-case keys (raw from device API).
+ base_path (list): Base API path — the only module-specific knowledge.
+ op (str): "set", "delete", or "purge".
+
+ Returns:
+ list: Tuples of ("set", path) or ("delete", path).
+ """
+ cmds = []
+
+ # Index have by normalized key for O(1) lookup.
+ # Preserves original kebab-case key for use in API paths.
+ have_idx = {k.replace("-", "_"): (k, v) for k, v in (have or {}).items()}
+
+ if op == "purge":
+ # have - want: delete have keys not present in want
+ # Scoped naturally by _BASE — only this subtree is in have
+ want_keys = {k.replace("-", "_") for k in (want or {})}
+ for norm_k, (orig_k, have_v) in have_idx.items():
+ if norm_k not in want_keys:
+ cmds.append(("delete", base_path + [orig_k]))
+ elif isinstance(have_v, dict):
+ want_nested = (want or {}).get(norm_k) or (want or {}).get(orig_k) or {}
+ if isinstance(want_nested, dict):
+ cmds += dict_op(want_nested, have_v, base_path + [orig_k], op="purge")
+ elif isinstance(have_v, (list, str)):
+ # List-valued leaf (e.g. a multi-value leafNode): purge
+ # extra have-only items not present in want's list, the
+ # same way op="set"/"delete" already diff list values.
+ # Device may return a single value as a string instead
+ # of a list -- same quirk correction as the list branch
+ # below.
+ want_nested = (want or {}).get(norm_k, (want or {}).get(orig_k))
+ if isinstance(want_nested, list):
+ have_list = [have_v] if isinstance(have_v, str) else have_v
+ want_set = {str(i) for i in want_nested}
+ for item in have_list:
+ if str(item) not in want_set:
+ cmds.append(("delete", base_path + [orig_k, str(item)]))
+ return cmds
+
+ for key, want_val in (want or {}).items():
+ if want_val is None:
+ continue
+
+ # Normalize want key for lookup, get original device key for path
+ norm_key = key.replace("-", "_")
+ orig_key, have_val = have_idx.get(norm_key, (key.replace("_", "-"), None))
+ path = base_path + [orig_key]
+
+ if isinstance(want_val, dict):
+ if not want_val:
+ # Presence node
+ if op == "set" and have_val is None:
+ cmds.append(("set", path))
+ elif op == "delete" and have_val is not None:
+ cmds.append(("delete", path))
+ else:
+ # Recurse — have_val passed raw, conversion happens recursively
+ cmds += dict_op(want_val, have_val or {}, path, op)
+
+ elif isinstance(want_val, list):
+ # Device may return a single value as a string instead of a list
+ if isinstance(have_val, str):
+ have_val = [have_val]
+ have_set = set(str(i) for i in (have_val or []))
+ if op == "set":
+ # want - have: add missing items
+ for item in want_val:
+ if str(item) not in have_set:
+ cmds.append(("set", path + [str(item)]))
+ elif op == "delete":
+ # want ∩ have: remove items that exist
+ for item in want_val:
+ if str(item) in have_set:
+ cmds.append(("delete", path + [str(item)]))
+
+ else:
+ # Scalar leaf
+ have_str = str(have_val) if have_val is not None else ""
+ if op == "set" and str(want_val) != have_str:
+ cmds.append(("set", path + [str(want_val)]))
+ elif op == "delete" and have_val is not None:
+ cmds.append(("delete", path))
+
+ return cmds
+
+
+class VyOSModule:
+ """Thin wrapper around VyOSRestClient for resource modules."""
def __init__(self, module):
self._module = module
self._client = VyOSRestClient(module)
- # ------------------------------------------------------------------
- # Read
- # ------------------------------------------------------------------
-
def get_config(self, path=None):
"""Retrieve the configuration subtree at *path*.
- Args:
- path (list, optional): Config path tokens. ``[]`` or ``None``
- returns the entire running configuration.
-
- Returns:
- dict: Configuration subtree, or ``{}`` if the path doesn't exist.
+ Returns raw device dict with kebab-case keys.
"""
try:
result = self._client.retrieve_show_config(path or [])
@@ -58,63 +380,6 @@ class VyOSModule:
except VyOSRestError:
return {}
- # ------------------------------------------------------------------
- # Write
- # ------------------------------------------------------------------
-
- # def apply_commands(self, commands):
- # """Execute a list of ``(op, path)`` command tuples against the device.
-
- # Args:
- # commands (list): Each item is a 2-tuple ``(op, path)`` where:
- # - *op* is ``"set"`` or ``"delete"``
- # - *path* is a list of config path tokens
-
- # Optionally a 3-tuple ``(op, path, value)`` for leaf nodes
- # that carry a value.
-
- # Returns:
- # list: Results from each command (dicts with ``op`` and ``path``).
-
- # Raises:
- # VyOSRestError: If a command fails and cannot be recovered.
-
- # Notes:
- # **Version-adaptive set:** if a ``set`` is rejected by the device,
- # the method retries once with the last path segment removed.
- # This handles schema simplifications between VyOS releases
- # (e.g. ``allow-client address <prefix>`` -> ``allow-client <prefix>``
- # in VyOS 1.5+).
-
- # **Idempotent delete:** ``delete`` failures are silently ignored
- # (the path is already absent).
- # """
- # results = []
-
- # for cmd in commands:
- # # Support two command formats:
- # # Tuple: ("set", ["path", "tokens"]) or ("set", ["path"], "value")
- # # Dict: {"op": "set", "path": ["path", "tokens"]}
- # if isinstance(cmd, dict):
- # op = cmd["op"]
- # path = list(cmd["path"])
- # value = cmd.get("value")
- # else:
- # op = cmd[0]
- # path = list(cmd[1])
- # value = cmd[2] if len(cmd) > 2 else None
-
- # if op == "set":
- # results.append(self._apply_set(path, value))
- # elif op == "delete":
- # results.append(self._apply_delete(path))
- # else:
- # self._module.fail_json(
- # msg="Unknown command op '{op}' in apply_commands".format(op=op),
- # )
-
- # return results
-
def apply_commands(self, commands):
if not commands:
return []
@@ -138,10 +403,6 @@ class VyOSModule:
self._client.configure_set(path, value)
return {"op": "set", "path": path, "status": "ok"}
except VyOSRestError as exc:
- # Retry without the second-to-last segment (version-adaptive).
- # Example: ["service","ntp","allow-client","address","10.0.0.0/24"]
- # -> ["service","ntp","allow-client","10.0.0.0/24"]
- # Only attempt if path is long enough to have an intermediate node.
if len(path) >= 3:
short_path = path[:-2] + [path[-1]]
try:
@@ -161,20 +422,18 @@ class VyOSModule:
except VyOSRestError:
return {"op": "delete", "path": path, "status": "noop"}
- # ------------------------------------------------------------------
- # Persist
- # ------------------------------------------------------------------
-
- def save_config(self, file_path=None):
- """Save the running configuration to disk.
-
- Args:
- file_path (str, optional): Destination path on the device.
- Defaults to ``/config/config.boot``.
+ def show(self, path):
+ """Run an operational show command via the /show endpoint.
- Returns:
- bool: ``True`` if saved successfully, ``False`` otherwise.
+ Raises VyOSRestError on failure; callers that need per-command
+ error reporting (e.g. vyos_command) rely on this propagating
+ rather than being indistinguishable from a valid empty response.
"""
+ result = self._client.show(path)
+ return result.get("data") or ""
+
+ def save_config(self, file_path=None):
+ """Save the running configuration to disk."""
try:
self._client.config_file_save(file_path)
return True
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()
diff --git a/tests/integration/targets/vyos_static_routes/aliases b/tests/integration/targets/vyos_static_routes/aliases
new file mode 100644
index 0000000..cc0afef
--- /dev/null
+++ b/tests/integration/targets/vyos_static_routes/aliases
@@ -0,0 +1 @@
+network/vyos
diff --git a/tests/integration/targets/vyos_static_routes/defaults/main.yaml b/tests/integration/targets/vyos_static_routes/defaults/main.yaml
new file mode 100644
index 0000000..164afea
--- /dev/null
+++ b/tests/integration/targets/vyos_static_routes/defaults/main.yaml
@@ -0,0 +1,3 @@
+---
+testcase: "[^_].*"
+test_items: []
diff --git a/tests/integration/targets/vyos_static_routes/tasks/httpapi.yaml b/tests/integration/targets/vyos_static_routes/tasks/httpapi.yaml
new file mode 100644
index 0000000..4147e6d
--- /dev/null
+++ b/tests/integration/targets/vyos_static_routes/tasks/httpapi.yaml
@@ -0,0 +1,21 @@
+---
+- name: Collect all httpapi test cases
+ ansible.builtin.find:
+ paths: "{{ role_path }}/tests/httpapi"
+ patterns: "{{ testcase }}.yaml"
+ use_regex: true
+ register: test_cases
+ delegate_to: localhost
+
+- name: Set test_items
+ ansible.builtin.set_fact:
+ test_items: "{{ test_cases.files | map(attribute='path') | list }}"
+
+- name: Run test case (connection=httpapi)
+ ansible.builtin.include_tasks: "{{ test_case_to_run }}"
+ vars:
+ ansible_connection: ansible.netcommon.httpapi
+ ansible_network_os: vyos.rest.vyos
+ with_items: "{{ test_items }}"
+ loop_control:
+ loop_var: test_case_to_run
diff --git a/tests/integration/targets/vyos_static_routes/tasks/main.yaml b/tests/integration/targets/vyos_static_routes/tasks/main.yaml
new file mode 100644
index 0000000..b1f6193
--- /dev/null
+++ b/tests/integration/targets/vyos_static_routes/tasks/main.yaml
@@ -0,0 +1,5 @@
+---
+- name: Run httpapi tests
+ ansible.builtin.include_tasks: httpapi.yaml
+ tags:
+ - httpapi
diff --git a/tests/integration/targets/vyos_static_routes/tests/httpapi.yaml b/tests/integration/targets/vyos_static_routes/tests/httpapi.yaml
new file mode 100644
index 0000000..4147e6d
--- /dev/null
+++ b/tests/integration/targets/vyos_static_routes/tests/httpapi.yaml
@@ -0,0 +1,21 @@
+---
+- name: Collect all httpapi test cases
+ ansible.builtin.find:
+ paths: "{{ role_path }}/tests/httpapi"
+ patterns: "{{ testcase }}.yaml"
+ use_regex: true
+ register: test_cases
+ delegate_to: localhost
+
+- name: Set test_items
+ ansible.builtin.set_fact:
+ test_items: "{{ test_cases.files | map(attribute='path') | list }}"
+
+- name: Run test case (connection=httpapi)
+ ansible.builtin.include_tasks: "{{ test_case_to_run }}"
+ vars:
+ ansible_connection: ansible.netcommon.httpapi
+ ansible_network_os: vyos.rest.vyos
+ with_items: "{{ test_items }}"
+ loop_control:
+ loop_var: test_case_to_run
diff --git a/tests/integration/targets/vyos_static_routes/tests/httpapi/_populate_config.yaml b/tests/integration/targets/vyos_static_routes/tests/httpapi/_populate_config.yaml
new file mode 100644
index 0000000..e257075
--- /dev/null
+++ b/tests/integration/targets/vyos_static_routes/tests/httpapi/_populate_config.yaml
@@ -0,0 +1,19 @@
+---
+- name: Populate static_routes config for testing
+ 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
+ ignore_errors: true
diff --git a/tests/integration/targets/vyos_static_routes/tests/httpapi/_remove_config.yaml b/tests/integration/targets/vyos_static_routes/tests/httpapi/_remove_config.yaml
new file mode 100644
index 0000000..dd02da6
--- /dev/null
+++ b/tests/integration/targets/vyos_static_routes/tests/httpapi/_remove_config.yaml
@@ -0,0 +1,5 @@
+---
+- name: Remove pre-existing static_routes config
+ vyos.rest.vyos_static_routes:
+ state: deleted
+ ignore_errors: true
diff --git a/tests/integration/targets/vyos_static_routes/tests/httpapi/deleted.yaml b/tests/integration/targets/vyos_static_routes/tests/httpapi/deleted.yaml
new file mode 100644
index 0000000..b02e256
--- /dev/null
+++ b/tests/integration/targets/vyos_static_routes/tests/httpapi/deleted.yaml
@@ -0,0 +1,28 @@
+---
+- debug:
+ msg: START vyos_static_routes deleted integration tests on connection={{ ansible_connection }}
+
+- include_tasks: _remove_config.yaml
+- include_tasks: _populate_config.yaml
+
+- block:
+ - name: Delete all static_routes configuration
+ register: result
+ vyos.rest.vyos_static_routes: &id001
+ state: deleted
+
+ - assert:
+ that:
+ - result.changed == true
+
+ - name: Delete static_routes configuration (IDEMPOTENT)
+ register: result
+ vyos.rest.vyos_static_routes: *id001
+
+ - name: Assert idempotent
+ assert:
+ that:
+ - result.changed == false
+
+ always:
+ - include_tasks: _remove_config.yaml
diff --git a/tests/integration/targets/vyos_static_routes/tests/httpapi/gathered.yaml b/tests/integration/targets/vyos_static_routes/tests/httpapi/gathered.yaml
new file mode 100644
index 0000000..c6d5e59
--- /dev/null
+++ b/tests/integration/targets/vyos_static_routes/tests/httpapi/gathered.yaml
@@ -0,0 +1,20 @@
+---
+- debug:
+ msg: START vyos_static_routes gathered integration tests on connection={{ ansible_connection }}
+
+- include_tasks: _remove_config.yaml
+- include_tasks: _populate_config.yaml
+
+- block:
+ - name: Gather static_routes configuration
+ register: result
+ vyos.rest.vyos_static_routes:
+ state: gathered
+
+ - assert:
+ that:
+ - result.changed == false
+ - result.gathered | selectattr('afi', 'eq', 'ipv4') | list | length > 0
+
+ always:
+ - include_tasks: _remove_config.yaml
diff --git a/tests/integration/targets/vyos_static_routes/tests/httpapi/merged.yaml b/tests/integration/targets/vyos_static_routes/tests/httpapi/merged.yaml
new file mode 100644
index 0000000..6cf6a01
--- /dev/null
+++ b/tests/integration/targets/vyos_static_routes/tests/httpapi/merged.yaml
@@ -0,0 +1,37 @@
+---
+- debug:
+ msg: START vyos_static_routes merged integration tests on connection={{ ansible_connection }}
+
+- include_tasks: _remove_config.yaml
+
+- block:
+ - name: Merge static_routes configuration
+ register: result
+ vyos.rest.vyos_static_routes: &id001
+ 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
+ state: merged
+
+ - assert:
+ that:
+ - result.changed == true
+
+ - name: Merge static_routes configuration (IDEMPOTENT)
+ register: result
+ vyos.rest.vyos_static_routes: *id001
+
+ - name: Assert idempotent
+ assert:
+ that:
+ - result.changed == false
+ - result.commands == []
+
+ always:
+ - include_tasks: _remove_config.yaml
diff --git a/tests/integration/targets/vyos_static_routes/tests/httpapi/overridden.yaml b/tests/integration/targets/vyos_static_routes/tests/httpapi/overridden.yaml
new file mode 100644
index 0000000..e716d75
--- /dev/null
+++ b/tests/integration/targets/vyos_static_routes/tests/httpapi/overridden.yaml
@@ -0,0 +1,34 @@
+---
+- debug:
+ msg: START vyos_static_routes overridden integration tests on connection={{ ansible_connection }}
+
+- include_tasks: _remove_config.yaml
+- include_tasks: _populate_config.yaml
+
+- block:
+ - name: Override static_routes configuration
+ register: result
+ vyos.rest.vyos_static_routes: &id001
+ config:
+ - afi: ipv4
+ routes:
+ - dest: 192.0.2.0/24
+ next_hops:
+ - forward_router_address: 10.0.0.2
+ state: overridden
+
+ - assert:
+ that:
+ - result.changed == true
+
+ - name: Override static_routes configuration (IDEMPOTENT)
+ register: result
+ vyos.rest.vyos_static_routes: *id001
+
+ - assert:
+ that:
+ - result.changed == false
+ - result.commands == []
+
+ always:
+ - include_tasks: _remove_config.yaml
diff --git a/tests/integration/targets/vyos_static_routes/tests/httpapi/replaced.yaml b/tests/integration/targets/vyos_static_routes/tests/httpapi/replaced.yaml
new file mode 100644
index 0000000..a27e637
--- /dev/null
+++ b/tests/integration/targets/vyos_static_routes/tests/httpapi/replaced.yaml
@@ -0,0 +1,35 @@
+---
+- debug:
+ msg: START vyos_static_routes replaced integration tests on connection={{ ansible_connection }}
+
+- include_tasks: _remove_config.yaml
+- include_tasks: _populate_config.yaml
+
+- block:
+ - name: Replace static_routes configuration
+ register: result
+ vyos.rest.vyos_static_routes: &id001
+ config:
+ - afi: ipv4
+ routes:
+ - dest: 192.0.2.0/24
+ next_hops:
+ - forward_router_address: 10.0.0.2
+ state: replaced
+
+ - assert:
+ that:
+ - result.changed == true
+
+ - name: Replace static_routes configuration (IDEMPOTENT)
+ register: result
+ vyos.rest.vyos_static_routes: *id001
+
+ - name: Assert idempotent
+ assert:
+ that:
+ - result.changed == false
+ - result.commands == []
+
+ always:
+ - include_tasks: _remove_config.yaml
diff --git a/tests/integration/targets/vyos_static_routes/tests/httpapi/rtt.yaml b/tests/integration/targets/vyos_static_routes/tests/httpapi/rtt.yaml
new file mode 100644
index 0000000..e0c76f9
--- /dev/null
+++ b/tests/integration/targets/vyos_static_routes/tests/httpapi/rtt.yaml
@@ -0,0 +1,71 @@
+---
+- debug:
+ msg: START vyos_static_routes round trip integration tests on connection={{ ansible_connection }}
+
+- include_tasks: _remove_config.yaml
+
+- block:
+ - name: RTT - Apply base configuration
+ 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
+ state: merged
+
+ - name: RTT - Gather configuration
+ register: gathered
+ vyos.rest.vyos_static_routes:
+ state: gathered
+
+ - name: RTT - Assert gathered matches applied
+ vars:
+ ipv4_routes: >-
+ {{ gathered.gathered
+ | selectattr('afi', 'eq', 'ipv4')
+ | map(attribute='routes')
+ | first }}
+ assert:
+ that:
+ - gathered.gathered | selectattr('afi', 'eq', 'ipv4') | list | length == 1
+ - ipv4_routes | selectattr('dest', 'eq', '192.0.2.0/24') | list | length == 1
+
+ - name: RTT - Modify configuration
+ vyos.rest.vyos_static_routes:
+ config:
+ - afi: ipv4
+ routes:
+ - dest: 192.0.2.0/24
+ next_hops:
+ - forward_router_address: 10.0.0.2
+ state: replaced
+
+ - name: RTT - Gather modified configuration
+ register: gathered2
+ vyos.rest.vyos_static_routes:
+ state: gathered
+
+ - name: RTT - Assert modification applied correctly
+ vars:
+ ipv4_routes2: >-
+ {{ gathered2.gathered
+ | selectattr('afi', 'eq', 'ipv4')
+ | map(attribute='routes')
+ | first }}
+ dest_hops: >-
+ {{ ipv4_routes2
+ | selectattr('dest', 'eq', '192.0.2.0/24')
+ | map(attribute='next_hops')
+ | first }}
+ assert:
+ that:
+ - gathered2.gathered | selectattr('afi', 'eq', 'ipv4') | list | length == 1
+ - dest_hops | selectattr('forward_router_address', 'eq', '10.0.0.2') | list | length == 1
+
+ always:
+ - include_tasks: _remove_config.yaml
diff --git a/tests/integration/targets/vyos_static_routes/vars/main.yaml b/tests/integration/targets/vyos_static_routes/vars/main.yaml
new file mode 100644
index 0000000..ed97d53
--- /dev/null
+++ b/tests/integration/targets/vyos_static_routes/vars/main.yaml
@@ -0,0 +1 @@
+---
diff --git a/tests/unit/fixtures/static_routes_running.json b/tests/unit/fixtures/static_routes_running.json
new file mode 100644
index 0000000..46cf700
--- /dev/null
+++ b/tests/unit/fixtures/static_routes_running.json
@@ -0,0 +1,21 @@
+{
+ "route": {
+ "192.0.2.0/24": {
+ "next-hop": {
+ "10.0.0.1": {}
+ }
+ },
+ "203.0.113.0/24": {
+ "blackhole": {
+ "distance": "200"
+ }
+ }
+ },
+ "route6": {
+ "2001:db8::/32": {
+ "next-hop": {
+ "2001:db8::1": {}
+ }
+ }
+ }
+}
diff --git a/tests/unit/modules/test_vyos_static_routes.py b/tests/unit/modules/test_vyos_static_routes.py
new file mode 100644
index 0000000..1a50da9
--- /dev/null
+++ b/tests/unit/modules/test_vyos_static_routes.py
@@ -0,0 +1,365 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import, division, print_function
+
+
+__metaclass__ = type
+
+import unittest
+
+from unittest.mock import MagicMock
+
+from ansible_collections.vyos.rest.plugins.modules.vyos_static_routes import (
+ _ROUTE_OPTIONS,
+ ARGUMENT_SPEC,
+ _derive_key_field,
+ _device_to_argspec,
+ _keyed_list_from_device,
+ _keyed_list_to_device,
+ _next_hop_entry_from_device,
+ _next_hop_entry_to_device,
+ _route_entry_from_device,
+ _route_entry_to_device,
+ _want_to_device,
+ build_commands,
+ cast_by_spec,
+ get_running_config,
+)
+
+from .base import load_fixture
+
+
+_BASE = ["protocols", "static"]
+
+
+class VyOSModuleTestCase(unittest.TestCase):
+ def setUp(self):
+ self.mock_vyos = MagicMock()
+ self.fixture = load_fixture("static_routes_running.json")
+ self.mock_vyos.get_config = MagicMock(return_value=self.fixture)
+
+
+class TestGetRunningConfig(VyOSModuleTestCase):
+ def test_returns_config_directly(self):
+ result = get_running_config(self.mock_vyos)
+ self.assertIn("route", result)
+ self.assertIn("route6", result)
+
+ def test_empty_config(self):
+ self.mock_vyos.get_config = MagicMock(return_value=None)
+ self.assertEqual(get_running_config(self.mock_vyos), {})
+
+
+class TestDeriveKeyField(unittest.TestCase):
+ def test_derives_dest_key(self):
+ route_opts = ARGUMENT_SPEC["config"]["options"]["routes"]["options"]
+ self.assertEqual(_derive_key_field(route_opts), "dest")
+
+ def test_derives_forward_router_address_key(self):
+ nh_opts = ARGUMENT_SPEC["config"]["options"]["routes"]["options"]["next_hops"]["options"]
+ self.assertEqual(_derive_key_field(nh_opts), "forward_router_address")
+
+ def test_raises_if_none_required(self):
+ with self.assertRaises(ValueError):
+ _derive_key_field({"a": {"type": "str"}})
+
+ def test_raises_if_more_than_one_required(self):
+ with self.assertRaises(ValueError):
+ _derive_key_field({"a": {"required": True}, "b": {"required": True}})
+
+
+class TestKeyedListHelper(unittest.TestCase):
+ def test_to_device_basic(self):
+ result = _keyed_list_to_device(
+ [{"dest": "192.0.2.0/24", "blackhole_config": {"distance": 200}}],
+ "dest",
+ )
+ self.assertEqual(result, {"192.0.2.0/24": {"blackhole_config": {"distance": 200}}})
+
+ def test_from_device_basic(self):
+ result = _keyed_list_from_device({"192.0.2.0/24": {"a": 1}}, "dest")
+ self.assertEqual(result, [{"dest": "192.0.2.0/24", "a": 1}])
+
+ def test_empty(self):
+ self.assertEqual(_keyed_list_to_device([], "dest"), {})
+ self.assertEqual(_keyed_list_from_device({}, "dest"), [])
+
+
+class TestBlackholeNoTypeField(unittest.TestCase):
+ """Regression test for the confirmed hallucinated field: the
+ original module's blackhole_config.type does not correspond to
+ anything on the device -- confirmed against vyos-1x, the
+ "blackhole" node has only "distance" (and "tag", out of scope).
+ The field has been removed entirely."""
+
+ def test_type_not_in_argspec(self):
+ bh_opts = ARGUMENT_SPEC["config"]["options"]["routes"]["options"]["blackhole_config"]
+ self.assertNotIn("type", bh_opts["options"])
+ self.assertEqual(set(bh_opts["options"].keys()), {"distance"})
+
+
+class TestRouteEntryToDeviceFromDevice(unittest.TestCase):
+ def test_blackhole_presence_only(self):
+ """An empty blackhole_config (no distance) still creates a
+ bare presence node -- achieving the same "just blackhole, no
+ distance" result the original's bogus "type" field was used
+ for, without needing any sentinel field at all."""
+ result = _route_entry_to_device({"blackhole_config": {}})
+ self.assertEqual(result, {"blackhole": {}})
+
+ def test_blackhole_with_distance(self):
+ result = _route_entry_to_device({"blackhole_config": {"distance": 200}})
+ self.assertEqual(result, {"blackhole": {"distance": 200}})
+
+ def test_next_hops_keyed_by_address(self):
+ result = _route_entry_to_device(
+ {"next_hops": [{"forward_router_address": "10.0.0.1", "admin_distance": 50}]},
+ )
+ self.assertEqual(result, {"next-hop": {"10.0.0.1": {"distance": 50}}})
+
+ def test_disabled_next_hop(self):
+ result = _route_entry_to_device(
+ {"next_hops": [{"forward_router_address": "10.0.0.1", "enabled": False}]},
+ )
+ self.assertEqual(result["next-hop"]["10.0.0.1"], {"disable": {}})
+
+ def test_enabled_true_produces_no_disable_leaf(self):
+ result = _route_entry_to_device(
+ {"next_hops": [{"forward_router_address": "10.0.0.1", "enabled": True}]},
+ )
+ self.assertNotIn("disable", result["next-hop"]["10.0.0.1"])
+
+ def test_from_device_blackhole(self):
+ """from_device stays purely structural (kebab->snake only);
+ int-casting is cast_by_spec's responsibility, applied
+ downstream in main() -- confirmed separately below."""
+ entry = _route_entry_from_device({"blackhole": {"distance": "200"}})
+ self.assertEqual(entry["blackhole_config"]["distance"], "200")
+
+ def test_from_device_next_hop_disabled(self):
+ entry = _route_entry_from_device({"next-hop": {"10.0.0.1": {"disable": {}}}})
+ self.assertEqual(entry["next_hops"][0]["enabled"], False)
+
+ def test_from_device_next_hop_enabled_omitted(self):
+ """Confirmed device behavior: an enabled next-hop has no
+ "disable" leaf at all -- "enabled" should not appear in the
+ parsed entry either, matching the argspec default."""
+ entry = _route_entry_from_device({"next-hop": {"10.0.0.1": {}}})
+ self.assertNotIn("enabled", entry["next_hops"][0])
+
+ def test_empty(self):
+ self.assertEqual(_route_entry_to_device({}), {})
+ self.assertEqual(_route_entry_from_device({}), {})
+
+
+class TestNextHopEntryToDeviceFromDevice(unittest.TestCase):
+ def test_interface(self):
+ result = _next_hop_entry_to_device({"interface": "eth0"})
+ self.assertEqual(result, {"interface": "eth0"})
+
+ def test_from_device_interface(self):
+ entry = _next_hop_entry_from_device({"interface": "eth0"})
+ self.assertEqual(entry, {"interface": "eth0"})
+
+ def test_from_device_distance_cast_to_int(self):
+ entry = _next_hop_entry_from_device({"distance": "50"})
+ self.assertEqual(entry["admin_distance"], 50)
+ self.assertIsInstance(entry["admin_distance"], int)
+
+
+class TestWantToDevice(unittest.TestCase):
+ def test_empty(self):
+ self.assertEqual(_want_to_device([]), {})
+ self.assertEqual(_want_to_device(None), {})
+
+ def test_afi_without_routes_omitted(self):
+ self.assertEqual(_want_to_device([{"afi": "ipv4"}]), {})
+
+ def test_keyed_by_route_key(self):
+ config = [{"afi": "ipv4", "routes": [{"dest": "192.0.2.0/24"}]}]
+ result = _want_to_device(config)
+ self.assertIn("192.0.2.0/24", result["route"])
+
+ def test_ipv6_uses_route6_key(self):
+ config = [{"afi": "ipv6", "routes": [{"dest": "2001:db8::/32"}]}]
+ result = _want_to_device(config)
+ self.assertIn("route6", result)
+
+
+class TestDeviceToArgspecFixture(VyOSModuleTestCase):
+ def test_both_afis_parsed(self):
+ raw = get_running_config(self.mock_vyos)
+ result = _device_to_argspec(raw)
+ afis = [e["afi"] for e in result]
+ self.assertIn("ipv4", afis)
+ self.assertIn("ipv6", afis)
+
+ def test_blackhole_route_parsed_with_casting(self):
+ """from_device alone leaves distance as the raw device string;
+ cast_by_spec (applied downstream in main(), confirmed here
+ directly) is what casts it to int, since cast_by_spec recurses
+ into type="dict" suboptions like blackhole_config."""
+ raw = get_running_config(self.mock_vyos)
+ result = _device_to_argspec(raw)
+ ipv4_routes = next(e for e in result if e["afi"] == "ipv4")["routes"]
+ bh_route = next(r for r in ipv4_routes if r["dest"] == "203.0.113.0/24")
+ self.assertEqual(bh_route["blackhole_config"]["distance"], "200")
+ cast_by_spec(bh_route, _ROUTE_OPTIONS)
+ self.assertEqual(bh_route["blackhole_config"]["distance"], 200)
+
+ def test_next_hop_route_parsed(self):
+ raw = get_running_config(self.mock_vyos)
+ result = _device_to_argspec(raw)
+ ipv4_routes = next(e for e in result if e["afi"] == "ipv4")["routes"]
+ nh_route = next(r for r in ipv4_routes if r["dest"] == "192.0.2.0/24")
+ self.assertEqual(nh_route["next_hops"][0]["forward_router_address"], "10.0.0.1")
+
+ def test_empty_config(self):
+ self.assertEqual(_device_to_argspec({}), [])
+ self.assertEqual(_device_to_argspec(None), [])
+
+
+class TestBuildCommands(VyOSModuleTestCase):
+ def setUp(self):
+ super().setUp()
+ self.raw = get_running_config(self.mock_vyos)
+
+ def test_merged_idempotent_against_own_fixture(self):
+ have = _device_to_argspec(self.raw)
+ self.assertEqual(build_commands(have, self.raw, "merged"), [])
+
+ def test_replaced_idempotent_against_own_fixture(self):
+ have = _device_to_argspec(self.raw)
+ self.assertEqual(build_commands(have, self.raw, "replaced"), [])
+
+ def test_overridden_idempotent_against_own_fixture(self):
+ have = _device_to_argspec(self.raw)
+ self.assertEqual(build_commands(have, self.raw, "overridden"), [])
+
+ def test_clear_omitted_next_hop_attribute_on_replaced(self):
+ """The primary confirmed bug fix from the PR review: the
+ original _route_cmds only emitted commands for setting values,
+ never for clearing an omitted attribute back to default, and
+ "replaced" state's own change-detection missed this entirely
+ since it only inspected generated set-commands."""
+ raw_have = {"route": {"192.0.2.0/24": {"next-hop": {"10.0.0.1": {"distance": "50"}}}}}
+ config = [
+ {
+ "afi": "ipv4",
+ "routes": [
+ {
+ "dest": "192.0.2.0/24",
+ "next_hops": [
+ {"forward_router_address": "10.0.0.1"},
+ ],
+ },
+ ],
+ },
+ ]
+ cmds = build_commands(config, raw_have, "replaced")
+ expected = ("delete", _BASE + ["route", "192.0.2.0/24", "next-hop", "10.0.0.1", "distance"])
+ self.assertIn(expected, cmds)
+
+ def test_replaced_scoped_to_named_route_only(self):
+ raw_have = {
+ "route": {
+ "192.0.2.0/24": {"next-hop": {"10.0.0.1": {}}},
+ "203.0.113.0/24": {"blackhole": {"distance": "200"}},
+ },
+ }
+ config = [
+ {
+ "afi": "ipv4",
+ "routes": [
+ {
+ "dest": "192.0.2.0/24",
+ "next_hops": [
+ {"forward_router_address": "10.0.0.1"},
+ ],
+ },
+ ],
+ },
+ ]
+ cmds = build_commands(config, raw_have, "replaced")
+ self.assertEqual(cmds, [])
+ self.assertFalse(any("203.0.113.0/24" in str(c) for c in cmds))
+
+ def test_overridden_deletes_omitted_route(self):
+ raw_have = {
+ "route": {
+ "192.0.2.0/24": {"next-hop": {"10.0.0.1": {}}},
+ "203.0.113.0/24": {"blackhole": {"distance": "200"}},
+ },
+ }
+ config = [
+ {
+ "afi": "ipv4",
+ "routes": [
+ {
+ "dest": "192.0.2.0/24",
+ "next_hops": [
+ {"forward_router_address": "10.0.0.1"},
+ ],
+ },
+ ],
+ },
+ ]
+ cmds = build_commands(config, raw_have, "overridden")
+ self.assertIn(("delete", _BASE + ["route", "203.0.113.0/24"]), cmds)
+
+ def test_deleted_named_route(self):
+ cmds = build_commands(
+ [{"afi": "ipv4", "routes": [{"dest": "192.0.2.0/24"}]}],
+ self.raw,
+ "deleted",
+ )
+ self.assertEqual(cmds, [("delete", _BASE + ["route", "192.0.2.0/24"])])
+
+ def test_deleted_named_afi_no_routes(self):
+ cmds = build_commands([{"afi": "ipv4"}], self.raw, "deleted")
+ self.assertEqual(cmds, [("delete", _BASE + ["route"])])
+
+ def test_deleted_no_config_removes_all(self):
+ cmds = build_commands([], self.raw, "deleted")
+ self.assertEqual(cmds, [("delete", _BASE)])
+
+ def test_deleted_named_nonexistent_is_noop(self):
+ cmds = build_commands(
+ [{"afi": "ipv4", "routes": [{"dest": "198.51.100.0/24"}]}],
+ self.raw,
+ "deleted",
+ )
+ self.assertEqual(cmds, [])
+
+ def test_collapsed_bare_route_no_blackhole_or_next_hop(self):
+ raw_have = {"route": {"192.0.2.0/24": {}}}
+ config = [{"afi": "ipv4", "routes": [{"dest": "192.0.2.0/24"}]}]
+ self.assertEqual(build_commands(config, raw_have, "merged"), [])
+
+ def test_merged_new_blackhole_route(self):
+ config = [{"afi": "ipv4", "routes": [{"dest": "198.51.100.0/24", "blackhole_config": {}}]}]
+ cmds = build_commands(config, {}, "merged")
+ self.assertIn(("set", _BASE + ["route", "198.51.100.0/24", "blackhole"]), cmds)
+
+ def test_merged_new_disabled_next_hop(self):
+ config = [
+ {
+ "afi": "ipv4",
+ "routes": [
+ {
+ "dest": "198.51.100.0/24",
+ "next_hops": [
+ {"forward_router_address": "10.0.0.9", "enabled": False},
+ ],
+ },
+ ],
+ },
+ ]
+ cmds = build_commands(config, {}, "merged")
+ self.assertIn(
+ ("set", _BASE + ["route", "198.51.100.0/24", "next-hop", "10.0.0.9", "disable"]),
+ cmds,
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()