diff options
| author | omnom62 <75066712+omnom62@users.noreply.github.com> | 2026-09-28 22:34:13 +1000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-28 13:34:13 +0100 |
| commit | 7cfca28e5857b480920c22ae12557f55ca2a8a19 (patch) | |
| tree | 8e3bfc20dc621371af85b9d62e89b4f3e1838c01 | |
| parent | 916b2cc86b29aabe3778be8fa88393f6a5324832 (diff) | |
| download | rest.vyos-7cfca28e5857b480920c22ae12557f55ca2a8a19.tar.gz rest.vyos-7cfca28e5857b480920c22ae12557f55ca2a8a19.zip | |
* T8989: vyos_vrf module
* T8989: vyos_vrf module
* T8989: vyos_vrf module unit tests
* T8989: vyos_vrf module integration tests
* T8989: vyos_vrf module integration tests
* T8989: vyos_vrf module changelog
* T8989: fix pylint disallowed-name issues
* T8989: fix pylint disallowed-name issues
* T8989: fix lint issues for vrf
* T8989: vrf AI comment fixed
* T8989: vrf AI comment fixed
---------
Co-authored-by: Daniil Baturin <daniil@vyos.io>
21 files changed, 2821 insertions, 0 deletions
@@ -106,6 +106,7 @@ Name | Description [vyos.rest.vyos_system](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_system_module.rst)|Manage system settings on VyOS devices using REST API [vyos.rest.vyos_user](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_user_module.rst)|Manage user accounts on VyOS devices using REST API [vyos.rest.vyos_vlan](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_vlan_module.rst)|Manage VLAN (vif) configuration on VyOS devices using REST API +[vyos.rest.vyos_vrf](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_vrf_module.rst)|Manage VRF configuration on VyOS devices using REST API <!--end collection content--> diff --git a/changelogs/fragments/t8989_vrf.yml b/changelogs/fragments/t8989_vrf.yml new file mode 100644 index 0000000..613bebd --- /dev/null +++ b/changelogs/fragments/t8989_vrf.yml @@ -0,0 +1,3 @@ +--- +minor_changes: + - vyos_vrf - Add new module and tests. diff --git a/docs/vyos.rest.vyos_vrf_module.rst b/docs/vyos.rest.vyos_vrf_module.rst new file mode 100644 index 0000000..92a7292 --- /dev/null +++ b/docs/vyos.rest.vyos_vrf_module.rst @@ -0,0 +1,861 @@ +.. _vyos.rest.vyos_vrf_module: + + +****************** +vyos.rest.vyos_vrf +****************** + +**Manage VRF configuration on VyOS devices using REST API** + + +Version added: 1.0.0 + +.. contents:: + :local: + :depth: 1 + + +Synopsis +-------- +- Manages Virtual Routing and Forwarding (VRF) instances on VyOS devices via the REST API. +- Supports merged, replaced, overridden, deleted, and gathered states. +- Protocol configuration within VRFs (BGP, OSPFv2, static routes) is managed inline with focused scope (core fields only). + + + + +Parameters +---------- + +.. raw:: html + + <table border=0 cellpadding=0 class="documentation-table"> + <tr> + <th colspan="6">Parameter</th> + <th>Choices/<font color="blue">Defaults</font></th> + <th width="100%">Comments</th> + </tr> + <tr> + <td colspan="6"> + <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">dictionary</span> + </div> + </td> + <td> + </td> + <td> + <div>VRF configuration.</div> + </td> + </tr> + <tr> + <td class="elbow-placeholder"></td> + <td colspan="5"> + <div class="ansibleOptionAnchor" id="parameter-"></div> + <b>bind_to_all</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>yes</li> + </ul> + </td> + <td> + <div>Enable binding services to all VRFs.</div> + <div>Whether omitting this option preserves the current device setting depends on <code>state</code>. With <code>merged</code>, and with <code>deleted</code> when specific <code>instances</code> are named, omission leaves it untouched. With <code>replaced</code> or <code>overridden</code>, omission deletes an existing setting, since those states replace everything not explicitly present in <code>config</code>. With <code>deleted</code> and no <code>instances</code> given, the entire VRF configuration (including this setting) is removed.</div> + <div>Only set this explicitly (<code>true</code> or <code>false</code>) when you want this module to manage it under <code>merged</code> or <code>deleted</code> with named instances.</div> + </td> + </tr> + <tr> + <td class="elbow-placeholder"></td> + <td colspan="5"> + <div class="ansibleOptionAnchor" id="parameter-"></div> + <b>instances</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 VRF instances.</div> + </td> + </tr> + <tr> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <td colspan="4"> + <div class="ansibleOptionAnchor" id="parameter-"></div> + <b>address_family</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>Address family configuration.</div> + </td> + </tr> + <tr> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <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 identifier.</div> + </td> + </tr> + <tr> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <td colspan="3"> + <div class="ansibleOptionAnchor" id="parameter-"></div> + <b>disable_forwarding</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><div style="color: blue"><b>no</b> ←</div></li> + <li>yes</li> + </ul> + </td> + <td> + <div>Disable IP forwarding for this address family.</div> + </td> + </tr> + <tr> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <td colspan="3"> + <div class="ansibleOptionAnchor" id="parameter-"></div> + <b>nht_no_resolve_via_default</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><div style="color: blue"><b>no</b> ←</div></li> + <li>yes</li> + </ul> + </td> + <td> + <div>Disable next-hop resolution via default route.</div> + </td> + </tr> + <tr> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <td colspan="3"> + <div class="ansibleOptionAnchor" id="parameter-"></div> + <b>route_maps</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>Route maps applied per protocol.</div> + </td> + </tr> + <tr> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <td colspan="2"> + <div class="ansibleOptionAnchor" id="parameter-"></div> + <b>protocol</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>any</li> + <li>babel</li> + <li>bgp</li> + <li>eigrp</li> + <li>isis</li> + <li>ospf</li> + <li>rip</li> + <li>static</li> + </ul> + </td> + <td> + <div>Protocol to which the route map applies.</div> + </td> + </tr> + <tr> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <td colspan="2"> + <div class="ansibleOptionAnchor" id="parameter-"></div> + <b>rm_name</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>Route map name.</div> + </td> + </tr> + + + <tr> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <td colspan="4"> + <div class="ansibleOptionAnchor" id="parameter-"></div> + <b>description</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>VRF description.</div> + </td> + </tr> + <tr> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <td colspan="4"> + <div class="ansibleOptionAnchor" id="parameter-"></div> + <b>disable</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><div style="color: blue"><b>no</b> ←</div></li> + <li>yes</li> + </ul> + </td> + <td> + <div>Administratively disable this VRF.</div> + </td> + </tr> + <tr> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <td colspan="4"> + <div class="ansibleOptionAnchor" id="parameter-"></div> + <b>name</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>VRF instance name.</div> + </td> + </tr> + <tr> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <td colspan="4"> + <div class="ansibleOptionAnchor" id="parameter-"></div> + <b>protocols</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>Protocol configuration within this VRF instance.</div> + </td> + </tr> + <tr> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <td colspan="3"> + <div class="ansibleOptionAnchor" id="parameter-"></div> + <b>bgp</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>BGP protocol configuration (core fields only).</div> + </td> + </tr> + <tr> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <td colspan="2"> + <div class="ansibleOptionAnchor" id="parameter-"></div> + <b>neighbor</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>BGP neighbors.</div> + </td> + </tr> + <tr> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <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>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>Neighbor IP address.</div> + </td> + </tr> + <tr> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <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>description</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>Neighbor description.</div> + </td> + </tr> + <tr> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <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>remote_as</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>Neighbor AS number.</div> + </td> + </tr> + + <tr> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <td colspan="2"> + <div class="ansibleOptionAnchor" id="parameter-"></div> + <b>system_as</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>BGP autonomous system number.</div> + </td> + </tr> + + <tr> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <td colspan="3"> + <div class="ansibleOptionAnchor" id="parameter-"></div> + <b>ospf</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>OSPFv2 protocol configuration (core fields only).</div> + </td> + </tr> + <tr> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <td colspan="2"> + <div class="ansibleOptionAnchor" id="parameter-"></div> + <b>areas</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>OSPF areas.</div> + </td> + </tr> + <tr> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <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>area_id</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>OSPF area identifier.</div> + </td> + </tr> + <tr> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <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>networks</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=string</span> + </div> + </td> + <td> + </td> + <td> + <div>Networks in this area.</div> + </td> + </tr> + + <tr> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <td colspan="2"> + <div class="ansibleOptionAnchor" id="parameter-"></div> + <b>parameters</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>OSPF parameters.</div> + </td> + </tr> + <tr> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <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>router_id</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>OSPF router ID.</div> + </td> + </tr> + + + <tr> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <td colspan="3"> + <div class="ansibleOptionAnchor" id="parameter-"></div> + <b>static</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>Static routes configuration.</div> + </td> + </tr> + <tr> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <td colspan="2"> + <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>Static routes.</div> + </td> + </tr> + <tr> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <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>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.</div> + </td> + </tr> + <tr> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <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>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=string</span> + </div> + </td> + <td> + </td> + <td> + <div>Next-hop IP addresses.</div> + </td> + </tr> + + + + <tr> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <td colspan="4"> + <div class="ansibleOptionAnchor" id="parameter-"></div> + <b>table_id</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>Routing table ID associated with this VRF.</div> + <div>The device enforces the valid range and rejects an invalid value with its own error message.</div> + <div>VyOS does not support changing an existing VRF's table ID in place -- it must be deleted and recreated. <code>state=merged</code> and <code>state=replaced</code> fail with a clear error if this differs from the current device value, rather than silently deleting and recreating the VRF. Use <code>state=overridden</code> (which deletes and recreates the VRF, re-applying every other desired field, since it already replaces everything to match <code>config</code>), or explicitly run <code>state=deleted</code> followed by <code>state=merged</code>/<code>replaced</code> as separate tasks.</div> + </td> + </tr> + <tr> + <td class="elbow-placeholder"></td> + <td class="elbow-placeholder"></td> + <td colspan="4"> + <div class="ansibleOptionAnchor" id="parameter-"></div> + <b>vni</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>Virtual Network Identifier.</div> + </td> + </tr> + + + <tr> + <td colspan="6"> + <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>Desired state of the VRF configuration.</div> + </td> + </tr> + </table> + <br/> + + + + +Examples +-------- + +.. code-block:: yaml + + - name: Merge VRF instances + vyos.rest.vyos_vrf: + config: + bind_to_all: true + instances: + - name: vrf1 + description: Red VRF + table_id: 101 + vni: 501 + protocols: + bgp: + system_as: 65001 + neighbor: + - address: 10.0.0.1 + remote_as: 65002 + ospf: + areas: + - area_id: "0" + networks: + - 10.0.0.0/24 + parameters: + router_id: 10.0.0.1 + static: + routes: + - dest: 192.168.10.0/24 + next_hops: + - 10.0.0.254 + state: merged + + - name: Delete specific VRF + vyos.rest.vyos_vrf: + config: + instances: + - name: vrf1 + state: deleted + + - name: Delete all VRF configuration + vyos.rest.vyos_vrf: + state: deleted + + - name: Gather current VRF configuration + vyos.rest.vyos_vrf: + 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">dictionary</span> + </div> + </td> + <td>when changed</td> + <td> + <div>VRF 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">dictionary</span> + </div> + </td> + <td>state is not gathered</td> + <td> + <div>VRF 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>state is not gathered</td> + <td> + <div>List of API command tuples sent to the device, or that would be sent (in check mode).</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">dictionary</span> + </div> + </td> + <td>when state is gathered</td> + <td> + <div>Current VRF configuration as structured data.</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 changed</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 b7a519e..382394b 100644 --- a/plugins/module_utils/vyos.py +++ b/plugins/module_utils/vyos.py @@ -383,3 +383,24 @@ class VyOSModule: return True except VyOSRestError: return False + + +def import_module_plugin(name): + """Import a sibling plugin from plugins/modules/ by name. + + Ansible's AnsiballZ does not add plugins/modules to sys.path, so + collection modules cannot be imported via the standard import system. + This utility resolves the module file relative to this module_utils + directory and loads it with importlib. + """ + import importlib.util + import os + + modules_dir = os.path.normpath( + os.path.join(os.path.dirname(__file__), "..", "modules"), + ) + module_path = os.path.join(modules_dir, "{0}.py".format(name)) + spec = importlib.util.spec_from_file_location(name, module_path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod diff --git a/plugins/modules/vyos_vrf.py b/plugins/modules/vyos_vrf.py new file mode 100644 index 0000000..3aba832 --- /dev/null +++ b/plugins/modules/vyos_vrf.py @@ -0,0 +1,1028 @@ +#!/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_vrf +short_description: Manage VRF configuration on VyOS devices using REST API +description: + - Manages Virtual Routing and Forwarding (VRF) instances on VyOS devices + via the REST API. + - Supports merged, replaced, overridden, deleted, and gathered states. + - Protocol configuration within VRFs (BGP, OSPFv2, static routes) is + managed inline with focused scope (core fields only). +version_added: "1.0.0" +author: + - VyOS Community (@vyos) +options: + config: + description: VRF configuration. + type: dict + suboptions: + bind_to_all: + description: + - Enable binding services to all VRFs. + - >- + Whether omitting this option preserves the current device + setting depends on C(state). With C(merged), and with + C(deleted) when specific C(instances) are named, omission + leaves it untouched. With C(replaced) or C(overridden), + omission deletes an existing setting, since those states + replace everything not explicitly present in C(config). + With C(deleted) and no C(instances) given, the entire VRF + configuration (including this setting) is removed. + - Only set this explicitly (C(true) or C(false)) when you want + this module to manage it under C(merged) or C(deleted) with + named instances. + type: bool + instances: + description: List of VRF instances. + type: list + elements: dict + suboptions: + name: + description: VRF instance name. + type: str + required: true + description: + description: VRF description. + type: str + disable: + description: Administratively disable this VRF. + type: bool + default: false + table_id: + description: + - Routing table ID associated with this VRF. + - The device enforces the valid range and rejects an invalid + value with its own error message. + - VyOS does not support changing an existing VRF's table ID + in place -- it must be deleted and recreated. C(state=merged) + and C(state=replaced) fail with a clear error if this + differs from the current device value, rather than + silently deleting and recreating the VRF. Use + C(state=overridden) (which deletes and recreates the VRF, + re-applying every other desired field, since it already + replaces everything to match C(config)), or explicitly run + C(state=deleted) followed by C(state=merged)/C(replaced) + as separate tasks. + type: int + vni: + description: Virtual Network Identifier. + type: int + address_family: + description: Address family configuration. + type: list + elements: dict + suboptions: + afi: + description: Address family identifier. + type: str + required: true + choices: [ipv4, ipv6] + disable_forwarding: + description: Disable IP forwarding for this address family. + type: bool + default: false + nht_no_resolve_via_default: + description: Disable next-hop resolution via default route. + type: bool + default: false + route_maps: + description: Route maps applied per protocol. + type: list + elements: dict + suboptions: + protocol: + description: Protocol to which the route map applies. + type: str + required: true + choices: [any, babel, bgp, eigrp, isis, ospf, rip, static] + rm_name: + description: Route map name. + type: str + required: true + protocols: + description: Protocol configuration within this VRF instance. + type: dict + suboptions: + bgp: + description: BGP protocol configuration (core fields only). + type: dict + suboptions: + system_as: + description: BGP autonomous system number. + type: int + neighbor: + description: BGP neighbors. + type: list + elements: dict + suboptions: + address: + description: Neighbor IP address. + type: str + required: true + remote_as: + description: Neighbor AS number. + type: int + description: + description: Neighbor description. + type: str + ospf: + description: OSPFv2 protocol configuration (core fields only). + type: dict + suboptions: + areas: + description: OSPF areas. + type: list + elements: dict + suboptions: + area_id: + description: OSPF area identifier. + type: str + required: true + networks: + description: Networks in this area. + type: list + elements: str + parameters: + description: OSPF parameters. + type: dict + suboptions: + router_id: + description: OSPF router ID. + type: str + static: + description: Static routes configuration. + type: dict + suboptions: + routes: + description: Static routes. + type: list + elements: dict + suboptions: + dest: + description: Destination prefix. + type: str + required: true + next_hops: + description: Next-hop IP addresses. + type: list + elements: str + state: + description: Desired state of the VRF configuration. + type: str + default: merged + choices: [merged, replaced, overridden, deleted, gathered] +""" + +EXAMPLES = r""" +- name: Merge VRF instances + vyos.rest.vyos_vrf: + config: + bind_to_all: true + instances: + - name: vrf1 + description: Red VRF + table_id: 101 + vni: 501 + protocols: + bgp: + system_as: 65001 + neighbor: + - address: 10.0.0.1 + remote_as: 65002 + ospf: + areas: + - area_id: "0" + networks: + - 10.0.0.0/24 + parameters: + router_id: 10.0.0.1 + static: + routes: + - dest: 192.168.10.0/24 + next_hops: + - 10.0.0.254 + state: merged + +- name: Delete specific VRF + vyos.rest.vyos_vrf: + config: + instances: + - name: vrf1 + state: deleted + +- name: Delete all VRF configuration + vyos.rest.vyos_vrf: + state: deleted + +- name: Gather current VRF configuration + vyos.rest.vyos_vrf: + state: gathered +""" + +RETURN = r""" +before: + description: VRF configuration before this module ran. + returned: state is not gathered + type: dict +after: + description: VRF configuration after this module ran. + returned: when changed + type: dict +commands: + description: List of API command tuples sent to the device, or that + would be sent (in check mode). + returned: state is not gathered + type: list +gathered: + description: Current VRF configuration as structured data. + returned: when state is gathered + type: dict +saved: + description: Whether the config was saved after changes. + returned: when changed + type: bool +""" + +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 = ["vrf"] + +# --------------------------------------------------------------------------- +# Field name renames: argspec key -> device key. +# Only entries that cannot be derived by mechanical snake_case <-> kebab-case +# conversion, or where the device uses a completely different name. +# +# table_id -> table argspec uses _id suffix, device does not +# system_as -> system-as hyphen in middle (mechanical would also work, +# declared here for explicitness) +# remote_as -> remote-as same +# rm_name -> route-map completely different device leaf name +# next_hops -> next-hop plural vs singular + hyphen +# areas -> area device uses singular tag-node name +# routes -> route device uses singular tag-node name +# --------------------------------------------------------------------------- +_DEVICE_RENAMES = { + "table_id": "table", + "system_as": "system-as", + "remote_as": "remote-as", + "rm_name": "route-map", + "next_hops": "next-hop", + "areas": "area", + "routes": "route", + "bind_to_all": "bind-to-all", + "router_id": "router-id", +} + + +# --------------------------------------------------------------------------- +# Generic helpers — same pattern as vyos_snmp_server +# --------------------------------------------------------------------------- + + +def _derive_key_field(options_spec): + """Derive the tag-node key field — the one required=True suboption.""" + required = [k for k, spec in options_spec.items() if spec.get("required")] + if len(required) != 1: + raise ValueError( + "expected exactly one required suboption, found: {0}".format(required), + ) + return required[0] + + +def _keyed_list_to_device(items, key_field, entry_transform=None): + """Convert argspec list of dicts to device tag-node dict keyed by key_field.""" + 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} + entry = entry_transform(rest) if entry_transform else autoclean(rest) + result[item[key_field]] = entry + return result + + +def _keyed_list_from_device(raw, key_field, entry_transform=None): + """Convert device tag-node dict to argspec list of dicts with key_field.""" + return [ + ( + {key_field: key, **entry_transform(data or {})} + if entry_transform + else {key_field: key, **from_device(data or {})} + ) + for key, data in sorted(to_tag_dict(raw).items()) + ] + + +# --------------------------------------------------------------------------- +# Structural entry overrides: sections where the device layout requires +# something beyond a field rename. Each entry: +# argspec_key -> (to_device_fn, from_device_fn) +# The to/from functions receive/return the entry dict WITHOUT the key field. +# --------------------------------------------------------------------------- + +# neighbor entries: fields use _DEVICE_RENAMES (remote_as -> remote-as), +# so we need _spec_to_device recursion, not plain autoclean. +# Defined after _spec_to_device is declared — see _ENTRY_OVERRIDES assignment. + + +# area entries: networks is a plain sorted list leaf, not a tag node. +def _area_to_device(rest): + d = {} + if rest.get("networks"): + d["network"] = sorted(rest["networks"]) + return d + + +def _area_from_device(raw): + raw = raw or {} + entry = {} + networks_raw = raw.get("network") + if networks_raw: + if isinstance(networks_raw, str): + entry["networks"] = [networks_raw] + elif isinstance(networks_raw, list): + entry["networks"] = sorted(networks_raw) + elif isinstance(networks_raw, dict): + entry["networks"] = sorted(networks_raw.keys()) + return entry + + +# route entries: next-hop is a tag node keyed by address, value is presence {}. +def _route_to_device(rest): + d = {} + if rest.get("next_hops"): + d["next-hop"] = {nh: {} for nh in rest["next_hops"]} + return d + + +def _route_from_device(raw): + raw = raw or {} + entry = {} + next_hops_raw = raw.get("next-hop") or {} + if isinstance(next_hops_raw, dict): + nhs = sorted(next_hops_raw.keys()) + if nhs: + entry["next_hops"] = nhs + elif isinstance(next_hops_raw, str): + entry["next_hops"] = [next_hops_raw] + return entry + + +# --------------------------------------------------------------------------- +# Generic recursive walkers — driven by ARGUMENT_SPEC + _DEVICE_RENAMES +# + _ENTRY_OVERRIDES. Same pattern as vyos_snmp_server. +# --------------------------------------------------------------------------- + + +def _spec_to_device(value, options_spec): + """Recursively convert argspec dict to device dict.""" + if not isinstance(value, dict): + return value + result = {} + for arg_key, sub_spec in options_spec.items(): + val = value.get(arg_key) + if val is None or val is False: + continue + device_key = _DEVICE_RENAMES.get(arg_key, arg_key) + sub_type = sub_spec.get("type") + sub_options = sub_spec.get("options") + if sub_type == "dict" and sub_options: + converted = _spec_to_device(val, sub_options) + if converted: + result[device_key] = converted + elif sub_type == "list" and sub_options: + key_field = _derive_key_field(sub_options) + entry_to, entry_from_unused = _ENTRY_OVERRIDES.get(arg_key, (None, None)) + entry_transform = entry_to or ( + lambda rest, spec=sub_options: _spec_to_device(rest, spec) + ) + result[device_key] = _keyed_list_to_device(val, key_field, entry_transform) + elif val is True: + result[device_key] = {} + elif sub_type == "list": + result[device_key] = list(val) + else: + result[device_key] = val + return result + + +def _device_to_spec(raw, options_spec): + """Recursively convert device dict to argspec dict.""" + if not raw or not isinstance(raw, dict): + return {} + have_idx = {k.replace("-", "_"): k for k in raw} + result = {} + for arg_key, sub_spec in options_spec.items(): + device_key = _DEVICE_RENAMES.get(arg_key, arg_key) + orig_key = device_key if device_key in raw else have_idx.get(arg_key) + if orig_key is None: + continue + raw_val = raw[orig_key] + sub_type = sub_spec.get("type") + sub_options = sub_spec.get("options") + if sub_type == "dict" and sub_options: + converted = _device_to_spec(raw_val, sub_options) + if converted: + result[arg_key] = converted + elif sub_type == "list" and sub_options: + key_field = _derive_key_field(sub_options) + entry_to_unused, entry_from = _ENTRY_OVERRIDES.get(arg_key, (None, None)) + entry_transform = entry_from or (lambda d, spec=sub_options: _device_to_spec(d, spec)) + entries = _keyed_list_from_device(raw_val, key_field, entry_transform) + if entries: + result[arg_key] = entries + elif sub_type == "list": + if raw_val: + result[arg_key] = sorted(to_tag_dict(raw_val).keys()) + elif isinstance(raw_val, dict) and not raw_val: + result[arg_key] = True + else: + result[arg_key] = raw_val + return result + + +# Entry overrides — defined after _spec_to_device so neighbor can use it. +# neighbor uses _spec_to_device recursion to apply _DEVICE_RENAMES +# (remote_as -> remote-as) inside each neighbor entry. +_ENTRY_OVERRIDES = { + "areas": (_area_to_device, _area_from_device), + "routes": (_route_to_device, _route_from_device), +} +# neighbor: use generic _spec_to_device with neighbor sub-options. +# Cannot declare inline above because _spec_to_device not yet defined. +# Assigned after ARGUMENT_SPEC is defined (see bottom of file). + + +# --------------------------------------------------------------------------- +# VRF address_family — bespoke because device uses ip/ipv6 as keys +# (not a standard tag node with argspec-named keys) +# nht_no_resolve_via_default maps to nested nht.no-resolve-via-default +# --------------------------------------------------------------------------- + +_AFI_TO_DEVICE = {"ipv4": "ip", "ipv6": "ipv6"} +_AFI_FROM_DEVICE = {"ip": "ipv4", "ipv6": "ipv6"} + + +def _af_to_device(af): + """Single address_family entry -> device ip/ipv6 subtree.""" + d = {} + if af.get("disable_forwarding"): + d["disable-forwarding"] = {} + if af.get("nht_no_resolve_via_default"): + d["nht"] = {"no-resolve-via-default": {}} + for rm in af.get("route_maps") or []: + proto = rm.get("protocol") + rm_name = rm.get("rm_name") + if proto and rm_name: + d.setdefault("protocol", {})[proto] = {"route-map": rm_name} + return d + + +def _af_from_device(afi_key, raw): + """Device ip/ipv6 subtree -> single address_family entry.""" + raw = raw or {} + entry = {"afi": _AFI_FROM_DEVICE.get(afi_key, afi_key)} + if "disable-forwarding" in raw: + entry["disable_forwarding"] = True + nht = raw.get("nht") or {} + if isinstance(nht, dict) and "no-resolve-via-default" in nht: + entry["nht_no_resolve_via_default"] = True + proto_raw = raw.get("protocol") or {} + if isinstance(proto_raw, dict): + rms = [ + {"protocol": p, "rm_name": (d or {}).get("route-map")} + for p, d in proto_raw.items() + if (d or {}).get("route-map") + ] + if rms: + entry["route_maps"] = rms + return entry + + +# --------------------------------------------------------------------------- +# VRF instance converters +# --------------------------------------------------------------------------- + + +def _instance_to_device(inst): + """argspec instance -> device VRF entry (non-protocol fields).""" + d = _spec_to_device( + {k: v for k, v in inst.items() if k not in ("name", "address_family", "protocols")}, + _INSTANCE_OPTIONS, + ) + for af in inst.get("address_family") or []: + afi = af.get("afi") + if afi: + dev_key = _AFI_TO_DEVICE.get(afi, afi) + af_data = _af_to_device(af) + if af_data: + d[dev_key] = af_data + return d + + +def _instance_from_device(name, raw): + """Device VRF entry -> argspec instance (non-protocol fields).""" + raw_base = {k: v for k, v in raw.items() if k not in ("ip", "ipv6", "protocols")} + inst = {"name": name} + d = _device_to_spec(raw_base, _INSTANCE_OPTIONS) + cast_by_spec(d, _INSTANCE_OPTIONS) + inst.update({k: v for k, v in d.items() if k not in ("address_family", "protocols")}) + afs = [_af_from_device(key, raw[key]) for key in ("ip", "ipv6") if key in raw and raw[key]] + if afs: + inst["address_family"] = afs + return inst + + +# --------------------------------------------------------------------------- +# Top-level config converters +# --------------------------------------------------------------------------- + + +def _config_to_device(config): + """Top-level argspec config -> device dict.""" + config = config or {} + result = _spec_to_device( + {k: v for k, v in config.items() if k != "instances"}, + _TOP_OPTIONS, + ) + name_dict = { + inst["name"]: _instance_to_device(inst) + for inst in config.get("instances") or [] + if inst.get("name") + } + if name_dict: + result["name"] = name_dict + return result + + +def _device_to_argspec(raw): + """Device raw config -> argspec (non-protocol fields).""" + raw = raw or {} + result = _device_to_spec( + {k: v for k, v in raw.items() if k != "name"}, + _TOP_OPTIONS, + ) + name_raw = raw.get("name") or {} + if isinstance(name_raw, dict): + instances = [ + _instance_from_device(vrf_name, vrf_data or {}) + for vrf_name, vrf_data in sorted(name_raw.items()) + ] + if instances: + result["instances"] = instances + return result + + +# --------------------------------------------------------------------------- +# Protocol converters — generic walkers with protocol sub-specs +# --------------------------------------------------------------------------- + + +def _routes_to_device(routes): + """Split routes by address family -- VyOS requires IPv4 static + routes under "route" and IPv6 under "route6" as genuinely separate + device subtrees (confirmed against VyOS's own interface-definitions + schema: static-route.xml.i and static-route6.xml.i are distinct + includes, not a single shared "route" path for both families). + """ + device = {} + for entry in routes or []: + dest = entry.get("dest") + if not dest: + continue + container = "route6" if ":" in dest else "route" + route_device = _route_to_device({k: v for k, v in entry.items() if k != "dest"}) + device.setdefault(container, {})[dest] = route_device + return device + + +def _routes_from_device(raw): + """Inverse of _routes_to_device -- merge the route and route6 + device subtrees back into a single argspec routes list.""" + entries = [] + for container in ("route", "route6"): + raw_container = (raw or {}).get(container) + if raw_container: + entries += _keyed_list_from_device(raw_container, "dest", _route_from_device) + return sorted(entries, key=lambda e: e["dest"]) + + +def _proto_to_device(proto_config, proto_key): + """argspec protocol config -> device protocol dict.""" + if proto_key == "static": + return _routes_to_device((proto_config or {}).get("routes")) + result = _spec_to_device(proto_config or {}, _PROTO_OPTIONS[proto_key]["options"]) + return result + + +def _proto_from_device(raw, proto_key): + """Device protocol dict -> argspec protocol config.""" + if proto_key == "static": + routes = _routes_from_device(raw) + return {"routes": routes} if routes else {} + result = _device_to_spec(raw or {}, _PROTO_OPTIONS[proto_key]["options"]) + cast_by_spec(result, _PROTO_OPTIONS[proto_key]["options"]) + return result + + +# --------------------------------------------------------------------------- +# Protocol dispatch — single source of truth. +# Adding a new protocol: one entry in _PROTO_HANDLERS + argspec only. +# --------------------------------------------------------------------------- + +_PROTO_HANDLERS = ["bgp", "ospf", "static"] + +# Tag-node containers per protocol (device key name) — used for placeholder seeding. +_PROTO_TAG_CONTAINERS = { + "bgp": ["neighbor"], + "ospf": ["area"], + "static": ["route", "route6"], +} + + +def _protocols_from_device(raw_vrf): + """Extract and convert all protocol configs from raw VRF device data.""" + proto_raw = (raw_vrf or {}).get("protocols") or {} + result = {} + for proto_key in _PROTO_HANDLERS: + if proto_raw.get(proto_key): + converted = _proto_from_device(proto_raw[proto_key], proto_key) + if converted: + result[proto_key] = converted + return result or None + + +def _seed_tag_node_placeholders(want, have, proto_key): + """Seed empty placeholders for new tag-node entries so dict_op uses + verbatim keys rather than guessing a kebab-case translation.""" + for container in _PROTO_TAG_CONTAINERS.get(proto_key, []): + want_entries = want.get(container) or {} + if isinstance(want_entries, dict): + have.setdefault(container, {}) + for entry_key in want_entries: + have[container].setdefault(entry_key, {}) + + +def _protocol_commands(vrf_name, protocols, raw_proto, state): + """Generate protocol commands for a VRF instance.""" + cmds = [] + for proto_key in _PROTO_HANDLERS: + want_proto = (protocols or {}).get(proto_key) + raw_have_proto = (raw_proto or {}).get(proto_key) or {} + + if want_proto is None and state not in ("overridden", "replaced"): + continue + + if want_proto is None: + if raw_have_proto: + cmds.append(("delete", _BASE + ["name", vrf_name, "protocols", proto_key])) + continue + + proto_base = _BASE + ["name", vrf_name, "protocols", proto_key] + want = _proto_to_device(want_proto, proto_key) + norm_have = _proto_to_device( + _proto_from_device(raw_have_proto, proto_key), + proto_key, + ) + _seed_tag_node_placeholders(want, norm_have, proto_key) + + if state in ("overridden", "replaced"): + cmds += dict_op(want, norm_have, proto_base, op="purge") + cmds += dict_op(want, norm_have, proto_base, op="set") + return cmds + + +# --------------------------------------------------------------------------- +# Running config +# --------------------------------------------------------------------------- + + +def get_running_config(vyos): + try: + return vyos.get_config(_BASE) or {} + except Exception as exc: + if "Configuration under specified path is empty" in str(exc): + return {} + raise + + +# --------------------------------------------------------------------------- +# Build commands +# --------------------------------------------------------------------------- + + +def build_commands(config, raw_have, state): + raw_have = raw_have or {} + config = config or {} + cmds = [] + + if state == "deleted": + instances = config.get("instances") or [] + if not instances: + return [("delete", _BASE)] if raw_have else [] + for inst in instances: + vrf_name = inst.get("name") + if vrf_name and (raw_have.get("name") or {}).get(vrf_name) is not None: + cmds.append(("delete", _BASE + ["name", vrf_name])) + if "bind_to_all" in config and config["bind_to_all"] is False and "bind-to-all" in raw_have: + cmds.append(("delete", _BASE + ["bind-to-all"])) + return cmds + + want = _config_to_device(config) + norm_have = _config_to_device(_device_to_argspec(raw_have)) + + # Seed placeholders for new VRF instances (verbatim tag-node keys) + for vrf_name in want.get("name") or {}: + norm_have.setdefault("name", {}).setdefault(vrf_name, {}) + + # VyOS's routing table ID cannot be modified in place once assigned -- + # confirmed via VyOS's own official documentation ("A routing table ID + # can not be modified once it is assigned. It can only be changed by + # deleting and re-adding the VRF instance") and its source + # (ConfigError: "VRF ... table id modification not possible!"). This + # module never does that destructive delete-and-recreate silently + # under merged/replaced -- only overridden's own explicit contract + # ("replace everything to match want, destructively if needed") + # covers it; merged/replaced fail loudly instead, so a table_id + # change is always something the user explicitly asked for via the + # right state, not a surprise this module decided on their behalf. + recreated_vrfs = set() + for vrf_name, vrf_want in (want.get("name") or {}).items(): + vrf_have = (norm_have.get("name") or {}).get(vrf_name) or {} + want_table = vrf_want.get("table") + have_table = vrf_have.get("table") + if want_table is None or have_table is None or str(want_table) == str(have_table): + continue + if state == "overridden": + cmds.append(("delete", _BASE + ["name", vrf_name])) + norm_have["name"][vrf_name] = {} + recreated_vrfs.add(vrf_name) + else: + raise ValueError( + "VRF '{name}': table_id cannot be changed in place under " + "state={state} -- VyOS does not support modifying an " + "existing VRF's routing table. Use state=overridden, or " + "explicitly remove and recreate it with separate " + "state=deleted and state=merged/replaced tasks.".format( + name=vrf_name, + state=state, + ), + ) + + if state == "overridden": + cmds += dict_op(want, norm_have, _BASE, op="purge") + elif state == "replaced": + for vrf_name, vrf_want in (want.get("name") or {}).items(): + vrf_have = (norm_have.get("name") or {}).get(vrf_name) or {} + cmds += dict_op(vrf_want, vrf_have, _BASE + ["name", vrf_name], op="purge") + if "bind-to-all" not in want and "bind-to-all" in norm_have: + cmds.append(("delete", _BASE + ["bind-to-all"])) + elif config.get("bind_to_all") is False and "bind-to-all" in norm_have: + # merged never runs a purge pass, so _spec_to_device's blanket + # "val is False: continue" skip means an explicit bind_to_all: + # false is otherwise indistinguishable from omission by the + # time dict_op sees "want" -- confirmed real bug: the device's + # bind-to-all setting silently stayed enabled with no delete + # command generated and changed=false reported. + cmds.append(("delete", _BASE + ["bind-to-all"])) + + cmds += dict_op(want, norm_have, _BASE, op="set") + + # Protocol commands per VRF instance + for inst in config.get("instances") or []: + vrf_name = inst.get("name") + if not vrf_name: + continue + protocols = inst.get("protocols") or {} + if vrf_name in recreated_vrfs: + raw_vrf = {} + else: + raw_vrf = (raw_have.get("name") or {}).get(vrf_name) or {} + raw_proto = raw_vrf.get("protocols") or {} + if protocols or state in ("overridden", "replaced") or vrf_name in recreated_vrfs: + cmds += _protocol_commands(vrf_name, protocols, raw_proto, state) + + return cmds + + +# --------------------------------------------------------------------------- +# Enrich have/after with protocol data +# --------------------------------------------------------------------------- + + +def _enrich_with_protocols(instances, raw_have): + """Add protocol data to each instance dict in-place.""" + for inst in instances or []: + raw_vrf = (raw_have.get("name") or {}).get(inst["name"]) or {} + protocols = _protocols_from_device(raw_vrf) + if protocols: + inst["protocols"] = protocols + + +# --------------------------------------------------------------------------- +# ARGUMENT_SPEC +# --------------------------------------------------------------------------- + +ARGUMENT_SPEC = dict( + config=dict( + type="dict", + options=dict( + bind_to_all=dict(type="bool"), + instances=dict( + type="list", + elements="dict", + options=dict( + name=dict(type="str", required=True), + description=dict(type="str"), + disable=dict(type="bool", default=False), + table_id=dict(type="int"), + vni=dict(type="int"), + address_family=dict( + type="list", + elements="dict", + options=dict( + afi=dict(type="str", required=True, choices=["ipv4", "ipv6"]), + disable_forwarding=dict(type="bool", default=False), + nht_no_resolve_via_default=dict(type="bool", default=False), + route_maps=dict( + type="list", + elements="dict", + options=dict( + protocol=dict( + type="str", + required=True, + choices=[ + "any", + "babel", + "bgp", + "eigrp", + "isis", + "ospf", + "rip", + "static", + ], + ), + rm_name=dict(type="str", required=True), + ), + ), + ), + ), + protocols=dict( + type="dict", + options=dict( + bgp=dict( + type="dict", + options=dict( + system_as=dict(type="int"), + neighbor=dict( + type="list", + elements="dict", + options=dict( + address=dict(type="str", required=True), + remote_as=dict(type="int"), + description=dict(type="str"), + ), + ), + ), + ), + ospf=dict( + type="dict", + options=dict( + areas=dict( + type="list", + elements="dict", + options=dict( + area_id=dict(type="str", required=True), + networks=dict(type="list", elements="str"), + ), + ), + parameters=dict( + type="dict", + options=dict( + router_id=dict(type="str"), + ), + ), + ), + ), + static=dict( + type="dict", + options=dict( + routes=dict( + type="list", + elements="dict", + options=dict( + dest=dict(type="str", required=True), + next_hops=dict(type="list", elements="str"), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + state=dict( + type="str", + default="merged", + choices=["merged", "replaced", "overridden", "deleted", "gathered"], + ), +) + + +def _init_specs(): + """Initialize module-level spec references and entry overrides. + Called once at import time via _init_specs(). Avoids module-level + subscript expressions that confuse ansible-doc's AST walker. + """ + top = ARGUMENT_SPEC["config"]["options"] + instance_opts = top["instances"]["options"] + proto_opts = instance_opts["protocols"]["options"] + neighbor_opts = proto_opts["bgp"]["options"]["neighbor"]["options"] + + global _TOP_OPTIONS, _INSTANCE_OPTIONS, _PROTO_OPTIONS + + _TOP_OPTIONS = top + _INSTANCE_OPTIONS = instance_opts + _PROTO_OPTIONS = proto_opts + + def _neighbor_entry_to_device(rest): + return _spec_to_device(rest, neighbor_opts) + + def _neighbor_entry_from_device(d): + return _device_to_spec(d, neighbor_opts) + + _ENTRY_OVERRIDES["neighbor"] = (_neighbor_entry_to_device, _neighbor_entry_from_device) + + +_TOP_OPTIONS = {} +_INSTANCE_OPTIONS = {} +_PROTO_OPTIONS = {} +_init_specs() + + +# --------------------------------------------------------------------------- +# main +# --------------------------------------------------------------------------- + + +def main(): + module = AnsibleModule(argument_spec=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) + _enrich_with_protocols(have.get("instances"), raw_have) + + if state == "gathered": + module.exit_json(changed=False, gathered=have) + + try: + cmds = build_commands(config, raw_have, state) + except ValueError as exc: + module.fail_json(msg=str(exc)) + + if module.check_mode: + module.exit_json(changed=bool(cmds), commands=cmds, before=have) + + if cmds: + response = vyos.apply_commands(cmds) + saved = vyos.save_config() + raw_after = get_running_config(vyos) + after = _device_to_argspec(raw_after) + _enrich_with_protocols(after.get("instances"), raw_after) + module.exit_json( + changed=True, + before=have, + after=after, + commands=cmds, + 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_vrf/aliases b/tests/integration/targets/vyos_vrf/aliases new file mode 100644 index 0000000..cc0afef --- /dev/null +++ b/tests/integration/targets/vyos_vrf/aliases @@ -0,0 +1 @@ +network/vyos diff --git a/tests/integration/targets/vyos_vrf/defaults/main.yaml b/tests/integration/targets/vyos_vrf/defaults/main.yaml new file mode 100644 index 0000000..164afea --- /dev/null +++ b/tests/integration/targets/vyos_vrf/defaults/main.yaml @@ -0,0 +1,3 @@ +--- +testcase: "[^_].*" +test_items: [] diff --git a/tests/integration/targets/vyos_vrf/tasks/httpapi.yaml b/tests/integration/targets/vyos_vrf/tasks/httpapi.yaml new file mode 100644 index 0000000..0ed3e42 --- /dev/null +++ b/tests/integration/targets/vyos_vrf/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 | sort }}" + +- 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_vrf/tasks/main.yaml b/tests/integration/targets/vyos_vrf/tasks/main.yaml new file mode 100644 index 0000000..b1f6193 --- /dev/null +++ b/tests/integration/targets/vyos_vrf/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_vrf/tests/httpapi/_populate_config.yaml b/tests/integration/targets/vyos_vrf/tests/httpapi/_populate_config.yaml new file mode 100644 index 0000000..69222b6 --- /dev/null +++ b/tests/integration/targets/vyos_vrf/tests/httpapi/_populate_config.yaml @@ -0,0 +1,23 @@ +--- +- name: Populate VRF configuration + vyos.rest.vyos_vrf: + config: + bind_to_all: true + instances: + - name: vrf1 + description: red + table_id: 101 + vni: 501 + disable: true + - name: vrf2 + description: blah2 + table_id: 102 + vni: 102 + address_family: + - afi: ipv4 + disable_forwarding: true + nht_no_resolve_via_default: true + - afi: ipv6 + disable_forwarding: true + nht_no_resolve_via_default: true + state: merged diff --git a/tests/integration/targets/vyos_vrf/tests/httpapi/_remove_config.yaml b/tests/integration/targets/vyos_vrf/tests/httpapi/_remove_config.yaml new file mode 100644 index 0000000..da63347 --- /dev/null +++ b/tests/integration/targets/vyos_vrf/tests/httpapi/_remove_config.yaml @@ -0,0 +1,4 @@ +--- +- name: Remove VRF configuration + vyos.rest.vyos_vrf: + state: deleted diff --git a/tests/integration/targets/vyos_vrf/tests/httpapi/deleted.yaml b/tests/integration/targets/vyos_vrf/tests/httpapi/deleted.yaml new file mode 100644 index 0000000..22eb43f --- /dev/null +++ b/tests/integration/targets/vyos_vrf/tests/httpapi/deleted.yaml @@ -0,0 +1,50 @@ +--- +- debug: + msg: START vyos_vrf deleted integration tests on connection={{ ansible_connection }} + +- include_tasks: _remove_config.yaml +- include_tasks: _populate_config.yaml + +- block: + - name: Delete specific VRF + register: result + vyos.rest.vyos_vrf: + config: + instances: + - name: vrf1 + state: deleted + + - assert: + that: + - result.changed == true + + - name: Gather and verify vrf1 deleted + register: result + vyos.rest.vyos_vrf: + state: gathered + + - assert: + that: + - result.gathered.instances | length == 1 + - result.gathered.instances[0].name == "vrf2" + + - name: Delete all VRF configuration + register: result + vyos.rest.vyos_vrf: + state: deleted + + - assert: + that: + - result.changed == true + + - name: Delete all VRF (IDEMPOTENT) + register: result + vyos.rest.vyos_vrf: + state: deleted + + - assert: + that: + - result.changed == false + + always: + - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_vrf/tests/httpapi/gathered.yaml b/tests/integration/targets/vyos_vrf/tests/httpapi/gathered.yaml new file mode 100644 index 0000000..83f543d --- /dev/null +++ b/tests/integration/targets/vyos_vrf/tests/httpapi/gathered.yaml @@ -0,0 +1,27 @@ +--- +- debug: + msg: START vyos_vrf gathered integration tests on connection={{ ansible_connection }} + +- include_tasks: _remove_config.yaml +- include_tasks: _populate_config.yaml + +- block: + - name: Gather VRF configuration + register: result + vyos.rest.vyos_vrf: + state: gathered + + - assert: + that: + - result.changed == false + - result.gathered.bind_to_all == true + - result.gathered.instances | length == 2 + - result.gathered.instances[0].name == "vrf1" + - result.gathered.instances[0].table_id == 101 + - result.gathered.instances[0].vni == 501 + - result.gathered.instances[0].disable == true + - result.gathered.instances[1].name == "vrf2" + - result.gathered.instances[1].table_id == 102 + + always: + - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_vrf/tests/httpapi/merged.yaml b/tests/integration/targets/vyos_vrf/tests/httpapi/merged.yaml new file mode 100644 index 0000000..2655ce3 --- /dev/null +++ b/tests/integration/targets/vyos_vrf/tests/httpapi/merged.yaml @@ -0,0 +1,59 @@ +--- +- debug: + msg: START vyos_vrf merged integration tests on connection={{ ansible_connection }} + +- include_tasks: _remove_config.yaml + +- block: + - name: Merge VRF configuration + register: result + vyos.rest.vyos_vrf: + config: + bind_to_all: true + instances: + - name: vrf1 + description: red + table_id: 101 + vni: 501 + state: merged + + - assert: + that: + - result.changed == true + - result.after.instances | length == 1 + - result.after.instances[0].name == "vrf1" + - result.after.instances[0].table_id == 101 + + - name: Merge VRF configuration (IDEMPOTENT) + register: result + vyos.rest.vyos_vrf: + config: + bind_to_all: true + instances: + - name: vrf1 + description: red + table_id: 101 + vni: 501 + state: merged + + - assert: + that: + - result.changed == false + - result.commands == [] + + - name: Merge second VRF instance + register: result + vyos.rest.vyos_vrf: + config: + instances: + - name: vrf2 + table_id: 102 + state: merged + + - assert: + that: + - result.changed == true + - result.after.instances | length == 2 + + always: + - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_vrf/tests/httpapi/merged_protocols.yaml b/tests/integration/targets/vyos_vrf/tests/httpapi/merged_protocols.yaml new file mode 100644 index 0000000..f600f84 --- /dev/null +++ b/tests/integration/targets/vyos_vrf/tests/httpapi/merged_protocols.yaml @@ -0,0 +1,117 @@ +--- +- debug: + msg: START vyos_vrf merged protocols integration tests on connection={{ ansible_connection }} + +- include_tasks: _remove_config.yaml + +- block: + - name: Merge VRF with BGP + register: result + vyos.rest.vyos_vrf: + config: + instances: + - name: vrf1 + table_id: 101 + protocols: + bgp: + system_as: 65001 + neighbor: + - address: 10.0.0.1 + remote_as: 65002 + description: peer1 + state: merged + + - assert: + that: + - result.changed == true + + - name: Merge VRF with BGP (IDEMPOTENT) + register: result + vyos.rest.vyos_vrf: + config: + instances: + - name: vrf1 + table_id: 101 + protocols: + bgp: + system_as: 65001 + neighbor: + - address: 10.0.0.1 + remote_as: 65002 + description: peer1 + state: merged + + - assert: + that: + - result.changed == false + - result.commands == [] + + - name: Merge VRF with OSPF and static routes + register: result + vyos.rest.vyos_vrf: + config: + instances: + - name: vrf1 + table_id: 101 + protocols: + ospf: + areas: + - area_id: "0" + networks: + - 10.0.0.0/24 + parameters: + router_id: 10.0.0.1 + static: + routes: + - dest: 192.168.10.0/24 + next_hops: + - 10.0.0.254 + state: merged + + - assert: + that: + - result.changed == true + + - name: Gather and verify protocols + register: result + vyos.rest.vyos_vrf: + state: gathered + + - assert: + that: + - result.gathered.instances[0].protocols.bgp.system_as == 65001 + - result.gathered.instances[0].protocols.ospf.areas | length == 1 + - result.gathered.instances[0].protocols.static.routes | length == 1 + + - name: Change an already-set OSPF router_id + register: result + vyos.rest.vyos_vrf: + config: + instances: + - name: vrf1 + table_id: 101 + protocols: + ospf: + areas: + - area_id: "0" + networks: + - 10.0.0.0/24 + parameters: + router_id: 10.0.0.99 + state: merged + + - assert: + that: + - result.changed == true + + - name: Gather and verify router_id was actually changed + register: result + vyos.rest.vyos_vrf: + state: gathered + + - assert: + that: + - result.gathered.instances[0].protocols.ospf.parameters.router_id == "10.0.0.99" + + always: + - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_vrf/tests/httpapi/overridden.yaml b/tests/integration/targets/vyos_vrf/tests/httpapi/overridden.yaml new file mode 100644 index 0000000..da72aac --- /dev/null +++ b/tests/integration/targets/vyos_vrf/tests/httpapi/overridden.yaml @@ -0,0 +1,50 @@ +--- +- debug: + msg: START vyos_vrf overridden integration tests on connection={{ ansible_connection }} + +- include_tasks: _remove_config.yaml +- include_tasks: _populate_config.yaml + +- block: + - name: Override with single VRF + register: result + vyos.rest.vyos_vrf: + config: + instances: + - name: vrf3 + table_id: 200 + vni: 2000 + state: overridden + + - assert: + that: + - result.changed == true + + - name: Gather and verify override + register: result + vyos.rest.vyos_vrf: + state: gathered + + - assert: + that: + - result.gathered.instances | length == 1 + - result.gathered.instances[0].name == "vrf3" + - result.gathered.instances[0].table_id == 200 + + - name: Override (IDEMPOTENT) + register: result + vyos.rest.vyos_vrf: + config: + instances: + - name: vrf3 + table_id: 200 + vni: 2000 + state: overridden + + - assert: + that: + - result.changed == false + - result.commands == [] + + always: + - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_vrf/tests/httpapi/replaced.yaml b/tests/integration/targets/vyos_vrf/tests/httpapi/replaced.yaml new file mode 100644 index 0000000..53714fc --- /dev/null +++ b/tests/integration/targets/vyos_vrf/tests/httpapi/replaced.yaml @@ -0,0 +1,52 @@ +--- +- debug: + msg: START vyos_vrf replaced integration tests on connection={{ ansible_connection }} + +- include_tasks: _remove_config.yaml +- include_tasks: _populate_config.yaml + +- block: + - name: Replace vrf1 configuration + register: result + vyos.rest.vyos_vrf: + config: + instances: + - name: vrf1 + description: updated + table_id: 101 + vni: 999 + state: replaced + + - assert: + that: + - result.changed == true + + - name: Gather and verify replacement + register: result + vyos.rest.vyos_vrf: + state: gathered + + - assert: + that: + - result.gathered.instances[0].vni == 999 + - result.gathered.instances[0].description == "updated" + - result.gathered.instances[0].disable is not defined or result.gathered.instances[0].disable == false + + - name: Replace vrf1 (IDEMPOTENT) + register: result + vyos.rest.vyos_vrf: + config: + instances: + - name: vrf1 + description: updated + table_id: 101 + vni: 999 + state: replaced + + - assert: + that: + - result.changed == false + - result.commands == [] + + always: + - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_vrf/tests/httpapi/rtt.yaml b/tests/integration/targets/vyos_vrf/tests/httpapi/rtt.yaml new file mode 100644 index 0000000..9d16bc6 --- /dev/null +++ b/tests/integration/targets/vyos_vrf/tests/httpapi/rtt.yaml @@ -0,0 +1,82 @@ +--- +- debug: + msg: START vyos_vrf round trip integration tests on connection={{ ansible_connection }} + +- include_tasks: _remove_config.yaml + +- block: + - name: RTT - Apply base configuration + vyos.rest.vyos_vrf: + config: + bind_to_all: true + instances: + - name: vrf1 + description: red + table_id: 101 + vni: 501 + protocols: + bgp: + system_as: 65001 + neighbor: + - address: 10.0.0.1 + remote_as: 65002 + ospf: + areas: + - area_id: "0" + networks: + - 10.0.0.0/24 + parameters: + router_id: 10.0.0.1 + static: + routes: + - dest: 192.168.10.0/24 + next_hops: + - 10.0.0.254 + state: merged + + - name: RTT - Gather + register: gathered + vyos.rest.vyos_vrf: + state: gathered + + - assert: + that: + - gathered.gathered.bind_to_all == true + - gathered.gathered.instances[0].name == "vrf1" + - gathered.gathered.instances[0].table_id == 101 + - gathered.gathered.instances[0].protocols.bgp.system_as == 65001 + - gathered.gathered.instances[0].protocols.ospf.parameters.router_id == "10.0.0.1" + - gathered.gathered.instances[0].protocols.static.routes[0].dest == "192.168.10.0/24" + + - name: RTT - Modify description and add BGP neighbor + vyos.rest.vyos_vrf: + config: + bind_to_all: true + instances: + - name: vrf1 + description: red-updated + table_id: 101 + vni: 501 + protocols: + bgp: + system_as: 65001 + neighbor: + - address: 10.0.0.1 + remote_as: 65002 + - address: 10.0.0.2 + remote_as: 65003 + state: replaced + + - name: RTT - Gather after modify + register: gathered2 + vyos.rest.vyos_vrf: + state: gathered + + - assert: + that: + - gathered2.gathered.instances[0].description == "red-updated" + - gathered2.gathered.instances[0].protocols.bgp.neighbor | length == 2 + - gathered2.gathered.instances[0].protocols.ospf is not defined + + always: + - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_vrf/vars/main.yaml b/tests/integration/targets/vyos_vrf/vars/main.yaml new file mode 100644 index 0000000..4303881 --- /dev/null +++ b/tests/integration/targets/vyos_vrf/vars/main.yaml @@ -0,0 +1,2 @@ +--- +# only common vars here diff --git a/tests/unit/fixtures/vrf_running.json b/tests/unit/fixtures/vrf_running.json new file mode 100644 index 0000000..602c292 --- /dev/null +++ b/tests/unit/fixtures/vrf_running.json @@ -0,0 +1,42 @@ +{ + "bind-to-all": {}, + "name": { + "vrf1": { + "description": "red", + "disable": {}, + "table": "101", + "vni": "501", + "protocols": { + "bgp": { + "system-as": "65001", + "neighbor": { + "10.0.0.1": { "remote-as": "65002", "description": "peer1" } + } + }, + "ospf": { + "area": { "0": { "network": ["10.0.0.0/24", "172.16.0.0/24"] } }, + "parameters": { "router-id": "10.0.0.1" } + }, + "static": { + "route": { + "192.168.10.0/24": { "next-hop": { "10.0.0.254": {} } } + } + } + } + }, + "vrf2": { + "description": "blah2", + "disable": {}, + "table": "102", + "vni": "102", + "ip": { + "disable-forwarding": {}, + "nht": { "no-resolve-via-default": {} } + }, + "ipv6": { + "disable-forwarding": {}, + "nht": { "no-resolve-via-default": {} } + } + } + } +} diff --git a/tests/unit/modules/test_vyos_vrf.py b/tests/unit/modules/test_vyos_vrf.py new file mode 100644 index 0000000..e17bcd1 --- /dev/null +++ b/tests/unit/modules/test_vyos_vrf.py @@ -0,0 +1,369 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +import unittest + +from ansible_collections.vyos.rest.plugins.modules.vyos_vrf import ( + _device_to_argspec, + _proto_from_device, + _proto_to_device, + _protocols_from_device, + build_commands, +) + +from .base import load_fixture + + +_RAW_HAVE = load_fixture("vrf_running.json") + + +class TestDeviceToArgspec(unittest.TestCase): + def setUp(self): + self.result = _device_to_argspec(_RAW_HAVE) + + def test_bind_to_all(self): + self.assertTrue(self.result["bind_to_all"]) + + def test_instances_count(self): + self.assertEqual(len(self.result["instances"]), 2) + + def test_vrf1_properties(self): + vrf1 = next(i for i in self.result["instances"] if i["name"] == "vrf1") + self.assertEqual(vrf1["description"], "red") + self.assertTrue(vrf1["disable"]) + self.assertEqual(vrf1["table_id"], 101) + self.assertEqual(vrf1["vni"], 501) + + def test_vrf2_address_family(self): + vrf2 = next(i for i in self.result["instances"] if i["name"] == "vrf2") + afis = {af["afi"]: af for af in vrf2["address_family"]} + self.assertIn("ipv4", afis) + self.assertTrue(afis["ipv4"]["disable_forwarding"]) + self.assertTrue(afis["ipv4"]["nht_no_resolve_via_default"]) + self.assertIn("ipv6", afis) + self.assertTrue(afis["ipv6"]["disable_forwarding"]) + self.assertTrue(afis["ipv6"]["nht_no_resolve_via_default"]) + + def test_empty_input(self): + self.assertEqual(_device_to_argspec({}), {}) + self.assertEqual(_device_to_argspec(None), {}) + + +class TestBgpFromDevice(unittest.TestCase): + def setUp(self): + self.raw = _RAW_HAVE["name"]["vrf1"]["protocols"]["bgp"] + self.result = _proto_from_device(self.raw, "bgp") + + def test_system_as(self): + self.assertEqual(self.result["system_as"], 65001) + + def test_neighbor_list(self): + self.assertEqual(len(self.result["neighbor"]), 1) + n = self.result["neighbor"][0] + self.assertEqual(n["address"], "10.0.0.1") + self.assertEqual(n["remote_as"], 65002) + self.assertEqual(n["description"], "peer1") + + def test_empty_input(self): + self.assertEqual(_proto_from_device({}, "bgp"), {}) + self.assertEqual(_proto_from_device(None, "bgp"), {}) + + +class TestBgpToDevice(unittest.TestCase): + def test_system_as_to_device(self): + result = _proto_to_device({"system_as": 65001}, "bgp") + self.assertIn("system-as", result) + self.assertEqual(result["system-as"], 65001) + + def test_neighbor_to_device(self): + result = _proto_to_device( + { + "system_as": 65001, + "neighbor": [{"address": "10.0.0.1", "remote_as": 65002}], + }, + "bgp", + ) + self.assertIn("neighbor", result) + self.assertIn("10.0.0.1", result["neighbor"]) + self.assertEqual(result["neighbor"]["10.0.0.1"]["remote-as"], 65002) + + def test_idempotent(self): + want = _proto_from_device(_RAW_HAVE["name"]["vrf1"]["protocols"]["bgp"], "bgp") + cmds = build_commands( + {"instances": [{"name": "vrf1", "table_id": 101, "protocols": {"bgp": want}}]}, + _RAW_HAVE, + "merged", + ) + bgp_cmds = [c for c in cmds if "bgp" in str(c)] + self.assertEqual(bgp_cmds, []) + + def test_add_neighbor(self): + want_bgp = { + "system_as": 65001, + "neighbor": [ + {"address": "10.0.0.1", "remote_as": 65002, "description": "peer1"}, + {"address": "10.0.0.2", "remote_as": 65003}, + ], + } + cmds = build_commands( + {"instances": [{"name": "vrf1", "table_id": 101, "protocols": {"bgp": want_bgp}}]}, + _RAW_HAVE, + "merged", + ) + paths = [c[1] for c in cmds] + self.assertIn( + [ + "vrf", + "name", + "vrf1", + "protocols", + "bgp", + "neighbor", + "10.0.0.2", + "remote-as", + "65003", + ], + paths, + ) + self.assertNotIn( + [ + "vrf", + "name", + "vrf1", + "protocols", + "bgp", + "neighbor", + "10.0.0.1", + "remote-as", + "65002", + ], + paths, + ) + + +class TestOspfFromDevice(unittest.TestCase): + def setUp(self): + self.raw = _RAW_HAVE["name"]["vrf1"]["protocols"]["ospf"] + self.result = _proto_from_device(self.raw, "ospf") + + def test_areas(self): + self.assertEqual(len(self.result["areas"]), 1) + area = self.result["areas"][0] + self.assertEqual(area["area_id"], "0") + self.assertIn("10.0.0.0/24", area["networks"]) + self.assertIn("172.16.0.0/24", area["networks"]) + + def test_parameters(self): + self.assertEqual(self.result["parameters"]["router_id"], "10.0.0.1") + + def test_empty_input(self): + self.assertEqual(_proto_from_device({}, "ospf"), {}) + + +class TestOspfBuildCommands(unittest.TestCase): + def test_idempotent(self): + want = _proto_from_device(_RAW_HAVE["name"]["vrf1"]["protocols"]["ospf"], "ospf") + cmds = build_commands( + {"instances": [{"name": "vrf1", "table_id": 101, "protocols": {"ospf": want}}]}, + _RAW_HAVE, + "merged", + ) + ospf_cmds = [c for c in cmds if "ospf" in str(c)] + self.assertEqual(ospf_cmds, []) + + def test_add_network(self): + want_ospf = { + "areas": [ + {"area_id": "0", "networks": ["10.0.0.0/24", "172.16.0.0/24", "192.168.0.0/24"]}, + ], + } + cmds = build_commands( + {"instances": [{"name": "vrf1", "table_id": 101, "protocols": {"ospf": want_ospf}}]}, + _RAW_HAVE, + "merged", + ) + paths = [c[1] for c in cmds] + self.assertIn( + ["vrf", "name", "vrf1", "protocols", "ospf", "area", "0", "network", "192.168.0.0/24"], + paths, + ) + + def test_add_area(self): + want_ospf = { + "areas": [ + {"area_id": "0", "networks": ["10.0.0.0/24", "172.16.0.0/24"]}, + {"area_id": "1", "networks": ["10.1.0.0/24"]}, + ], + } + cmds = build_commands( + {"instances": [{"name": "vrf1", "table_id": 101, "protocols": {"ospf": want_ospf}}]}, + _RAW_HAVE, + "merged", + ) + paths = [c[1] for c in cmds] + self.assertIn( + ["vrf", "name", "vrf1", "protocols", "ospf", "area", "1", "network", "10.1.0.0/24"], + paths, + ) + + def test_change_router_id(self): + """Regression test: confirmed bug where router_id was missing + from _DEVICE_RENAMES. A brand-new router_id happened to work + via dict_op's own fallback conversion, and an unchanged value + happened to stay idempotent since both sides of the comparison + shared the same (wrong) key -- only *changing* an existing + router_id actually exposed the corrupted "router_id" (no + hyphen) device path, which VyOS would reject.""" + want_ospf = { + "areas": [ + {"area_id": "0", "networks": ["10.0.0.0/24", "172.16.0.0/24"]}, + ], + "parameters": {"router_id": "10.0.0.99"}, + } + cmds = build_commands( + {"instances": [{"name": "vrf1", "table_id": 101, "protocols": {"ospf": want_ospf}}]}, + _RAW_HAVE, + "merged", + ) + self.assertIn( + ( + "set", + [ + "vrf", + "name", + "vrf1", + "protocols", + "ospf", + "parameters", + "router-id", + "10.0.0.99", + ], + ), + cmds, + ) + paths = [c[1] for c in cmds] + self.assertFalse( + any("router_id" in p for p in paths), + "router_id (underscore) must never appear in a device path", + ) + + +class TestStaticFromDevice(unittest.TestCase): + def setUp(self): + self.raw = _RAW_HAVE["name"]["vrf1"]["protocols"]["static"] + self.result = _proto_from_device(self.raw, "static") + + def test_routes(self): + self.assertEqual(len(self.result["routes"]), 1) + route = self.result["routes"][0] + self.assertEqual(route["dest"], "192.168.10.0/24") + self.assertIn("10.0.0.254", route["next_hops"]) + + def test_empty_input(self): + self.assertEqual(_proto_from_device({}, "static"), {}) + + +class TestStaticBuildCommands(unittest.TestCase): + def test_idempotent(self): + want = _proto_from_device(_RAW_HAVE["name"]["vrf1"]["protocols"]["static"], "static") + cmds = build_commands( + {"instances": [{"name": "vrf1", "table_id": 101, "protocols": {"static": want}}]}, + _RAW_HAVE, + "merged", + ) + static_cmds = [c for c in cmds if "static" in str(c)] + self.assertEqual(static_cmds, []) + + def test_add_route(self): + want_static = { + "routes": [ + {"dest": "192.168.10.0/24", "next_hops": ["10.0.0.254"]}, + {"dest": "192.168.20.0/24", "next_hops": ["10.0.0.254"]}, + ], + } + cmds = build_commands( + { + "instances": [ + {"name": "vrf1", "table_id": 101, "protocols": {"static": want_static}}, + ], + }, + _RAW_HAVE, + "merged", + ) + paths = [c[1] for c in cmds] + self.assertIn( + [ + "vrf", + "name", + "vrf1", + "protocols", + "static", + "route", + "192.168.20.0/24", + "next-hop", + "10.0.0.254", + ], + paths, + ) + + +class TestProtocolsFromDevice(unittest.TestCase): + def test_all_protocols(self): + raw_vrf = _RAW_HAVE["name"]["vrf1"] + result = _protocols_from_device(raw_vrf) + self.assertIn("bgp", result) + self.assertIn("ospf", result) + self.assertIn("static", result) + + def test_no_protocols(self): + raw_vrf = _RAW_HAVE["name"]["vrf2"] + result = _protocols_from_device(raw_vrf) + self.assertIsNone(result) + + +class TestBuildCommands(unittest.TestCase): + def test_merged_new_vrf(self): + config = {"instances": [{"name": "vrf3", "table_id": 200, "vni": 2000}]} + cmds = build_commands(config, _RAW_HAVE, "merged") + paths = [c[1] for c in cmds] + self.assertIn(["vrf", "name", "vrf3", "table", "200"], paths) + self.assertIn(["vrf", "name", "vrf3", "vni", "2000"], paths) + + def test_merged_idempotent(self): + config = { + "bind_to_all": True, + "instances": [ + {"name": "vrf1", "description": "red", "table_id": 101, "vni": 501}, + ], + } + cmds = build_commands(config, _RAW_HAVE, "merged") + self.assertEqual(cmds, []) + + def test_deleted_specific_vrf(self): + config = {"instances": [{"name": "vrf1"}]} + cmds = build_commands(config, _RAW_HAVE, "deleted") + self.assertIn(("delete", ["vrf", "name", "vrf1"]), cmds) + self.assertNotIn(("delete", ["vrf", "name", "vrf2"]), cmds) + + def test_deleted_all(self): + cmds = build_commands({}, _RAW_HAVE, "deleted") + self.assertIn(("delete", ["vrf"]), cmds) + + def test_overridden_removes_extra_vrf(self): + config = {"instances": [{"name": "vrf1", "table_id": 101}]} + cmds = build_commands(config, _RAW_HAVE, "overridden") + paths = [c[1] for c in cmds] + self.assertIn(["vrf", "name", "vrf2"], paths) + + def test_merged_does_not_delete_unreferenced_vrf(self): + config = {"instances": [{"name": "vrf1", "table_id": 101}]} + cmds = build_commands(config, _RAW_HAVE, "merged") + paths = [c[1] for c in cmds] + self.assertNotIn(["vrf", "name", "vrf2"], paths) + + +if __name__ == "__main__": + unittest.main() |
