diff options
| author | omnom62 <75066712+omnom62@users.noreply.github.com> | 2026-08-21 23:54:29 +1000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-08-21 08:54:29 -0500 |
| commit | 885b9462480712210ddeea9ca4a4b7a52e9ef587 (patch) | |
| tree | a994ef61f704d41bdab770c86a21481a5d95d2b1 | |
| parent | 6dc9d721513dca5a573c7ee01b74d703ff41f95f (diff) | |
| download | rest.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
21 files changed, 1947 insertions, 95 deletions
@@ -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> ←</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> ←</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() |
