summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authoromnom62 <75066712+omnom62@users.noreply.github.com>2026-09-28 22:34:13 +1000
committerGitHub <noreply@github.com>2026-09-28 13:34:13 +0100
commit7cfca28e5857b480920c22ae12557f55ca2a8a19 (patch)
tree8e3bfc20dc621371af85b9d62e89b4f3e1838c01
parent916b2cc86b29aabe3778be8fa88393f6a5324832 (diff)
downloadrest.vyos-7cfca28e5857b480920c22ae12557f55ca2a8a19.tar.gz
rest.vyos-7cfca28e5857b480920c22ae12557f55ca2a8a19.zip
T8989: vyos vrf module (#39)HEADmain
* 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>
-rw-r--r--README.md1
-rw-r--r--changelogs/fragments/t8989_vrf.yml3
-rw-r--r--docs/vyos.rest.vyos_vrf_module.rst861
-rw-r--r--plugins/module_utils/vyos.py21
-rw-r--r--plugins/modules/vyos_vrf.py1028
-rw-r--r--tests/integration/targets/vyos_vrf/aliases1
-rw-r--r--tests/integration/targets/vyos_vrf/defaults/main.yaml3
-rw-r--r--tests/integration/targets/vyos_vrf/tasks/httpapi.yaml21
-rw-r--r--tests/integration/targets/vyos_vrf/tasks/main.yaml5
-rw-r--r--tests/integration/targets/vyos_vrf/tests/httpapi/_populate_config.yaml23
-rw-r--r--tests/integration/targets/vyos_vrf/tests/httpapi/_remove_config.yaml4
-rw-r--r--tests/integration/targets/vyos_vrf/tests/httpapi/deleted.yaml50
-rw-r--r--tests/integration/targets/vyos_vrf/tests/httpapi/gathered.yaml27
-rw-r--r--tests/integration/targets/vyos_vrf/tests/httpapi/merged.yaml59
-rw-r--r--tests/integration/targets/vyos_vrf/tests/httpapi/merged_protocols.yaml117
-rw-r--r--tests/integration/targets/vyos_vrf/tests/httpapi/overridden.yaml50
-rw-r--r--tests/integration/targets/vyos_vrf/tests/httpapi/replaced.yaml52
-rw-r--r--tests/integration/targets/vyos_vrf/tests/httpapi/rtt.yaml82
-rw-r--r--tests/integration/targets/vyos_vrf/vars/main.yaml2
-rw-r--r--tests/unit/fixtures/vrf_running.json42
-rw-r--r--tests/unit/modules/test_vyos_vrf.py369
21 files changed, 2821 insertions, 0 deletions
diff --git a/README.md b/README.md
index 5d33329..d0c88d2 100644
--- a/README.md
+++ b/README.md
@@ -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>&nbsp;&larr;</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>&nbsp;&larr;</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>&nbsp;&larr;</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&#x27;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>&nbsp;&larr;</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()