summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--changelogs/fragments/t8989_l3_interface_dict_op.yml3
-rw-r--r--changelogs/fragments/t8989_ospf_interfaces_dict_op.yml3
-rw-r--r--changelogs/fragments/t8989_ospfv3_dict_op.yml3
-rw-r--r--changelogs/fragments/t8989_ospv2_dict_op.yml5
-rw-r--r--changelogs/fragments/t8989_vyos_get_value.yml3
-rw-r--r--docs/vyos.rest.vyos_l3_interfaces_module.rst51
-rw-r--r--docs/vyos.rest.vyos_ospf_interfaces_module.rst57
-rw-r--r--docs/vyos.rest.vyos_ospfv2_module.rst659
-rw-r--r--docs/vyos.rest.vyos_ospfv3_module.rst16
-rw-r--r--plugins/module_utils/vyos.py6
-rw-r--r--plugins/modules/vyos_l3_interfaces.py372
-rw-r--r--plugins/modules/vyos_ospf_interfaces.py677
-rw-r--r--plugins/modules/vyos_ospfv2.py1327
-rw-r--r--plugins/modules/vyos_ospfv3.py367
-rw-r--r--tests/unit/fixtures/l3_interfaces_running.json19
-rw-r--r--tests/unit/fixtures/ospf_interfaces_ipv4.json13
-rw-r--r--tests/unit/fixtures/ospf_interfaces_ipv6.json7
-rw-r--r--tests/unit/fixtures/ospfv2_running.json71
-rw-r--r--tests/unit/fixtures/ospfv3_running.json11
-rw-r--r--tests/unit/modules/test_vyos_l3_interfaces.py448
-rw-r--r--tests/unit/modules/test_vyos_ospf_interfaces.py298
-rw-r--r--tests/unit/modules/test_vyos_ospfv2.py408
-rw-r--r--tests/unit/modules/test_vyos_ospfv3.py260
-rw-r--r--tests/unit/test_module_utils_vyos.py88
24 files changed, 3177 insertions, 1995 deletions
diff --git a/changelogs/fragments/t8989_l3_interface_dict_op.yml b/changelogs/fragments/t8989_l3_interface_dict_op.yml
new file mode 100644
index 0000000..5cccc00
--- /dev/null
+++ b/changelogs/fragments/t8989_l3_interface_dict_op.yml
@@ -0,0 +1,3 @@
+---
+minor_changes:
+ - vyos_l3_interfaces - Refactor the module to comply with dict_op paradigm.
diff --git a/changelogs/fragments/t8989_ospf_interfaces_dict_op.yml b/changelogs/fragments/t8989_ospf_interfaces_dict_op.yml
new file mode 100644
index 0000000..81a23f5
--- /dev/null
+++ b/changelogs/fragments/t8989_ospf_interfaces_dict_op.yml
@@ -0,0 +1,3 @@
+---
+minor_changes:
+ - vyos_ospf_interfaces - Refactor module to dict_op arch, add more tests.
diff --git a/changelogs/fragments/t8989_ospfv3_dict_op.yml b/changelogs/fragments/t8989_ospfv3_dict_op.yml
new file mode 100644
index 0000000..b151ac3
--- /dev/null
+++ b/changelogs/fragments/t8989_ospfv3_dict_op.yml
@@ -0,0 +1,3 @@
+---
+minor_changes:
+ - vyos_ospfv3 - Refactor module to dict_op style.
diff --git a/changelogs/fragments/t8989_ospv2_dict_op.yml b/changelogs/fragments/t8989_ospv2_dict_op.yml
new file mode 100644
index 0000000..5a0f78d
--- /dev/null
+++ b/changelogs/fragments/t8989_ospv2_dict_op.yml
@@ -0,0 +1,5 @@
+---
+minor_changes:
+ - vyos_ospfv2 - refactor command generation onto ``dict_op``.
+ - vyos_ospfv2 - add support for ``areas[].virtual_link``, ``max_metric``,
+ ``mpls_te``, ``timers``, and ``passive_interface_exclude``.
diff --git a/changelogs/fragments/t8989_vyos_get_value.yml b/changelogs/fragments/t8989_vyos_get_value.yml
new file mode 100644
index 0000000..f6b7043
--- /dev/null
+++ b/changelogs/fragments/t8989_vyos_get_value.yml
@@ -0,0 +1,3 @@
+---
+minor_changes:
+ - plugins/module_utils/vyos.py - Add VyOSModule.get_value() wrapper for the "returnValue" retrieve operation.
diff --git a/docs/vyos.rest.vyos_l3_interfaces_module.rst b/docs/vyos.rest.vyos_l3_interfaces_module.rst
index d251abb..784a565 100644
--- a/docs/vyos.rest.vyos_l3_interfaces_module.rst
+++ b/docs/vyos.rest.vyos_l3_interfaces_module.rst
@@ -18,7 +18,7 @@ Version added: 1.0.0
Synopsis
--------
- Manages IPv4 and IPv6 address configuration on VyOS interfaces using the HTTPS REST API.
-- Mirrors ``vyos.vyos.vyos_l3_interfaces`` but uses the HTTP API instead of CLI.
+- L2 attributes (description, mtu, duplex, speed, vrf) and VIF presence/L2 attributes are owned by :ref:`vyos.rest.vyos_interfaces <vyos.rest.vyos_interfaces_module>`, not this module -- confirmed by design: a VIF's device path is shared between the two modules, and this module's own commands only ever touch the ``address`` leaf within it, never the VIF subtree as a whole or any of vyos_interfaces' own fields.
@@ -251,21 +251,6 @@ Parameters
<tr>
<td colspan="4">
<div class="ansibleOptionAnchor" id="parameter-"></div>
- <b>running_config</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>Used only with state <code>parsed</code>.</div>
- </td>
- </tr>
- <tr>
- <td colspan="4">
- <div class="ansibleOptionAnchor" id="parameter-"></div>
<b>state</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
<div style="font-size: small">
@@ -279,8 +264,6 @@ Parameters
<li>overridden</li>
<li>deleted</li>
<li>gathered</li>
- <li>rendered</li>
- <li>parsed</li>
</ul>
</td>
<td>
@@ -289,8 +272,6 @@ Parameters
<div><code>overridden</code> - Replace addresses for all interfaces.</div>
<div><code>deleted</code> - Remove listed or all interface addresses.</div>
<div><code>gathered</code> - Read interface addresses from device without changes.</div>
- <div><code>rendered</code> - Return commands for provided config without connecting.</div>
- <div><code>parsed</code> - Parse running_config into structured data.</div>
</td>
</tr>
</table>
@@ -423,36 +404,6 @@ Common return values are documented `here <https://docs.ansible.com/ansible/late
<tr>
<td colspan="1">
<div class="ansibleOptionAnchor" id="return-"></div>
- <b>parsed</b>
- <a class="ansibleOptionLink" href="#return-" title="Permalink to this return value"></a>
- <div style="font-size: small">
- <span style="color: purple">list</span>
- </div>
- </td>
- <td>when state is parsed</td>
- <td>
- <div>Structured data parsed from running_config (state=parsed).</div>
- <br/>
- </td>
- </tr>
- <tr>
- <td colspan="1">
- <div class="ansibleOptionAnchor" id="return-"></div>
- <b>rendered</b>
- <a class="ansibleOptionLink" href="#return-" title="Permalink to this return value"></a>
- <div style="font-size: small">
- <span style="color: purple">list</span>
- </div>
- </td>
- <td>when state is rendered</td>
- <td>
- <div>Commands for the provided config (state=rendered).</div>
- <br/>
- </td>
- </tr>
- <tr>
- <td colspan="1">
- <div class="ansibleOptionAnchor" id="return-"></div>
<b>response</b>
<a class="ansibleOptionLink" href="#return-" title="Permalink to this return value"></a>
<div style="font-size: small">
diff --git a/docs/vyos.rest.vyos_ospf_interfaces_module.rst b/docs/vyos.rest.vyos_ospf_interfaces_module.rst
index 1439a71..60424ac 100644
--- a/docs/vyos.rest.vyos_ospf_interfaces_module.rst
+++ b/docs/vyos.rest.vyos_ospf_interfaces_module.rst
@@ -17,10 +17,7 @@ Version added: 1.0.0
Synopsis
--------
-- Manages OSPF and OSPFv3 interface configuration on VyOS devices via the REST API.
-- IPv4 OSPF maps to ``protocols ospf interface``.
-- IPv6 OSPFv3 maps to ``protocols ospfv3 interface``.
-- Uses REST API (``connection=httpapi``) instead of CLI.
+- Manages OSPFv2 (IPv4) and OSPFv3 (IPv6) per-interface configuration on VyOS devices via the REST API.
@@ -96,6 +93,23 @@ Parameters
<td class="elbow-placeholder"></td>
<td colspan="3">
<div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>area</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 area to assign this interface to (<code>set protocols ospf interface &lt;name&gt; area &lt;id&gt;</code> for OSPFv2, <code>set protocols ospfv3 interface &lt;name&gt; area &lt;id&gt;</code> for OSPFv3). This is a per-interface alternative to enabling OSPF via <code>area &lt;id&gt; network &lt;prefix&gt;</code> -- VyOS&#x27;s own documentation frames the network-based form as standard and this per-interface form as more convenient in some cases (many interfaces/subnets). Confirmed via VyOS&#x27;s official documentation across 1.4+/1.5 LTS/rolling as the current syntax, distinct from and superseding the older (1.3-era) <code>area &lt;id&gt; interface &lt;name&gt;</code> form.</div>
+ </td>
+ </tr>
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td colspan="3">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
<b>authentication</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
<div style="font-size: small">
@@ -198,7 +212,7 @@ Parameters
<td>
</td>
<td>
- <div>Interface bandwidth in kbps (IPv4 only).</div>
+ <div>Interface bandwidth in Mbit/s (IPv4 only).</div>
</td>
</tr>
<tr>
@@ -304,7 +318,7 @@ Parameters
</ul>
</td>
<td>
- <div>Disable MTU check (IPv4 only).</div>
+ <div>Disable MTU mismatch detection.</div>
</td>
</tr>
<tr>
@@ -327,7 +341,7 @@ Parameters
</ul>
</td>
<td>
- <div>Network type (IPv4 only).</div>
+ <div>Network type.</div>
</td>
</tr>
<tr>
@@ -348,7 +362,7 @@ Parameters
</ul>
</td>
<td>
- <div>Disable adjacency formation (IPv6 only).</div>
+ <div>Suppress adjacency formation on this interface.</div>
</td>
</tr>
<tr>
@@ -460,6 +474,14 @@ Notes
- ``ansible_network_os`` must be set to ``vyos.rest.vyos``.
+See Also
+--------
+
+.. seealso::
+
+ :ref:`vyos.vyos.vyos_ospf_interfaces_module`
+ The official documentation on the **vyos.vyos.vyos_ospf_interfaces** module.
+
Examples
--------
@@ -473,27 +495,8 @@ Examples
address_family:
- afi: ipv4
cost: 100
- transmit_delay: 50
- priority: 26
- - afi: ipv6
- dead_interval: 39
- passive: true
state: merged
- - name: Delete OSPF interface configuration
- vyos.rest.vyos_ospf_interfaces:
- config:
- - name: eth1
- state: deleted
-
- - name: Delete all OSPF interface configuration
- vyos.rest.vyos_ospf_interfaces:
- state: deleted
-
- - name: Gather current OSPF interface configuration
- vyos.rest.vyos_ospf_interfaces:
- state: gathered
-
Return Values
diff --git a/docs/vyos.rest.vyos_ospfv2_module.rst b/docs/vyos.rest.vyos_ospfv2_module.rst
index 1c5c248..9e0c41b 100644
--- a/docs/vyos.rest.vyos_ospfv2_module.rst
+++ b/docs/vyos.rest.vyos_ospfv2_module.rst
@@ -19,7 +19,8 @@ Synopsis
--------
- Manages OSPFv2 configuration on VyOS devices via the REST API.
- Uses REST API (``connection=httpapi``) instead of CLI.
-- In VyOS 1.5+, passive interfaces use per-interface config rather than passive-interface.
+- Scope matches the current vyos.vyos.vyos_ospfv2 (CLI collection) module: areas (including virtual_link), auto_cost, default_information, default_metric, distance, log_adjacency_changes, max_metric, mpls_te, neighbor, parameters, passive_interface, passive_interface_exclude, redistribute, timers. VyOS's OSPF schema is considerably larger than even this -- access-list, aggregation, capability, graceful-restart, ldp-sync, maximum-paths, per-interface tuning (bandwidth/hello-multiplier/network-type/authentication/intervals beyond passive), segment-routing, and summary-address are not modeled here, matching real gaps in the CLI module's own scope, not oversights.
+- A standalone top-level ``route_map`` field exists in the CLI module's argspec but does not correspond to any real device path -- confirmed against a live VyOS 1.5.0 device's ``set protocols ospf`` completions, which list no ``route-map`` entry at that level (only nested under ``default-information originate`` and per-protocol under ``redistribute``, both of which are modeled here). Deliberately omitted rather than built against a confirmed non-functional field.
@@ -31,12 +32,12 @@ Parameters
<table border=0 cellpadding=0 class="documentation-table">
<tr>
- <th colspan="5">Parameter</th>
+ <th colspan="6">Parameter</th>
<th>Choices/<font color="blue">Defaults</font></th>
<th width="100%">Comments</th>
</tr>
<tr>
- <td colspan="5">
+ <td colspan="6">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>config</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -52,7 +53,7 @@ Parameters
</tr>
<tr>
<td class="elbow-placeholder"></td>
- <td colspan="4">
+ <td colspan="5">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>areas</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -70,7 +71,7 @@ Parameters
<tr>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="3">
+ <td colspan="4">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>area_id</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -88,7 +89,7 @@ Parameters
<tr>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="3">
+ <td colspan="4">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>area_type</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -106,7 +107,7 @@ Parameters
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="2">
+ <td colspan="3">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>normal</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -128,7 +129,7 @@ Parameters
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="2">
+ <td colspan="3">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>nssa</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -147,7 +148,7 @@ Parameters
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="1">
+ <td colspan="2">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>default_cost</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -166,7 +167,7 @@ Parameters
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="1">
+ <td colspan="2">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>no_summary</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -189,7 +190,7 @@ Parameters
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="1">
+ <td colspan="2">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>set</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -212,7 +213,7 @@ Parameters
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="1">
+ <td colspan="2">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>translate</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -236,7 +237,7 @@ Parameters
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="2">
+ <td colspan="3">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>stub</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -255,7 +256,7 @@ Parameters
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="1">
+ <td colspan="2">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>default_cost</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -274,7 +275,7 @@ Parameters
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="1">
+ <td colspan="2">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>no_summary</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -297,7 +298,7 @@ Parameters
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="1">
+ <td colspan="2">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>set</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -320,7 +321,7 @@ Parameters
<tr>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="3">
+ <td colspan="4">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>authentication</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -341,7 +342,7 @@ Parameters
<tr>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="3">
+ <td colspan="4">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>network</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -360,7 +361,7 @@ Parameters
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="2">
+ <td colspan="3">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>address</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -379,7 +380,7 @@ Parameters
<tr>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="3">
+ <td colspan="4">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>range</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -398,7 +399,7 @@ Parameters
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="2">
+ <td colspan="3">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>address</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -417,7 +418,7 @@ Parameters
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="2">
+ <td colspan="3">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>cost</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -435,7 +436,7 @@ Parameters
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="2">
+ <td colspan="3">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>not_advertise</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -457,7 +458,7 @@ Parameters
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="2">
+ <td colspan="3">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>substitute</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -475,7 +476,7 @@ Parameters
<tr>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="3">
+ <td colspan="4">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>shortcut</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -494,11 +495,220 @@ Parameters
<div>Shortcut mode.</div>
</td>
</tr>
-
<tr>
<td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
<td colspan="4">
<div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>virtual_link</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>Virtual link.</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>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>Virtual link address (router ID of the remote ABR).</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>authentication</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>Virtual link authentication.</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>md5</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>MD5 key id based authentication.</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>key_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>MD5 key id (1-255).</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>md5_key</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>MD5 key (16 characters or less).</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>plaintext_password</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>Plain text password (8 characters or less).</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>dead_interval</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>Interval after which a neighbor is declared dead.</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>hello_interval</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>Interval between hello packets.</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>retransmit_interval</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>Interval between retransmitting lost link state advertisements.</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>transmit_delay</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>Link state transmit delay.</div>
+ </td>
+ </tr>
+
+
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td colspan="5">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
<b>auto_cost</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
<div style="font-size: small">
@@ -514,7 +724,7 @@ Parameters
<tr>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="3">
+ <td colspan="4">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>reference_bandwidth</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -531,7 +741,7 @@ Parameters
<tr>
<td class="elbow-placeholder"></td>
- <td colspan="4">
+ <td colspan="5">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>default_information</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -548,7 +758,7 @@ Parameters
<tr>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="3">
+ <td colspan="4">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>originate</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -566,7 +776,7 @@ Parameters
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="2">
+ <td colspan="3">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>always</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -588,7 +798,7 @@ Parameters
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="2">
+ <td colspan="3">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>metric</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -606,7 +816,7 @@ Parameters
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="2">
+ <td colspan="3">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>metric_type</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -624,7 +834,7 @@ Parameters
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="2">
+ <td colspan="3">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>route_map</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -642,7 +852,7 @@ Parameters
<tr>
<td class="elbow-placeholder"></td>
- <td colspan="4">
+ <td colspan="5">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>default_metric</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -658,7 +868,7 @@ Parameters
</tr>
<tr>
<td class="elbow-placeholder"></td>
- <td colspan="4">
+ <td colspan="5">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>distance</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -675,7 +885,7 @@ Parameters
<tr>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="3">
+ <td colspan="4">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>global</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -692,7 +902,7 @@ Parameters
<tr>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="3">
+ <td colspan="4">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>ospf</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -710,7 +920,7 @@ Parameters
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="2">
+ <td colspan="3">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>external</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -728,7 +938,7 @@ Parameters
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="2">
+ <td colspan="3">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>inter_area</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -746,7 +956,7 @@ Parameters
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="2">
+ <td colspan="3">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>intra_area</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -764,7 +974,7 @@ Parameters
<tr>
<td class="elbow-placeholder"></td>
- <td colspan="4">
+ <td colspan="5">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>log_adjacency_changes</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -783,8 +993,156 @@ Parameters
</tr>
<tr>
<td class="elbow-placeholder"></td>
+ <td colspan="5">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>max_metric</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 maximum/infinite-distance metric.</div>
+ </td>
+ </tr>
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td colspan="4">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>router_lsa</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>Advertise own Router-LSA with infinite distance (stub router).</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>administrative</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>Administratively apply, for an indefinite period.</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>on_shutdown</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>Time (seconds) to advertise self as stub-router before shutdown.</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>on_startup</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>Time (seconds) to advertise self as stub-router on startup.</div>
+ </td>
+ </tr>
+
+
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td colspan="5">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>mpls_te</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>MPLS Traffic Engineering parameters.</div>
+ </td>
+ </tr>
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td colspan="4">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>enabled</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">boolean</span>
+ </div>
+ </td>
+ <td>
+ <ul style="margin: 0; padding: 0"><b>Choices:</b>
+ <li>no</li>
+ <li>yes</li>
+ </ul>
+ </td>
+ <td>
+ <div>Enable MPLS-TE functionality.</div>
+ </td>
+ </tr>
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
<td colspan="4">
<div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>router_address</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">string</span>
+ </div>
+ </td>
+ <td>
+ </td>
+ <td>
+ <div>Stable IP address of the advertising router.</div>
+ </td>
+ </tr>
+
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td colspan="5">
+ <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">
@@ -801,7 +1159,7 @@ Parameters
<tr>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="3">
+ <td colspan="4">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>neighbor_id</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -819,7 +1177,7 @@ Parameters
<tr>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="3">
+ <td colspan="4">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>poll_interval</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -836,7 +1194,7 @@ Parameters
<tr>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="3">
+ <td colspan="4">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>priority</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -853,7 +1211,7 @@ Parameters
<tr>
<td class="elbow-placeholder"></td>
- <td colspan="4">
+ <td colspan="5">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>parameters</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -870,7 +1228,7 @@ Parameters
<tr>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="3">
+ <td colspan="4">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>abr_type</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -893,7 +1251,7 @@ Parameters
<tr>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="3">
+ <td colspan="4">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>opaque_lsa</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -914,7 +1272,7 @@ Parameters
<tr>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="3">
+ <td colspan="4">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>rfc1583_compatibility</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -935,7 +1293,7 @@ Parameters
<tr>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="3">
+ <td colspan="4">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>router_id</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -952,7 +1310,7 @@ Parameters
<tr>
<td class="elbow-placeholder"></td>
- <td colspan="4">
+ <td colspan="5">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>passive_interface</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -964,12 +1322,29 @@ Parameters
<td>
</td>
<td>
- <div>Passive interfaces (VyOS 1.5+: configured via <code>protocols ospf interface &lt;name&gt; passive</code>).</div>
+ <div>Interfaces to suppress routing updates on, via per-interface configuration (<code>protocols ospf interface &lt;name&gt; passive</code>).</div>
</td>
</tr>
<tr>
<td class="elbow-placeholder"></td>
- <td colspan="4">
+ <td colspan="5">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>passive_interface_exclude</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>Interfaces to explicitly exclude from passive mode (via <code>protocols ospf interface &lt;name&gt; passive disable</code>) -- e.g. when passive is otherwise applied broadly.</div>
+ </td>
+ </tr>
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td colspan="5">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>redistribute</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -987,7 +1362,7 @@ Parameters
<tr>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="3">
+ <td colspan="4">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>metric</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -1004,7 +1379,7 @@ Parameters
<tr>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="3">
+ <td colspan="4">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>metric_type</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -1021,7 +1396,7 @@ Parameters
<tr>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="3">
+ <td colspan="4">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>route_map</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -1038,7 +1413,7 @@ Parameters
<tr>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="3">
+ <td colspan="4">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>route_type</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -1060,10 +1435,157 @@ Parameters
</td>
</tr>
-
<tr>
+ <td class="elbow-placeholder"></td>
<td colspan="5">
<div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>timers</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>Routing timers.</div>
+ </td>
+ </tr>
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td colspan="4">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>refresh</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>Refresh parameters.</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>timers</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>Refresh timer (seconds).</div>
+ </td>
+ </tr>
+
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td colspan="4">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>throttle</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>Throttling adaptive timers.</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>spf</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>SPF timers.</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>delay</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>Delay (ms) from first change received to SPF calculation.</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>initial_holdtime</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>Initial hold time (ms) between consecutive SPF calculations.</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>max_holdtime</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>Maximum hold time (ms).</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">
@@ -1096,9 +1618,16 @@ Notes
.. note::
- Requires ``ansible_connection=httpapi`` with the VyOS httpapi plugin.
- ``ansible_network_os`` must be set to ``vyos.rest.vyos``.
- - VyOS 1.5+ uses per-interface passive configuration rather than the global ``passive-interface`` command used in VyOS 1.4.
+See Also
+--------
+
+.. seealso::
+
+ :ref:`vyos.vyos.vyos_ospfv2_module`
+ The official documentation on the **vyos.vyos.vyos_ospfv2** module.
+
Examples
--------
@@ -1119,10 +1648,6 @@ Examples
normal: true
network:
- address: 192.0.2.0/24
- - area_id: "3"
- area_type:
- nssa:
- set: true
- area_id: "4"
area_type:
stub:
@@ -1135,6 +1660,16 @@ Examples
metric: 10
passive_interface:
- eth1
+ max_metric:
+ router_lsa:
+ administrative: true
+ mpls_te:
+ enabled: true
+ router_address: 192.0.11.11
+ timers:
+ throttle:
+ spf:
+ delay: 200
state: merged
- name: Delete all OSPFv2 configuration
diff --git a/docs/vyos.rest.vyos_ospfv3_module.rst b/docs/vyos.rest.vyos_ospfv3_module.rst
index 18bb48e..2b63cf5 100644
--- a/docs/vyos.rest.vyos_ospfv3_module.rst
+++ b/docs/vyos.rest.vyos_ospfv3_module.rst
@@ -19,6 +19,9 @@ Synopsis
--------
- Manages OSPFv3 configuration on VyOS devices via the REST API.
- Uses REST API (``connection=httpapi``) instead of CLI.
+- Scope matches the current vyos.vyos.vyos_ospfv3 (CLI collection) module, confirmed against VyOS's official documentation (1.4+/1.5 LTS/rolling).
+- ``areas.interface`` (an area-to-interface assignment list) exists in the CLI module's argspec but was deliberately NOT carried over here -- confirmed via VyOS's official docs across multiple versions that ``set protocols ospfv3 area <id> interface <name>`` is the superseded, 1.3-era syntax. The current mechanism, ``set protocols ospfv3 interface <name> area <id>``, is a per-interface setting and is modeled in :ref:`vyos.rest.vyos_ospf_interfaces <vyos.rest.vyos_ospf_interfaces_module>`'s ``area`` field instead (and is not affected by this module's ``state=replaced``).
+- ``distance`` and ``graceful-restart`` are real, confirmed OSPFv3 features not modeled here, matching a genuine gap in the CLI module's own scope rather than an oversight.
@@ -133,7 +136,7 @@ Parameters
<td>
</td>
<td>
- <div>Summarize routes matching prefix.</div>
+ <div>Summarize routes matching prefix (border routers only).</div>
</td>
</tr>
<tr>
@@ -278,6 +281,7 @@ Parameters
<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>
@@ -332,6 +336,16 @@ Notes
- ``ansible_network_os`` must be set to ``vyos.rest.vyos``.
+See Also
+--------
+
+.. seealso::
+
+ :ref:`vyos.vyos.vyos_ospfv3_module`
+ The official documentation on the **vyos.vyos.vyos_ospfv3** module.
+ :ref:`vyos.rest.vyos_ospf_interfaces_module`
+ The official documentation on the **vyos.rest.vyos_ospf_interfaces** module.
+
Examples
--------
diff --git a/plugins/module_utils/vyos.py b/plugins/module_utils/vyos.py
index f7c17f9..b7a519e 100644
--- a/plugins/module_utils/vyos.py
+++ b/plugins/module_utils/vyos.py
@@ -320,7 +320,8 @@ class VyOSModule:
that's actually already correct.
"""
result = self._client.retrieve_return_value(path)
- return result.get("data") or ""
+ data = result.get("data")
+ return "" if data is None else data
def apply_commands(self, commands):
if not commands:
@@ -372,7 +373,8 @@ class VyOSModule:
rather than being indistinguishable from a valid empty response.
"""
result = self._client.show(path)
- return result.get("data") or ""
+ data = result.get("data")
+ return "" if data is None else data
def save_config(self, file_path=None):
"""Save the running configuration to disk."""
diff --git a/plugins/modules/vyos_l3_interfaces.py b/plugins/modules/vyos_l3_interfaces.py
index 9b33973..c9c6deb 100644
--- a/plugins/modules/vyos_l3_interfaces.py
+++ b/plugins/modules/vyos_l3_interfaces.py
@@ -14,7 +14,13 @@ short_description: Manage L3 interface configuration on VyOS devices via REST AP
description:
- Manages IPv4 and IPv6 address configuration on VyOS interfaces using the
HTTPS REST API.
- - Mirrors C(vyos.vyos.vyos_l3_interfaces) but uses the HTTP API instead of CLI.
+ - >-
+ L2 attributes (description, mtu, duplex, speed, vrf) and VIF
+ presence/L2 attributes are owned by M(vyos.rest.vyos_interfaces), not
+ this module -- confirmed by design: a VIF's device path is shared
+ between the two modules, and this module's own commands only ever
+ touch the C(address) leaf within it, never the VIF subtree as a whole
+ or any of vyos_interfaces' own fields.
version_added: "1.0.0"
author:
- VyOS Community (@vyos)
@@ -71,9 +77,6 @@ options:
address:
description: IPv6 address in CIDR notation, C(dhcpv6), or C(auto-config).
type: str
- running_config:
- description: Used only with state C(parsed).
- type: str
state:
description:
- C(merged) - Add addresses without removing existing ones.
@@ -81,10 +84,8 @@ options:
- C(overridden) - Replace addresses for all interfaces.
- C(deleted) - Remove listed or all interface addresses.
- C(gathered) - Read interface addresses from device without changes.
- - C(rendered) - Return commands for provided config without connecting.
- - C(parsed) - Parse running_config into structured data.
type: str
- choices: [merged, replaced, overridden, deleted, gathered, rendered, parsed]
+ choices: [merged, replaced, overridden, deleted, gathered]
default: merged
seealso:
- module: vyos.vyos.vyos_l3_interfaces
@@ -142,14 +143,6 @@ gathered:
description: Current L3 interface configuration as structured data.
returned: when state is gathered
type: list
-rendered:
- description: Commands for the provided config (state=rendered).
- returned: when state is rendered
- type: list
-parsed:
- description: Structured data parsed from running_config (state=parsed).
- returned: when state is parsed
- type: list
saved:
description: Whether the config was saved after changes.
returned: when changes are applied
@@ -161,10 +154,16 @@ response:
"""
from ansible.module_utils.basic import AnsibleModule
-from ansible_collections.vyos.rest.plugins.module_utils.vyos import VyOSModule
+from ansible_collections.vyos.rest.plugins.module_utils.vyos import VyOSModule, to_tag_dict
+
+_BASE = ["interfaces"]
-_IFACE_TYPE = {
+# Aligned with vyos_interfaces' own 11-type table -- confirmed
+# inconsistent in the original (missing ppp/wlan here specifically),
+# corrected for consistency between the two modules that share the
+# same interface namespace.
+_IFACE_TYPE_PREFIX = {
"eth": "ethernet",
"bond": "bonding",
"lo": "loopback",
@@ -173,23 +172,42 @@ _IFACE_TYPE = {
"vti": "vti",
"dum": "dummy",
"vtun": "openvpn",
+ "ppp": "pppoe",
+ "wlan": "wireless",
"br": "bridge",
}
-def _iface_type(name):
- for prefix, itype in _IFACE_TYPE.items():
+def _guess_iface_type(name):
+ for prefix, itype in _IFACE_TYPE_PREFIX.items():
if name.startswith(prefix):
return itype
return "ethernet"
-def _iface_base(name):
- return ["interfaces", _iface_type(name), name]
+def _resolve_iface_type(name, raw_have):
+ """Prefer the real type from the device's own raw response over a
+ name-prefix guess -- only fall back to guessing for a brand-new
+ interface that doesn't exist on the device yet. Same fix as
+ vyos_interfaces' own confirmed bug: the original guessed
+ unconditionally, even for interfaces the device already knows the
+ real type of.
+ """
+ for itype, ifaces in (raw_have or {}).items():
+ if name in to_tag_dict(ifaces):
+ return itype
+ return _guess_iface_type(name)
+
+
+def _iface_base(name, raw_have):
+ return _BASE + [_resolve_iface_type(name, raw_have), name]
def _addr_list(raw):
- """Normalize address field — string or list → sorted list."""
+ """VyOS's address leaf collapses to a bare string for a single
+ value, or a list for multiple -- confirmed device behavior,
+ handled explicitly here since it's a plain multi-value leaf, not
+ a tag-node (to_tag_dict doesn't apply)."""
if not raw:
return []
if isinstance(raw, str):
@@ -200,92 +218,105 @@ def _addr_list(raw):
def _split_addresses(addresses):
- """Split address list into ipv4 and ipv6 lists."""
ipv4 = []
ipv6 = []
for addr in addresses:
- if addr in ("dhcp", "dhcpv6"):
- if addr == "dhcp":
- ipv4.append(addr)
- else:
- ipv6.append(addr)
- elif ":" in addr:
+ if addr == "dhcp":
+ ipv4.append(addr)
+ elif addr in ("dhcpv6", "auto-config") or ":" in addr:
ipv6.append(addr)
else:
ipv4.append(addr)
return sorted(ipv4), sorted(ipv6)
-def _parse_iface(name, idata):
- """Parse raw API interface data into argspec format."""
- idata = idata or {}
- entry = {"name": name}
-
- addrs = _addr_list(idata.get("address"))
- if addrs:
- ipv4, ipv6 = _split_addresses(addrs)
- if ipv4:
- entry["ipv4"] = [{"address": a} for a in ipv4]
- if ipv6:
- entry["ipv6"] = [{"address": a} for a in ipv6]
-
- vif_data = idata.get("vif") or {}
- if isinstance(vif_data, dict) and vif_data:
- vifs = []
- for vlan_id, vdata in sorted(vif_data.items(), key=lambda x: int(x[0])):
- vdata = vdata or {}
- vif = {"vlan_id": int(vlan_id)}
- vaddrs = _addr_list(vdata.get("address"))
- if vaddrs:
- vipv4, vipv6 = _split_addresses(vaddrs)
- if vipv4:
- vif["ipv4"] = [{"address": a} for a in vipv4]
- if vipv6:
- vif["ipv6"] = [{"address": a} for a in vipv6]
- vifs.append(vif)
- if vifs:
- entry["vifs"] = vifs
-
- return entry
+def _parse_addr_entry(idata):
+ """Returns (ipv4_argspec_list, ipv6_argspec_list) for one address
+ leaf's raw value -- shared by both interface-level and vif-level
+ parsing."""
+ addrs = _addr_list((idata or {}).get("address"))
+ if not addrs:
+ return [], []
+ ipv4, ipv6 = _split_addresses(addrs)
+ return (
+ [{"address": a} for a in ipv4],
+ [{"address": a} for a in ipv6],
+ )
def get_running_config(vyos):
- raw = vyos.get_config(["interfaces"])
- if not raw or not isinstance(raw, dict):
- return []
+ """VyOS's REST API collapses a single-child tag node to a plain
+ string (or a list for multiple) -- confirmed as a real failure
+ mode during vyos_ospf_interfaces's build. Normalizing through
+ to_tag_dict unconditionally means callers always receive a
+ genuine dict.
+ """
+ return to_tag_dict(vyos.get_config(_BASE) or {})
+
+def _device_to_argspec(raw):
result = []
- for itype, ifaces in sorted(raw.items()):
- if not isinstance(ifaces, dict):
- continue
- for iname, idata in sorted(ifaces.items()):
- entry = _parse_iface(iname, idata)
- # only include if there's at least one address or vif
+ for itype, ifaces in sorted((raw or {}).items()):
+ for name, idata in sorted(to_tag_dict(ifaces).items()):
+ idata = idata or {}
+ entry = {"name": name}
+
+ ipv4, ipv6 = _parse_addr_entry(idata)
+ if ipv4:
+ entry["ipv4"] = ipv4
+ if ipv6:
+ entry["ipv6"] = ipv6
+
+ vif_raw = idata.get("vif")
+ if vif_raw:
+ vifs = []
+ for vlan_id, vdata in sorted(to_tag_dict(vif_raw).items(), key=lambda x: int(x[0])):
+ vipv4, vipv6 = _parse_addr_entry(vdata)
+ if vipv4 or vipv6:
+ vif = {"vlan_id": int(vlan_id)}
+ if vipv4:
+ vif["ipv4"] = vipv4
+ if vipv6:
+ vif["ipv6"] = vipv6
+ vifs.append(vif)
+ if vifs:
+ entry["vifs"] = vifs
+
if entry.get("ipv4") or entry.get("ipv6") or entry.get("vifs"):
result.append(entry)
-
return result
def _normalize(config):
- """Convert argspec list to dict keyed by interface name."""
+ """Argspec list -> dict keyed by interface name, with address
+ lists flattened to plain sorted string lists for set-diffing."""
result = {}
for entry in config or []:
- name = entry["name"]
- ipv4 = sorted([a["address"] for a in (entry.get("ipv4") or [])])
- ipv6 = sorted([a["address"] for a in (entry.get("ipv6") or [])])
+ name = entry.get("name")
+ if not name:
+ continue
+ ipv4 = sorted(a["address"] for a in (entry.get("ipv4") or []) if a.get("address"))
+ ipv6 = sorted(a["address"] for a in (entry.get("ipv6") or []) if a.get("address"))
vifs = {}
for vif in entry.get("vifs") or []:
- vid = vif["vlan_id"]
- vipv4 = sorted([a["address"] for a in (vif.get("ipv4") or [])])
- vipv6 = sorted([a["address"] for a in (vif.get("ipv6") or [])])
+ vid = vif.get("vlan_id")
+ if vid is None:
+ continue
+ vipv4 = sorted(a["address"] for a in (vif.get("ipv4") or []) if a.get("address"))
+ vipv6 = sorted(a["address"] for a in (vif.get("ipv6") or []) if a.get("address"))
vifs[vid] = {"ipv4": vipv4, "ipv6": vipv6}
result[name] = {"ipv4": ipv4, "ipv6": ipv6, "vifs": vifs}
return result
def _addr_cmds(base, want_addrs, have_addrs, state):
- """Generate set/delete commands for address lists."""
+ """Address is a plain multi-value leaf, not a keyed/tag-node
+ section -- a set-diff is the correct, direct mechanism here
+ (dict_op's keyed-entry model doesn't fit a bare value list).
+ Confirmed scoped to exactly the "address" leaf under base,
+ never anything else -- base may be an interface or a vif, and
+ in neither case does this function ever touch a sibling leaf.
+ """
cmds = []
want_set = set(want_addrs)
have_set = set(have_addrs)
@@ -300,15 +331,25 @@ def _addr_cmds(base, want_addrs, have_addrs, state):
return cmds
-def _vif_cmds(iface_base, want_vifs, have_vifs, state):
- """Generate commands for VIF subinterfaces."""
+def _vif_addr_cmds(iface_base, want_vifs, have_vifs, state):
+ """VIF address commands, explicitly scoped to only the address
+ leaf within each VIF -- never the VIF subtree as a whole.
+
+ Confirmed severe bug in the original: an omitted VIF under
+ replaced/overridden/deleted generated a whole-VIF delete
+ (`delete ... vif <id>`), which would also destroy the VIF's own
+ description/mtu/disable settings -- fields owned by
+ vyos_interfaces, not this module. The same class of bug just
+ confirmed and fixed in vyos_interfaces for interface-level
+ "address" leaking the other way. Here, an omitted VIF under any
+ state only ever has its own known addresses individually deleted;
+ the VIF's own presence/other fields are never touched.
+ """
cmds = []
- if state in ("replaced", "overridden"):
- for vid in set(have_vifs) - set(want_vifs):
- cmds.append(("delete", iface_base + ["vif", str(vid)]))
-
- for vid, want_vif in want_vifs.items():
+ all_vids = set(have_vifs) | set(want_vifs)
+ for vid in all_vids:
+ want_vif = want_vifs.get(vid, {"ipv4": [], "ipv6": []})
have_vif = have_vifs.get(vid, {"ipv4": [], "ipv6": []})
vif_base = iface_base + ["vif", str(vid)]
cmds += _addr_cmds(vif_base, want_vif["ipv4"], have_vif["ipv4"], state)
@@ -317,144 +358,119 @@ def _vif_cmds(iface_base, want_vifs, have_vifs, state):
return cmds
-def build_commands(config, have_raw, state):
- cmds = []
- have_map = _normalize(have_raw)
+def build_commands(config, raw_have, state):
+ raw_have = raw_have or {}
+ have_list = _device_to_argspec(raw_have)
+ have_map = _normalize(have_list)
if state == "deleted":
+ cmds = []
if not config:
for name, have in have_map.items():
- base = _iface_base(name)
- for addr in have["ipv4"] + have["ipv6"]:
- cmds.append(("delete", base + ["address", addr]))
- for vid in have["vifs"]:
- cmds.append(("delete", base + ["vif", str(vid)]))
- else:
- want_map = _normalize(config)
- for name, want in want_map.items():
- have = have_map.get(name, {"ipv4": [], "ipv6": [], "vifs": {}})
- base = _iface_base(name)
- if not want["ipv4"] and not want["ipv6"] and not want["vifs"]:
- # delete all addresses for this interface
- for addr in have["ipv4"] + have["ipv6"]:
+ base = _iface_base(name, raw_have)
+ cmds += _addr_cmds(base, [], have["ipv4"], "deleted")
+ cmds += _addr_cmds(base, [], have["ipv6"], "deleted")
+ cmds += _vif_addr_cmds(base, {}, have["vifs"], "deleted")
+ return cmds
+ for entry in config or []:
+ name = entry.get("name")
+ if not name:
+ continue
+ have = have_map.get(name, {"ipv4": [], "ipv6": [], "vifs": {}})
+ base = _iface_base(name, raw_have)
+ named_addrs = entry.get("ipv4") or entry.get("ipv6") or entry.get("vifs")
+ if not named_addrs:
+ cmds += _addr_cmds(base, [], have["ipv4"], "deleted")
+ cmds += _addr_cmds(base, [], have["ipv6"], "deleted")
+ cmds += _vif_addr_cmds(base, {}, have["vifs"], "deleted")
+ else:
+ want = _normalize([entry])[name]
+ for addr in want["ipv4"] + want["ipv6"]:
+ if addr in have["ipv4"] + have["ipv6"]:
cmds.append(("delete", base + ["address", addr]))
- for vid in have["vifs"]:
- cmds.append(("delete", base + ["vif", str(vid)]))
- else:
- for addr in want["ipv4"] + want["ipv6"]:
- if addr in have["ipv4"] + have["ipv6"]:
- cmds.append(("delete", base + ["address", addr]))
- for vid in want["vifs"]:
- if vid in have["vifs"]:
- cmds.append(("delete", base + ["vif", str(vid)]))
+ for vid, want_vif in want["vifs"].items():
+ have_vif = have["vifs"].get(vid, {"ipv4": [], "ipv6": []})
+ vif_base = base + ["vif", str(vid)]
+ want_addrs = want_vif["ipv4"] + want_vif["ipv6"]
+ addrs = want_addrs or (have_vif["ipv4"] + have_vif["ipv6"])
+ for addr in addrs:
+ if addr in have_vif["ipv4"] + have_vif["ipv6"]:
+ cmds.append(("delete", vif_base + ["address", addr]))
return cmds
want_map = _normalize(config)
+ commands = []
if state == "overridden":
for name in set(have_map) - set(want_map):
have = have_map[name]
- base = _iface_base(name)
- for addr in have["ipv4"] + have["ipv6"]:
- cmds.append(("delete", base + ["address", addr]))
- for vid in have["vifs"]:
- cmds.append(("delete", base + ["vif", str(vid)]))
+ base = _iface_base(name, raw_have)
+ commands += _addr_cmds(base, [], have["ipv4"], "overridden")
+ commands += _addr_cmds(base, [], have["ipv6"], "overridden")
+ commands += _vif_addr_cmds(base, {}, have["vifs"], "overridden")
for name, want in want_map.items():
have = have_map.get(name, {"ipv4": [], "ipv6": [], "vifs": {}})
- base = _iface_base(name)
- cmds += _addr_cmds(base, want["ipv4"], have["ipv4"], state)
- cmds += _addr_cmds(base, want["ipv6"], have["ipv6"], state)
- cmds += _vif_cmds(base, want["vifs"], have["vifs"], state)
+ base = _iface_base(name, raw_have)
+ commands += _addr_cmds(base, want["ipv4"], have["ipv4"], state)
+ commands += _addr_cmds(base, want["ipv6"], have["ipv6"], state)
+ commands += _vif_addr_cmds(base, want["vifs"], have["vifs"], state)
- return cmds
+ return commands
+_ADDR_OPTIONS = dict(address=dict(type="str"))
+
+_VIF_OPTIONS = dict(
+ vlan_id=dict(type="int", required=True),
+ ipv4=dict(type="list", elements="dict", options=_ADDR_OPTIONS),
+ ipv6=dict(type="list", elements="dict", options=_ADDR_OPTIONS),
+)
+
+_ENTRY_OPTIONS = dict(
+ name=dict(type="str", required=True),
+ ipv4=dict(type="list", elements="dict", options=_ADDR_OPTIONS),
+ ipv6=dict(type="list", elements="dict", options=_ADDR_OPTIONS),
+ vifs=dict(type="list", elements="dict", options=_VIF_OPTIONS),
+)
+
ARGUMENT_SPEC = dict(
- config=dict(
- type="list",
- elements="dict",
- options=dict(
- name=dict(type="str", required=True),
- ipv4=dict(
- type="list",
- elements="dict",
- options=dict(address=dict(type="str")),
- ),
- ipv6=dict(
- type="list",
- elements="dict",
- options=dict(address=dict(type="str")),
- ),
- vifs=dict(
- type="list",
- elements="dict",
- options=dict(
- vlan_id=dict(type="int", required=True),
- ipv4=dict(
- type="list",
- elements="dict",
- options=dict(address=dict(type="str")),
- ),
- ipv6=dict(
- type="list",
- elements="dict",
- options=dict(address=dict(type="str")),
- ),
- ),
- ),
- ),
- ),
- running_config=dict(type="str"),
+ config=dict(type="list", elements="dict", options=_ENTRY_OPTIONS),
state=dict(
type="str",
default="merged",
- choices=["merged", "replaced", "overridden", "deleted", "gathered", "rendered", "parsed"],
+ choices=["merged", "replaced", "overridden", "deleted", "gathered"],
),
)
def main():
- module = AnsibleModule(
- argument_spec=ARGUMENT_SPEC,
- mutually_exclusive=[["config", "running_config"]],
- required_if=[
- ("state", "rendered", ["config"]),
- ("state", "parsed", ["running_config"]),
- ],
- supports_check_mode=True,
- )
+ module = AnsibleModule(ARGUMENT_SPEC, supports_check_mode=True)
vyos = VyOSModule(module)
state = module.params["state"]
config = module.params.get("config") or []
- if state == "parsed":
- # parsed is offline — just return empty for now
- module.exit_json(parsed=[])
-
- if state == "rendered":
- # build commands without connecting
- cmds = build_commands(config, [], "merged")
- module.exit_json(rendered=cmds, commands=cmds)
-
- have = get_running_config(vyos)
+ raw_have = get_running_config(vyos)
+ have = _device_to_argspec(raw_have)
if state == "gathered":
module.exit_json(changed=False, gathered=have)
- commands = build_commands(config, have, state)
+ commands = build_commands(config, raw_have, state)
if module.check_mode:
- module.exit_json(changed=bool(commands), commands=commands, before=have)
+ module.exit_json(changed=bool(commands), commands=commands, before=have, after=have)
if commands:
response = vyos.apply_commands(commands)
saved = vyos.save_config()
+ after_raw = get_running_config(vyos)
+ after = _device_to_argspec(after_raw)
module.exit_json(
changed=True,
before=have,
- after=get_running_config(vyos),
+ after=after,
commands=commands,
saved=saved,
response=response,
diff --git a/plugins/modules/vyos_ospf_interfaces.py b/plugins/modules/vyos_ospf_interfaces.py
index 5388ef0..992aa4d 100644
--- a/plugins/modules/vyos_ospf_interfaces.py
+++ b/plugins/modules/vyos_ospf_interfaces.py
@@ -1,6 +1,7 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
-# GNU General Public License v3.0+
+# 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
@@ -11,10 +12,8 @@ DOCUMENTATION = r"""
module: vyos_ospf_interfaces
short_description: Manage OSPF interface configuration on VyOS devices using REST API
description:
- - Manages OSPF and OSPFv3 interface configuration on VyOS devices via the REST API.
- - IPv4 OSPF maps to C(protocols ospf interface).
- - IPv6 OSPFv3 maps to C(protocols ospfv3 interface).
- - Uses REST API (C(connection=httpapi)) instead of CLI.
+ - Manages OSPFv2 (IPv4) and OSPFv3 (IPv6) per-interface configuration on
+ VyOS devices via the REST API.
version_added: "1.0.0"
author:
- VyOS Community (@vyos)
@@ -38,6 +37,21 @@ options:
type: str
choices: [ipv4, ipv6]
required: true
+ area:
+ description: >-
+ OSPF area to assign this interface to (C(set protocols
+ ospf interface <name> area <id>) for OSPFv2, C(set
+ protocols ospfv3 interface <name> area <id>) for
+ OSPFv3). This is a per-interface alternative to
+ enabling OSPF via C(area <id> network <prefix>) --
+ VyOS's own documentation frames the network-based form
+ as standard and this per-interface form as more
+ convenient in some cases (many interfaces/subnets).
+ Confirmed via VyOS's official documentation across
+ 1.4+/1.5 LTS/rolling as the current syntax, distinct
+ from and superseding the older (1.3-era) C(area <id>
+ interface <name>) form.
+ type: str
authentication:
description: Authentication settings (IPv4 only).
type: dict
@@ -56,7 +70,7 @@ options:
description: MD5 key string.
type: str
bandwidth:
- description: Interface bandwidth in kbps (IPv4 only).
+ description: Interface bandwidth in Mbit/s (IPv4 only).
type: int
cost:
description: Interface cost metric.
@@ -74,14 +88,14 @@ options:
description: OSPFv3 instance ID (IPv6 only).
type: str
mtu_ignore:
- description: Disable MTU check (IPv4 only).
+ description: Disable MTU mismatch detection.
type: bool
network:
- description: Network type (IPv4 only).
+ description: Network type.
type: str
choices: [broadcast, non-broadcast, point-to-multipoint, point-to-point]
passive:
- description: Disable adjacency formation (IPv6 only).
+ description: Suppress adjacency formation on this interface.
type: bool
priority:
description: Interface priority.
@@ -106,6 +120,8 @@ options:
notes:
- Requires C(ansible_connection=httpapi) with the VyOS httpapi plugin.
- C(ansible_network_os) must be set to C(vyos.rest.vyos).
+seealso:
+ - module: vyos.vyos.vyos_ospf_interfaces
"""
EXAMPLES = r"""
@@ -116,26 +132,7 @@ EXAMPLES = r"""
address_family:
- afi: ipv4
cost: 100
- transmit_delay: 50
- priority: 26
- - afi: ipv6
- dead_interval: 39
- passive: true
state: merged
-
-- name: Delete OSPF interface configuration
- vyos.rest.vyos_ospf_interfaces:
- config:
- - name: eth1
- state: deleted
-
-- name: Delete all OSPF interface configuration
- vyos.rest.vyos_ospf_interfaces:
- state: deleted
-
-- name: Gather current OSPF interface configuration
- vyos.rest.vyos_ospf_interfaces:
- state: gathered
"""
RETURN = r"""
@@ -166,336 +163,339 @@ response:
"""
from ansible.module_utils.basic import AnsibleModule
-from ansible_collections.vyos.rest.plugins.module_utils.vyos import VyOSModule
+from ansible_collections.vyos.rest.plugins.module_utils.vyos import (
+ VyOSModule,
+ autoclean,
+ cast_by_spec,
+ dict_op,
+ from_device,
+ to_tag_dict,
+)
_BASE4 = ["protocols", "ospf", "interface"]
_BASE6 = ["protocols", "ospfv3", "interface"]
-# IPv4 scalar fields: argspec_key -> api_key
-_IPV4_SCALARS = {
- "bandwidth": "bandwidth",
- "cost": "cost",
- "dead_interval": "dead-interval",
- "hello_interval": "hello-interval",
- "mtu_ignore": "mtu-ignore",
- "network": "network",
- "priority": "priority",
- "retransmit_interval": "retransmit-interval",
- "transmit_delay": "transmit-delay",
-}
-
-# IPv6 scalar fields
-_IPV6_SCALARS = {
- "cost": "cost",
- "dead_interval": "dead-interval",
- "hello_interval": "hello-interval",
- "ifmtu": "ifmtu",
- "instance": "instance-id",
- "passive": "passive",
- "priority": "priority",
- "retransmit_interval": "retransmit-interval",
- "transmit_delay": "transmit-delay",
-}
-
-# bool fields that use presence (no value)
-_IPV4_BOOL_PRESENCE = {"mtu_ignore"}
-_IPV6_BOOL_PRESENCE = {"passive"}
-
-
-def _parse_ipv4_iface(data):
- af = {"afi": "ipv4"}
- data = data or {}
- for arg_key, api_key in _IPV4_SCALARS.items():
- if api_key in data:
- if arg_key in _IPV4_BOOL_PRESENCE:
- af[arg_key] = True
- else:
- val = data[api_key]
- if arg_key in (
- "bandwidth",
- "cost",
- "dead_interval",
- "hello_interval",
- "priority",
- "retransmit_interval",
- "transmit_delay",
- ):
- try:
- af[arg_key] = int(val)
- except (TypeError, ValueError):
- af[arg_key] = val
- else:
- af[arg_key] = val
- # authentication
- auth = data.get("authentication", {})
- if auth:
- auth_entry = {}
- if "plaintext-password" in auth:
- auth_entry["plaintext_password"] = auth["plaintext-password"]
- md5 = auth.get("md5", {})
- if md5:
- key_id_data = md5.get("key-id", {})
- if key_id_data:
- key_id = list(key_id_data.keys())[0]
- md5_key = key_id_data[key_id].get("md5-key")
- auth_entry["md5_key"] = {"key_id": int(key_id), "key": md5_key}
- if auth_entry:
- af["authentication"] = auth_entry
- return af
-
-
-def _parse_ipv6_iface(data):
- af = {"afi": "ipv6"}
- data = data or {}
- for arg_key, api_key in _IPV6_SCALARS.items():
- if api_key in data:
- if arg_key in _IPV6_BOOL_PRESENCE:
- af[arg_key] = True
- else:
- val = data[api_key]
- if arg_key in (
- "cost",
- "dead_interval",
- "hello_interval",
- "ifmtu",
- "priority",
- "retransmit_interval",
- "transmit_delay",
- ):
- try:
- af[arg_key] = int(val)
- except (TypeError, ValueError):
- af[arg_key] = val
- else:
- af[arg_key] = val
- return af
+_IPV6_RENAMES = {"instance": "instance-id"}
-def get_running_config(vyos):
- raw4 = vyos.get_config(_BASE4) or {}
- raw4 = raw4.get("interface", raw4)
- raw6 = vyos.get_config(_BASE6) or {}
- raw6 = raw6.get("interface", raw6)
+def _derive_key_field(options_spec):
+ required = [k for k, spec in options_spec.items() if spec.get("required")]
+ if len(required) != 1:
+ raise ValueError(
+ "expected exactly one required suboption to serve as the key field, "
+ "found: {0}".format(required),
+ )
+ return required[0]
- ifaces = {}
- for iface_name, data in raw4.items():
- if iface_name not in ifaces:
- ifaces[iface_name] = {"name": iface_name, "address_family": []}
- af = _parse_ipv4_iface(data)
- if len(af) > 1: # more than just afi key
- ifaces[iface_name]["address_family"].append(af)
-
- for iface_name, data in raw6.items():
- if iface_name not in ifaces:
- ifaces[iface_name] = {"name": iface_name, "address_family": []}
- af = _parse_ipv6_iface(data)
- if len(af) > 1:
- ifaces[iface_name]["address_family"].append(af)
-
- result = sorted(ifaces.values(), key=lambda x: x["name"])
- # Remove empty address_family lists
- for iface in result:
- if not iface["address_family"]:
- del iface["address_family"]
- return result
+def _kebab_fields(d):
+ cleaned = autoclean(d)
+ return {k.replace("_", "-"): v for k, v in cleaned.items()}
-def _ipv4_af_cmds(iface_name, af, have_af, op="set"):
- cmds = []
- base = _BASE4 + [iface_name]
- have_af = have_af or {}
+def _keyed_list_to_device(items, key_field, entry_transform=None):
+ entry_transform = entry_transform or _kebab_fields
+ result = {}
+ for item in items or []:
+ if not item.get(key_field):
+ continue
+ rest = {k: v for k, v in item.items() if k != key_field}
+ result[str(item[key_field])] = entry_transform(rest)
+ return result
- for arg_key, api_key in _IPV4_SCALARS.items():
- want_val = af.get(arg_key)
- have_val = have_af.get(arg_key)
- if want_val is not None and want_val != have_val:
- if arg_key in _IPV4_BOOL_PRESENCE:
- cmds.append(("set", base + [api_key]))
- else:
- cmds.append(("set", base + [api_key, str(want_val)]))
- elif op == "replace" and have_val is not None and want_val != have_val:
- cmds.append(("delete", base + [api_key]))
-
- # authentication
- want_auth = af.get("authentication") or {}
- have_auth = have_af.get("authentication") or {}
- if want_auth.get("plaintext_password") and want_auth["plaintext_password"] != have_auth.get(
- "plaintext_password",
- ):
- cmds.append(
- (
- "set",
- base
- + [
- "authentication",
- "plaintext-password",
- want_auth["plaintext_password"],
- ],
- ),
- )
- md5 = want_auth.get("md5_key") or {}
- if md5 and md5 != have_auth.get("md5_key"):
- cmds.append(
- (
- "set",
- base
- + [
- "authentication",
- "md5",
- "key-id",
- str(md5["key_id"]),
- "md5-key",
- md5["key"],
- ],
- ),
- )
- return cmds
+def _keyed_list_from_device(raw, key_field, entry_transform=None):
+ entry_transform = entry_transform or from_device
+ return [
+ {key_field: key, **entry_transform(data or {})}
+ for key, data in sorted(to_tag_dict(raw).items())
+ ]
+
+
+def _auth_to_device(auth):
+ if not auth:
+ return {}
+ device = {}
+ if auth.get("plaintext_password"):
+ device["plaintext-password"] = auth["plaintext_password"]
+ md5 = auth.get("md5_key") or {}
+ if md5.get("key_id") is not None and md5.get("key") is not None:
+ device["md5"] = {"key-id": {str(md5["key_id"]): {"md5-key": md5["key"]}}}
+ return device
+
+
+def _auth_from_device(data):
+ if not data:
+ return None
+ entry = {}
+ if data.get("plaintext-password"):
+ entry["plaintext_password"] = data["plaintext-password"]
+ key_id_raw = (data.get("md5") or {}).get("key-id")
+ if key_id_raw:
+ key_id, kdata = sorted(to_tag_dict(key_id_raw).items())[0]
+ entry["md5_key"] = {"key_id": int(key_id), "key": (kdata or {}).get("md5-key")}
+ return entry or None
+
+
+def _af4_entry_to_device(rest):
+ exclude = {"afi", "authentication"}
+ device = _kebab_fields({k: v for k, v in rest.items() if k not in exclude})
+ if rest.get("authentication"):
+ auth_device = _auth_to_device(rest["authentication"])
+ if auth_device:
+ device["authentication"] = auth_device
+ return device
+
+
+def _af4_entry_from_device(data):
+ exclude = {"authentication"}
+ entry = from_device({k: v for k, v in data.items() if k not in exclude})
+ auth = _auth_from_device(data.get("authentication"))
+ if auth:
+ entry["authentication"] = auth
+ return entry
+
+
+def _af6_entry_to_device(rest):
+ exclude = set(_IPV6_RENAMES)
+ device = _kebab_fields({k: v for k, v in rest.items() if k not in exclude})
+ for arg_key, device_key in _IPV6_RENAMES.items():
+ if rest.get(arg_key) is not None:
+ device[device_key] = rest[arg_key]
+ return device
+
+
+def _af6_entry_from_device(data):
+ exclude = set(_IPV6_RENAMES.values())
+ entry = from_device({k: v for k, v in data.items() if k not in exclude})
+ for arg_key, device_key in _IPV6_RENAMES.items():
+ if data.get(device_key) is not None:
+ entry[arg_key] = data[device_key]
+ return entry
+
+
+def _want_to_device(config):
+ want4 = {}
+ want6 = {}
+ for entry in config or []:
+ name = entry.get("name")
+ if not name:
+ continue
+ for af in entry.get("address_family") or []:
+ afi = af.get("afi")
+ rest = {k: v for k, v in af.items() if k != "afi"}
+ if afi == "ipv4":
+ want4[name] = _af4_entry_to_device(rest)
+ elif afi == "ipv6":
+ want6[name] = _af6_entry_to_device(rest)
+ return want4, want6
-def _ipv6_af_cmds(iface_name, af, have_af, op="set"):
- cmds = []
- base = _BASE6 + [iface_name]
- have_af = have_af or {}
+def get_running_config(vyos):
+ """VyOS's REST API collapses a single-child tag node to a plain
+ string (or a list for multiple) -- confirmed by reproduction: an
+ unguarded .get("interface", ...) on a non-dict response raises
+ AttributeError, and consuming an unnormalized raw4/raw6 downstream
+ (e.g. in build_commands's deleted branch) iterates a collapsed
+ string's individual characters as if they were interface names,
+ producing corrupted delete paths. Guarding the unwrap and
+ normalizing via to_tag_dict here means every caller always
+ receives a genuine {name: {...}} dict, with no need to re-guard
+ at each use site.
+ """
+ raw4 = vyos.get_config(_BASE4) or {}
+ if isinstance(raw4, dict):
+ raw4 = raw4.get("interface", raw4)
+ raw4 = to_tag_dict(raw4)
- for arg_key, api_key in _IPV6_SCALARS.items():
- want_val = af.get(arg_key)
- have_val = have_af.get(arg_key)
- if want_val is not None and want_val != have_val:
- if arg_key in _IPV6_BOOL_PRESENCE:
- cmds.append(("set", base + [api_key]))
- else:
- cmds.append(("set", base + [api_key, str(want_val)]))
- elif op == "replace" and have_val is not None and want_val != have_val:
- cmds.append(("delete", base + [api_key]))
+ raw6 = vyos.get_config(_BASE6) or {}
+ if isinstance(raw6, dict):
+ raw6 = raw6.get("interface", raw6)
+ raw6 = to_tag_dict(raw6)
- return cmds
+ return raw4, raw6
-def build_commands(config, have_raw, state):
- cmds = []
- have_map = {e["name"]: e for e in have_raw}
+def _device_to_argspec(raw4, raw6):
+ ifaces = {}
+ for name, data in sorted(to_tag_dict(raw4 or {}).items()):
+ entry = _af4_entry_from_device(data or {})
+ if entry:
+ ifaces.setdefault(name, {"name": name, "address_family": []})
+ ifaces[name]["address_family"].append({"afi": "ipv4", **entry})
+ for name, data in sorted(to_tag_dict(raw6 or {}).items()):
+ entry = _af6_entry_from_device(data or {})
+ if entry:
+ ifaces.setdefault(name, {"name": name, "address_family": []})
+ ifaces[name]["address_family"].append({"afi": "ipv6", **entry})
+ return sorted(ifaces.values(), key=lambda i: i["name"])
+
+
+def _validate_config(config):
+ """Confirmed real bugs, both by reproduction:
+
+ 1. md5_key.key/key_id were checked by truthiness, not presence --
+ key_id set without key reached _auth_to_device and produced
+ {"md5-key": None} in the generated command (a broken device
+ write, not a validation failure); an empty-string key without
+ key_id silently passed validation and then got dropped
+ entirely by _auth_to_device's own key_id is not None check
+ (the original confirmed bug, just reachable via a second
+ value). is not None checks catch both directions and don't
+ treat a legitimately-empty (but explicitly set) value as
+ "unset".
+
+ 2. authentication is documented and confirmed (via vyos-1x
+ schema) as IPv4-only -- OSPFv3's interface config has no
+ authentication node at all -- but the shared argspec doesn't
+ enforce this, and _af6_entry_to_device would silently forward
+ it into the ipv6 device path, where the real device would
+ reject it at apply time instead of failing cleanly upfront.
+ """
+ for entry in config or []:
+ name = entry.get("name")
+ for af in entry.get("address_family") or []:
+ afi = af.get("afi")
+ auth = af.get("authentication") or {}
+
+ if afi == "ipv6" and auth:
+ return (
+ "address_family.authentication was set for interface "
+ "'{0}' with afi: ipv6 -- authentication is IPv4-only "
+ "and has no corresponding OSPFv3 device path.".format(name)
+ )
+
+ md5 = auth.get("md5_key") or {}
+ has_key = md5.get("key") is not None
+ has_key_id = md5.get("key_id") is not None
+ if has_key != has_key_id:
+ return (
+ "address_family.authentication.md5_key.key and .key_id "
+ "were not both set for interface '{0}' -- both are "
+ "required together.".format(name)
+ )
+ return None
+
+
+def build_commands(config, raw_have, state):
+ raw4, raw6 = raw_have
+ config = config or []
if state == "deleted":
+ cmds = []
if not config:
- # delete all
- if have_raw:
- for iface in have_raw:
- name = iface["name"]
- afs = {af["afi"] for af in iface.get("address_family", [])}
- if "ipv4" in afs:
- cmds.append(("delete", _BASE4 + [name]))
- if "ipv6" in afs:
- cmds.append(("delete", _BASE6 + [name]))
- else:
- for entry in config:
- name = entry["name"]
- if name in have_map:
- have_afs = {af["afi"] for af in have_map[name].get("address_family", [])}
- want_afis = {af["afi"] for af in (entry.get("address_family") or [])}
- if not want_afis:
- # delete all AFIs for this interface
- if "ipv4" in have_afs:
- cmds.append(("delete", _BASE4 + [name]))
- if "ipv6" in have_afs:
- cmds.append(("delete", _BASE6 + [name]))
- else:
- if "ipv4" in want_afis and "ipv4" in have_afs:
- cmds.append(("delete", _BASE4 + [name]))
- if "ipv6" in want_afis and "ipv6" in have_afs:
- cmds.append(("delete", _BASE6 + [name]))
- return cmds
-
- if state == "overridden":
- want_names = {e["name"] for e in config}
- for name, have_iface in have_map.items():
- if name not in want_names:
- have_afs = {af["afi"] for af in have_iface.get("address_family", [])}
- if "ipv4" in have_afs:
+ for name in raw4 or {}:
+ cmds.append(("delete", _BASE4 + [name]))
+ for name in raw6 or {}:
+ cmds.append(("delete", _BASE6 + [name]))
+ return cmds
+ for entry in config:
+ name = entry.get("name")
+ if not name:
+ continue
+ want_afis = {af.get("afi") for af in (entry.get("address_family") or [])}
+ if not want_afis:
+ if name in (raw4 or {}):
cmds.append(("delete", _BASE4 + [name]))
- if "ipv6" in have_afs:
+ if name in (raw6 or {}):
cmds.append(("delete", _BASE6 + [name]))
-
- for entry in config:
- name = entry["name"]
- have_iface = have_map.get(name, {})
- have_af_map = {af["afi"]: af for af in have_iface.get("address_family", [])}
-
- for af in entry.get("address_family", []):
- afi = af["afi"]
- have_af = have_af_map.get(afi, {})
-
- if state == "replaced":
- af_clean = {k: v for k, v in af.items() if v is not None}
- if have_af and have_af != af_clean:
- if afi == "ipv4":
- cmds.append(("delete", _BASE4 + [name]))
- have_af = {}
- else:
- cmds.append(("delete", _BASE6 + [name]))
- have_af = {}
- elif have_af == af_clean:
- continue # already matches — idempotent
-
- if afi == "ipv4":
- cmds += _ipv4_af_cmds(name, af, have_af)
else:
- cmds += _ipv6_af_cmds(name, af, have_af)
-
- return cmds
+ if "ipv4" in want_afis and name in (raw4 or {}):
+ cmds.append(("delete", _BASE4 + [name]))
+ if "ipv6" in want_afis and name in (raw6 or {}):
+ cmds.append(("delete", _BASE6 + [name]))
+ return cmds
+ want4, want6 = _want_to_device(config)
+ have_argspec = _device_to_argspec(raw4, raw6)
+ norm4, norm6 = _want_to_device(have_argspec)
-ARGUMENT_SPEC = dict(
- config=dict(
- type="list",
- elements="dict",
+ commands = []
+ if state == "overridden":
+ commands += dict_op(want4, norm4, _BASE4, op="purge")
+ commands += dict_op(want6, norm6, _BASE6, op="purge")
+ # Confirmed bug: an interface present on the raw device with
+ # an entirely empty per-AFI entry (bare presence, nothing
+ # configured under it) produces {} from _af*_entry_from_device,
+ # which _device_to_argspec silently omits -- invisible to both
+ # want and norm_have, so dict_op's purge above (which only
+ # ever iterates have's own keys) can never generate a delete
+ # for it. These names are, by construction, guaranteed absent
+ # from norm4/norm6, so there's no risk of duplicating a delete
+ # dict_op already issued.
+ for name in raw4 or {}:
+ if name not in norm4 and name not in want4:
+ commands.append(("delete", _BASE4 + [name]))
+ for name in raw6 or {}:
+ if name not in norm6 and name not in want6:
+ commands.append(("delete", _BASE6 + [name]))
+ elif state == "replaced":
+ for entry in config:
+ name = entry.get("name")
+ if not name:
+ continue
+ for af in entry.get("address_family") or []:
+ afi = af.get("afi")
+ if afi == "ipv4":
+ commands += dict_op(
+ want4.get(name, {}),
+ norm4.get(name, {}),
+ _BASE4 + [name],
+ op="purge",
+ )
+ elif afi == "ipv6":
+ commands += dict_op(
+ want6.get(name, {}),
+ norm6.get(name, {}),
+ _BASE6 + [name],
+ op="purge",
+ )
+ commands += dict_op(want4, norm4, _BASE4, op="set")
+ commands += dict_op(want6, norm6, _BASE6, op="set")
+ return commands
+
+
+_AUTH_OPTIONS = dict(
+ plaintext_password=dict(type="str", no_log=True),
+ md5_key=dict(
+ type="dict",
+ no_log=True,
options=dict(
- name=dict(type="str", required=True),
- address_family=dict(
- type="list",
- elements="dict",
- options=dict(
- afi=dict(type="str", choices=["ipv4", "ipv6"], required=True),
- authentication=dict(
- type="dict",
- options=dict(
- plaintext_password=dict(type="str", no_log=True),
- md5_key=dict(
- type="dict",
- no_log=True,
- options=dict(
- key_id=dict(type="int"),
- key=dict(type="str", no_log=True),
- ),
- ),
- ),
- ),
- bandwidth=dict(type="int"),
- cost=dict(type="int"),
- dead_interval=dict(type="int"),
- hello_interval=dict(type="int"),
- ifmtu=dict(type="int"),
- instance=dict(type="str"),
- mtu_ignore=dict(type="bool"),
- network=dict(
- type="str",
- choices=[
- "broadcast",
- "non-broadcast",
- "point-to-multipoint",
- "point-to-point",
- ],
- ),
- passive=dict(type="bool"),
- priority=dict(type="int"),
- retransmit_interval=dict(type="int"),
- transmit_delay=dict(type="int"),
- ),
- ),
+ key_id=dict(type="int"),
+ key=dict(type="str", no_log=True),
),
),
+)
+
+_AF_OPTIONS = dict(
+ afi=dict(type="str", choices=["ipv4", "ipv6"], required=True),
+ area=dict(type="str"),
+ authentication=dict(type="dict", options=_AUTH_OPTIONS),
+ bandwidth=dict(type="int"),
+ cost=dict(type="int"),
+ dead_interval=dict(type="int"),
+ hello_interval=dict(type="int"),
+ ifmtu=dict(type="int"),
+ instance=dict(type="str"),
+ mtu_ignore=dict(type="bool"),
+ network=dict(
+ type="str",
+ choices=["broadcast", "non-broadcast", "point-to-multipoint", "point-to-point"],
+ ),
+ passive=dict(type="bool"),
+ priority=dict(type="int"),
+ retransmit_interval=dict(type="int"),
+ transmit_delay=dict(type="int"),
+)
+
+_ENTRY_OPTIONS = dict(
+ name=dict(type="str", required=True),
+ address_family=dict(type="list", elements="dict", options=_AF_OPTIONS),
+)
+
+ARGUMENT_SPEC = dict(
+ config=dict(type="list", elements="dict", options=_ENTRY_OPTIONS),
state=dict(
default="merged",
choices=["merged", "replaced", "overridden", "deleted", "gathered"],
@@ -510,23 +510,36 @@ def main():
state = module.params["state"]
config = module.params.get("config") or []
- have = get_running_config(vyos)
+ validation_error = _validate_config(config)
+ if validation_error:
+ module.fail_json(msg=validation_error)
+
+ raw_have = get_running_config(vyos)
+ have = _device_to_argspec(*raw_have)
+ for iface in have:
+ for af in iface.get("address_family") or []:
+ cast_by_spec(af, _AF_OPTIONS)
if state == "gathered":
module.exit_json(changed=False, gathered=have)
- commands = build_commands(config, have, state)
+ commands = build_commands(config, raw_have, state)
if module.check_mode:
- module.exit_json(changed=bool(commands), commands=commands, before=have)
+ module.exit_json(changed=bool(commands), commands=commands, before=have, after=have)
if commands:
response = vyos.apply_commands(commands)
saved = vyos.save_config()
+ after_raw = get_running_config(vyos)
+ after = _device_to_argspec(*after_raw)
+ for iface in after:
+ for af in iface.get("address_family") or []:
+ cast_by_spec(af, _AF_OPTIONS)
module.exit_json(
changed=True,
before=have,
- after=get_running_config(vyos),
+ after=after,
commands=commands,
saved=saved,
response=response,
diff --git a/plugins/modules/vyos_ospfv2.py b/plugins/modules/vyos_ospfv2.py
index 5ac333d..efdd2a5 100644
--- a/plugins/modules/vyos_ospfv2.py
+++ b/plugins/modules/vyos_ospfv2.py
@@ -1,6 +1,7 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
-# GNU General Public License v3.0+
+# 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
@@ -13,7 +14,25 @@ short_description: Manage OSPFv2 configuration on VyOS devices using REST API
description:
- Manages OSPFv2 configuration on VyOS devices via the REST API.
- Uses REST API (C(connection=httpapi)) instead of CLI.
- - 'In VyOS 1.5+, passive interfaces use per-interface config rather than passive-interface.'
+ - >-
+ Scope matches the current vyos.vyos.vyos_ospfv2 (CLI collection) module:
+ areas (including virtual_link), auto_cost, default_information,
+ default_metric, distance, log_adjacency_changes, max_metric, mpls_te,
+ neighbor, parameters, passive_interface, passive_interface_exclude,
+ redistribute, timers. VyOS's OSPF schema is considerably larger than
+ even this -- access-list, aggregation, capability, graceful-restart,
+ ldp-sync, maximum-paths, per-interface
+ tuning (bandwidth/hello-multiplier/network-type/authentication/intervals beyond passive),
+ segment-routing, and summary-address are not modeled here, matching
+ real gaps in the CLI module's own scope, not oversights.
+ - >-
+ A standalone top-level C(route_map) field exists in the CLI module's
+ argspec but does not correspond to any real device path -- confirmed
+ against a live VyOS 1.5.0 device's C(set protocols ospf) completions,
+ which list no C(route-map) entry at that level (only nested under
+ C(default-information originate) and per-protocol under
+ C(redistribute), both of which are modeled here). Deliberately
+ omitted rather than built against a confirmed non-functional field.
version_added: "1.0.0"
author:
- VyOS Community (@vyos)
@@ -103,6 +122,45 @@ options:
description: Shortcut mode.
type: str
choices: [default, disable, enable]
+ virtual_link:
+ description: Virtual link.
+ type: list
+ elements: dict
+ suboptions:
+ address:
+ description: Virtual link address (router ID of the remote ABR).
+ type: str
+ required: true
+ authentication:
+ description: Virtual link authentication.
+ type: dict
+ suboptions:
+ md5:
+ description: MD5 key id based authentication.
+ type: list
+ elements: dict
+ suboptions:
+ key_id:
+ description: MD5 key id (1-255).
+ type: int
+ md5_key:
+ description: MD5 key (16 characters or less).
+ type: str
+ plaintext_password:
+ description: Plain text password (8 characters or less).
+ type: str
+ dead_interval:
+ description: Interval after which a neighbor is declared dead.
+ type: int
+ hello_interval:
+ description: Interval between hello packets.
+ type: int
+ retransmit_interval:
+ description: Interval between retransmitting lost link state advertisements.
+ type: int
+ transmit_delay:
+ description: Link state transmit delay.
+ type: int
auto_cost:
description: Auto-cost reference bandwidth.
type: dict
@@ -157,6 +215,33 @@ options:
description: Log adjacency changes.
type: str
choices: [detail]
+ max_metric:
+ description: OSPFv2 maximum/infinite-distance metric.
+ type: dict
+ suboptions:
+ router_lsa:
+ description: Advertise own Router-LSA with infinite distance (stub router).
+ type: dict
+ suboptions:
+ administrative:
+ description: Administratively apply, for an indefinite period.
+ type: bool
+ on_shutdown:
+ description: Time (seconds) to advertise self as stub-router before shutdown.
+ type: int
+ on_startup:
+ description: Time (seconds) to advertise self as stub-router on startup.
+ type: int
+ mpls_te:
+ description: MPLS Traffic Engineering parameters.
+ type: dict
+ suboptions:
+ enabled:
+ description: Enable MPLS-TE functionality.
+ type: bool
+ router_address:
+ description: Stable IP address of the advertising router.
+ type: str
neighbor:
description: OSPF neighbors.
type: list
@@ -190,9 +275,16 @@ options:
description: Router ID.
type: str
passive_interface:
- description: >
- Passive interfaces (VyOS 1.5+: configured via
- C(protocols ospf interface <name> passive)).
+ description: >-
+ Interfaces to suppress routing updates on, via per-interface
+ configuration (C(protocols ospf interface <name> passive)).
+ type: list
+ elements: str
+ passive_interface_exclude:
+ description: >-
+ Interfaces to explicitly exclude from passive mode (via
+ C(protocols ospf interface <name> passive disable)) -- e.g.
+ when passive is otherwise applied broadly.
type: list
elements: str
redistribute:
@@ -213,6 +305,34 @@ options:
route_map:
description: Route map.
type: str
+ timers:
+ description: Routing timers.
+ type: dict
+ suboptions:
+ refresh:
+ description: Refresh parameters.
+ type: dict
+ suboptions:
+ timers:
+ description: Refresh timer (seconds).
+ type: int
+ throttle:
+ description: Throttling adaptive timers.
+ type: dict
+ suboptions:
+ spf:
+ description: SPF timers.
+ type: dict
+ suboptions:
+ delay:
+ description: Delay (ms) from first change received to SPF calculation.
+ type: int
+ initial_holdtime:
+ description: Initial hold time (ms) between consecutive SPF calculations.
+ type: int
+ max_holdtime:
+ description: Maximum hold time (ms).
+ type: int
state:
description:
- Desired state.
@@ -226,8 +346,8 @@ options:
notes:
- Requires C(ansible_connection=httpapi) with the VyOS httpapi plugin.
- C(ansible_network_os) must be set to C(vyos.rest.vyos).
- - VyOS 1.5+ uses per-interface passive configuration rather than
- the global C(passive-interface) command used in VyOS 1.4.
+seealso:
+ - module: vyos.vyos.vyos_ospfv2
"""
EXAMPLES = r"""
@@ -245,10 +365,6 @@ EXAMPLES = r"""
normal: true
network:
- address: 192.0.2.0/24
- - area_id: "3"
- area_type:
- nssa:
- set: true
- area_id: "4"
area_type:
stub:
@@ -261,6 +377,16 @@ EXAMPLES = r"""
metric: 10
passive_interface:
- eth1
+ max_metric:
+ router_lsa:
+ administrative: true
+ mpls_te:
+ enabled: true
+ router_address: 192.0.11.11
+ timers:
+ throttle:
+ spf:
+ delay: 200
state: merged
- name: Delete all OSPFv2 configuration
@@ -300,618 +426,706 @@ response:
"""
from ansible.module_utils.basic import AnsibleModule
-from ansible_collections.vyos.rest.plugins.module_utils.vyos import VyOSModule
+from ansible_collections.vyos.rest.plugins.module_utils.vyos import (
+ VyOSModule,
+ autoclean,
+ cast_by_spec,
+ dict_op,
+ from_device,
+ to_tag_dict,
+)
_BASE = ["protocols", "ospf"]
-def _parse_areas(raw_areas):
- if not raw_areas or not isinstance(raw_areas, dict):
- return []
- areas = []
- for area_id, data in sorted(raw_areas.items()):
- area = {"area_id": area_id}
- data = data or {}
+def _derive_key_field(options_spec):
+ """The field identifying each entry in a named-list section is
+ never inferable from a generic walk alone -- but it doesn't need
+ to be hand-declared either: every named-list section in this
+ argspec already marks exactly one suboption required=True.
+ """
+ required = [k for k, spec in options_spec.items() if spec.get("required")]
+ if len(required) != 1:
+ raise ValueError(
+ "expected exactly one required suboption to serve as the key field, "
+ "found: {0}".format(required),
+ )
+ return required[0]
- # area-type
- at = data.get("area-type", {})
- if at:
- area_type = {}
- if "normal" in at:
- area_type["normal"] = True
- if "nssa" in at:
- nssa_data = at["nssa"] or {}
- nssa = {"set": True}
- if "default-cost" in nssa_data:
- nssa["default_cost"] = int(nssa_data["default-cost"])
- if "no-summary" in nssa_data:
- nssa["no_summary"] = True
- if "translate" in nssa_data:
- nssa["translate"] = nssa_data["translate"]
- area_type["nssa"] = nssa
- if "stub" in at:
- stub_data = at["stub"] or {}
- stub = {"set": True}
- if "default-cost" in stub_data:
- stub["default_cost"] = int(stub_data["default-cost"])
- if "no-summary" in stub_data:
- stub["no_summary"] = True
- area_type["stub"] = stub
- if area_type:
- area["area_type"] = area_type
-
- if "authentication" in data:
- area["authentication"] = data["authentication"]
-
- if "shortcut" in data:
- area["shortcut"] = data["shortcut"]
-
- # network
- net = data.get("network")
- if net:
- if isinstance(net, str):
- area["network"] = [{"address": net}]
- elif isinstance(net, dict):
- area["network"] = [{"address": a} for a in sorted(net.keys())]
- elif isinstance(net, list):
- area["network"] = [{"address": a} for a in sorted(net)]
-
- # range
- rng = data.get("range", {})
- if rng and isinstance(rng, dict):
- ranges = []
- for addr, rdata in sorted(rng.items()):
- r = {"address": addr}
- rdata = rdata or {}
- if "cost" in rdata:
- r["cost"] = int(rdata["cost"])
- if "not-advertise" in rdata:
- r["not_advertise"] = True
- if "substitute" in rdata:
- r["substitute"] = rdata["substitute"]
- ranges.append(r)
- if ranges:
- area["range"] = ranges
-
- areas.append(area)
- return areas
-
-
-def _parse_redistribute(raw):
- if not raw or not isinstance(raw, dict):
- return []
- result = []
- for rt, data in sorted(raw.items()):
- entry = {"route_type": rt}
- data = data or {}
- if "metric" in data:
- entry["metric"] = int(data["metric"])
- if "metric-type" in data:
- entry["metric_type"] = int(data["metric-type"])
- if "route-map" in data:
- entry["route_map"] = data["route-map"]
- result.append(entry)
+
+def _keyed_list_to_device(items, key_field, entry_transform=None):
+ entry_transform = entry_transform or _kebab_fields
+ result = {}
+ for item in items or []:
+ if not item.get(key_field):
+ continue
+ rest = {k: v for k, v in item.items() if k != key_field}
+ result[str(item[key_field])] = entry_transform(rest)
return result
-def _parse_neighbor(raw):
- if not raw or not isinstance(raw, dict):
- return []
- result = []
- for nb_id, data in sorted(raw.items()):
- entry = {"neighbor_id": nb_id}
- data = data or {}
- if "poll-interval" in data:
- entry["poll_interval"] = int(data["poll-interval"])
- if "priority" in data:
- entry["priority"] = int(data["priority"])
- result.append(entry)
- return result
+def _keyed_list_from_device(raw, key_field, entry_transform=None, key_cast=None):
+ entry_transform = entry_transform or from_device
+ key_cast = key_cast or (lambda k: k)
+ return [
+ {key_field: key_cast(key), **entry_transform(data or {})}
+ for key, data in sorted(to_tag_dict(raw).items())
+ ]
-def _parse_parameters(raw):
- if not raw or not isinstance(raw, dict):
- return {}
- result = {}
- if "router-id" in raw:
- result["router_id"] = raw["router-id"]
- if "abr-type" in raw:
- result["abr_type"] = raw["abr-type"]
- if "opaque-lsa" in raw:
- result["opaque_lsa"] = True
- if "rfc1583-compatibility" in raw:
- result["rfc1583_compatibility"] = True
- return result
+# ---------------------------------------------------------------------------
+# area_type -- confirmed against vyos-1x: normal (presence), nssa (a node
+# with default-cost/no-summary/translate -- presence of the node itself
+# is the "set" flag, matching the argspec's own "set" boolean rather
+# than a separate device leaf), stub (default-cost/no-summary,
+# same presence pattern). Genuine structural exception: the argspec's
+# nssa/stub "set" key doesn't exist as a device leaf at all -- the
+# *node's presence* IS the set flag, so "set" must be stripped out
+# before the walk and re-derived on the way back.
+# ---------------------------------------------------------------------------
-def _parse_default_information(raw):
- if not raw or not isinstance(raw, dict):
+def _area_type_to_device(area_type):
+ if not area_type:
return {}
- orig = raw.get("originate", {}) or {}
- result = {}
- if "always" in orig:
- result["always"] = True
- if "metric" in orig:
- result["metric"] = int(orig["metric"])
- if "metric-type" in orig:
- result["metric_type"] = int(orig["metric-type"])
- if "route-map" in orig:
- result["route_map"] = orig["route-map"]
- if result:
- return {"originate": result}
- return {}
+ device = {}
+ if area_type.get("normal"):
+ device["normal"] = {}
+ nssa = area_type.get("nssa")
+ if nssa:
+ nssa_device = _kebab_fields({k: v for k, v in nssa.items() if k != "set"})
+ device["nssa"] = nssa_device
+ stub = area_type.get("stub")
+ if stub:
+ stub_device = _kebab_fields({k: v for k, v in stub.items() if k != "set"})
+ device["stub"] = stub_device
+ return device
+
+
+def _area_type_from_device(data):
+ if not data:
+ return None
+ entry = {}
+ if "normal" in data:
+ entry["normal"] = True
+ if "nssa" in data:
+ nssa = from_device(data["nssa"] or {})
+ nssa["set"] = True
+ entry["nssa"] = nssa
+ if "stub" in data:
+ stub = from_device(data["stub"] or {})
+ stub["set"] = True
+ entry["stub"] = stub
+ return entry or None
+
+
+# ---------------------------------------------------------------------------
+# virtual_link -- confirmed against vyos-1x: keyed by address, with an
+# "authentication" node (md5 tag-node keyed by key-id, or a bare
+# plaintext-password leaf), plus dead-interval/hello-interval/
+# retransmit-interval/transmit-delay as plain leaves.
+# ---------------------------------------------------------------------------
+
+
+def _vlink_auth_to_device(auth):
+ if not auth:
+ return {}
+ device = {}
+ md5_list = auth.get("md5") or []
+ if md5_list:
+ md5_device = {
+ str(entry["key_id"]): {"md5-key": entry["md5_key"]}
+ for entry in md5_list
+ if entry.get("key_id") is not None and entry.get("md5_key") is not None
+ }
+ if md5_device:
+ device["md5"] = md5_device
+ if auth.get("plaintext_password"):
+ device["plaintext-password"] = auth["plaintext_password"]
+ return device
+
+
+def _vlink_auth_from_device(data):
+ if not data:
+ return None
+ entry = {}
+ md5_raw = data.get("md5")
+ if md5_raw:
+ entry["md5"] = [
+ {"key_id": int(key_id), "md5_key": (kdata or {}).get("md5-key")}
+ for key_id, kdata in sorted(to_tag_dict(md5_raw).items())
+ ]
+ if data.get("plaintext-password"):
+ entry["plaintext_password"] = data["plaintext-password"]
+ return entry or None
+
+
+def _vlink_entry_to_device(rest):
+ exclude = {"authentication"}
+ device = _kebab_fields({k: v for k, v in rest.items() if k not in exclude})
+ if rest.get("authentication"):
+ auth_device = _vlink_auth_to_device(rest["authentication"])
+ if auth_device:
+ device["authentication"] = auth_device
+ return device
+
+
+def _vlink_entry_from_device(data):
+ exclude = {"authentication"}
+ entry = from_device({k: v for k, v in data.items() if k not in exclude})
+ auth = _vlink_auth_from_device(data.get("authentication"))
+ if auth:
+ entry["authentication"] = auth
+ return entry
+
+
+# ---------------------------------------------------------------------------
+# area -- orchestrates area_type, network, range, virtual_link, and the
+# plain leaves (authentication, shortcut).
+# ---------------------------------------------------------------------------
+
+_NETWORK_KEY = "address"
+_RANGE_KEY = "address"
+_VLINK_KEY = "address"
+
+
+def _area_entry_to_device(rest):
+ exclude = {"area_type", "network", "range", "virtual_link"}
+ device = autoclean({k: v for k, v in rest.items() if k not in exclude})
+
+ at = _area_type_to_device(rest.get("area_type"))
+ if at:
+ device["area-type"] = at
+
+ networks = rest.get("network") or []
+ if networks:
+ device["network"] = {n["address"]: {} for n in networks if n.get("address")}
+
+ ranges = rest.get("range") or []
+ if ranges:
+ device["range"] = _keyed_list_to_device(ranges, _RANGE_KEY)
+
+ vlinks = rest.get("virtual_link") or []
+ if vlinks:
+ device["virtual-link"] = _keyed_list_to_device(vlinks, _VLINK_KEY, _vlink_entry_to_device)
+
+ return device
+
+
+def _area_entry_from_device(data):
+ exclude = {"area-type", "network", "range", "virtual-link"}
+ entry = from_device({k: v for k, v in data.items() if k not in exclude})
+
+ at = _area_type_from_device(data.get("area-type"))
+ if at:
+ entry["area_type"] = at
+
+ net_raw = data.get("network")
+ if net_raw:
+ entry["network"] = [{"address": addr} for addr in sorted(to_tag_dict(net_raw))]
+
+ range_raw = data.get("range")
+ if range_raw:
+ entry["range"] = _keyed_list_from_device(range_raw, _RANGE_KEY)
+
+ vlink_raw = data.get("virtual-link")
+ if vlink_raw:
+ entry["virtual_link"] = _keyed_list_from_device(
+ vlink_raw,
+ _VLINK_KEY,
+ _vlink_entry_from_device,
+ )
+
+ return entry
+
+# ---------------------------------------------------------------------------
+# distance -- confirmed genuine structural exception: argspec's "global"
+# is a reserved-adjacent-but-legal dict key (fine as a string key, no
+# Python keyword issue since it's inside dict(**{...}), unlike "as"
+# elsewhere in this collection), device path is distance.global (a
+# leaf) and distance.ospf.{external,inter-area,intra-area}.
+# ---------------------------------------------------------------------------
-def _parse_distance(raw):
- if not raw or not isinstance(raw, dict):
+
+def _distance_to_device(dist):
+ if not dist:
return {}
- result = {}
- if "global" in raw:
- result["global"] = int(raw["global"])
- ospf = raw.get("ospf", {}) or {}
+ device = {}
+ if dist.get("global") is not None:
+ device["global"] = dist["global"]
+ ospf = dist.get("ospf")
if ospf:
- od = {}
- if "external" in ospf:
- od["external"] = int(ospf["external"])
- if "inter-area" in ospf:
- od["inter_area"] = int(ospf["inter-area"])
- if "intra-area" in ospf:
- od["intra_area"] = int(ospf["intra-area"])
- if od:
- result["ospf"] = od
- return result
+ ospf_device = _kebab_fields(ospf)
+ if ospf_device:
+ device["ospf"] = ospf_device
+ return device
+
+
+def _distance_from_device(data):
+ if not data:
+ return None
+ entry = {}
+ if "global" in data:
+ entry["global"] = int(data["global"])
+ if data.get("ospf"):
+ entry["ospf"] = from_device(data["ospf"])
+ return entry or None
+
+
+# ---------------------------------------------------------------------------
+# timers -- confirmed genuine structural exception: argspec groups
+# "refresh" and "throttle" both under one "timers" parent, but the
+# device has them as two SEPARATE top-level nodes ("refresh" and
+# "timers.throttle") -- not a nested nesting-insertion like most
+# exceptions in this collection, but a nesting *removal*/regrouping.
+# Handled at the top level in build_commands/get_running_config rather
+# than as a single self-contained entry-transform, since it spans two
+# different top-level device keys.
+# ---------------------------------------------------------------------------
+
+
+def _timers_to_device_refresh(timers):
+ """Returns the device's top-level "refresh" node contents."""
+ refresh = (timers or {}).get("refresh") or {}
+ if refresh.get("timers") is not None:
+ return {"timers": refresh["timers"]}
+ return {}
-def get_running_config(vyos):
- raw = vyos.get_config(_BASE)
- if not raw or not isinstance(raw, dict):
- return {}
- result = {}
+def _timers_to_device_throttle(timers):
+ """Returns the device's top-level "timers" node contents (just the
+ throttle.spf subtree, matching confirmed scope)."""
+ throttle = (timers or {}).get("throttle") or {}
+ spf = throttle.get("spf") or {}
+ spf_device = _kebab_fields(spf)
+ if spf_device:
+ return {"throttle": {"spf": spf_device}}
+ return {}
- areas = _parse_areas(raw.get("area"))
- if areas:
- result["areas"] = areas
- ac = raw.get("auto-cost", {})
- if ac and "reference-bandwidth" in ac:
- result["auto_cost"] = {"reference_bandwidth": int(ac["reference-bandwidth"])}
+def _timers_from_device(refresh_raw, timers_raw):
+ entry = {}
+ if refresh_raw and refresh_raw.get("timers") is not None:
+ entry["refresh"] = {"timers": int(refresh_raw["timers"])}
+ throttle_raw = (timers_raw or {}).get("throttle") or {}
+ spf_raw = throttle_raw.get("spf")
+ if spf_raw:
+ entry["throttle"] = {"spf": from_device(spf_raw)}
+ return entry or None
+
+
+# ---------------------------------------------------------------------------
+# passive_interface / passive_interface_exclude -- confirmed against
+# vyos-1x: both map onto the SAME per-interface "interface <name>
+# passive" node -- presence alone means passive-enabled,
+# "passive.disable" (a generic-disable-node) means explicitly
+# excluded. These share one device subtree, so both are handled
+# together rather than as two independent list diffs.
+# ---------------------------------------------------------------------------
+
+
+def _passive_to_device(passive_list, exclude_list):
+ device = {}
+ for iface in passive_list or []:
+ device[iface] = {"passive": {}}
+ for iface in exclude_list or []:
+ device[iface] = {"passive": {"disable": {}}}
+ return device
+
+
+def _passive_from_device(iface_raw):
+ passive = []
+ excluded = []
+ for name, data in sorted((iface_raw or {}).items()):
+ data = data or {}
+ passive_node = data.get("passive")
+ if passive_node is None:
+ continue
+ if isinstance(passive_node, dict) and "disable" in passive_node:
+ excluded.append(name)
+ else:
+ passive.append(name)
+ return passive, excluded
- di = _parse_default_information(raw.get("default-information", {}))
- if di:
- result["default_information"] = di
- if "default-metric" in raw:
- result["default_metric"] = int(raw["default-metric"])
+# ---------------------------------------------------------------------------
+# redistribute -- fully generic once keyed by route_type; metric/
+# metric-type/route-map are all plain leaves.
+# ---------------------------------------------------------------------------
- dist = _parse_distance(raw.get("distance", {}))
- if dist:
- result["distance"] = dist
+_REDISTRIBUTE_KEY = "route_type"
+_NEIGHBOR_KEY = "neighbor_id"
- lac = raw.get("log-adjacency-changes", {})
- if lac:
- if isinstance(lac, dict) and "detail" in lac:
- result["log_adjacency_changes"] = "detail"
- elif lac == "detail":
- result["log_adjacency_changes"] = "detail"
- neighbors = _parse_neighbor(raw.get("neighbor"))
+def _want_to_device(config):
+ config = config or {}
+ device = {}
+
+ areas = config.get("areas") or []
+ if areas:
+ device["area"] = _keyed_list_to_device(areas, "area_id", _area_entry_to_device)
+
+ ac = config.get("auto_cost") or {}
+ if ac.get("reference_bandwidth") is not None:
+ device["auto-cost"] = {"reference-bandwidth": ac["reference_bandwidth"]}
+
+ di = (config.get("default_information") or {}).get("originate") or {}
+ di_device = _kebab_fields(di)
+ if di_device:
+ device["default-information"] = {"originate": di_device}
+
+ if config.get("default_metric") is not None:
+ device["default-metric"] = config["default_metric"]
+
+ dist_device = _distance_to_device(config.get("distance"))
+ if dist_device:
+ device["distance"] = dist_device
+
+ if config.get("log_adjacency_changes"):
+ device["log-adjacency-changes"] = {config["log_adjacency_changes"]: {}}
+
+ mm = (config.get("max_metric") or {}).get("router_lsa") or {}
+ mm_device = _kebab_fields(mm)
+ if mm_device:
+ device["max-metric"] = {"router-lsa": mm_device}
+
+ mpls = config.get("mpls_te") or {}
+ mpls_device = {}
+ if mpls.get("enabled"):
+ mpls_device["enable"] = {}
+ if mpls.get("router_address"):
+ mpls_device["router-address"] = mpls["router_address"]
+ if mpls_device:
+ device["mpls-te"] = mpls_device
+
+ neighbors = config.get("neighbor") or []
if neighbors:
- result["neighbor"] = neighbors
+ device["neighbor"] = _keyed_list_to_device(neighbors, _NEIGHBOR_KEY)
- params = _parse_parameters(raw.get("parameters"))
- if params:
- result["parameters"] = params
+ params_device = _kebab_fields(config.get("parameters") or {})
+ if params_device:
+ device["parameters"] = params_device
- # passive interfaces — VyOS 1.5 uses interface <name> passive
- iface_raw = raw.get("interface", {}) or {}
- passive = sorted(
- [name for name, data in iface_raw.items() if isinstance(data, dict) and "passive" in data],
+ iface_device = _passive_to_device(
+ config.get("passive_interface"),
+ config.get("passive_interface_exclude"),
)
- if passive:
- result["passive_interface"] = passive
+ if iface_device:
+ device["interface"] = iface_device
- redist = _parse_redistribute(raw.get("redistribute"))
+ redist = config.get("redistribute") or []
if redist:
- result["redistribute"] = redist
+ device["redistribute"] = _keyed_list_to_device(redist, _REDISTRIBUTE_KEY)
- return result
+ refresh_device = _timers_to_device_refresh(config.get("timers"))
+ if refresh_device:
+ device["refresh"] = refresh_device
+ throttle_device = _timers_to_device_throttle(config.get("timers"))
+ if throttle_device:
+ device.setdefault("timers", {}).update(throttle_device)
+ return device
-def _area_type_cmds(abase, area_type, have_at):
- cmds = []
- have_at = have_at or {}
- if area_type.get("normal") and not have_at.get("normal"):
- cmds.append(("set", abase + ["area-type", "normal"]))
- nssa = area_type.get("nssa") or {}
- if nssa:
- have_nssa = have_at.get("nssa") or {}
- if not have_nssa:
- cmds.append(("set", abase + ["area-type", "nssa"]))
- if nssa.get("default_cost") and nssa["default_cost"] != have_nssa.get("default_cost"):
- cmds.append(
- (
- "set",
- abase
- + [
- "area-type",
- "nssa",
- "default-cost",
- str(nssa["default_cost"]),
- ],
- ),
- )
- if nssa.get("no_summary") and not have_nssa.get("no_summary"):
- cmds.append(("set", abase + ["area-type", "nssa", "no-summary"]))
- if nssa.get("translate") and nssa["translate"] != have_nssa.get("translate"):
- cmds.append(("set", abase + ["area-type", "nssa", "translate", nssa["translate"]]))
- stub = area_type.get("stub") or {}
- if stub:
- have_stub = have_at.get("stub") or {}
- if not have_stub:
- if stub.get("default_cost"):
- cmds.append(
- (
- "set",
- abase
- + [
- "area-type",
- "stub",
- "default-cost",
- str(stub["default_cost"]),
- ],
- ),
- )
- else:
- cmds.append(("set", abase + ["area-type", "stub"]))
- elif stub.get("default_cost") and stub["default_cost"] != have_stub.get("default_cost"):
- cmds.append(
- (
- "set",
- abase
- + [
- "area-type",
- "stub",
- "default-cost",
- str(stub["default_cost"]),
- ],
- ),
- )
- return cmds
-
-
-def _area_cmds(area, have_area):
- cmds = []
- area_id = area["area_id"]
- abase = _BASE + ["area", area_id]
- have_area = have_area or {}
-
- if area.get("area_type"):
- cmds += _area_type_cmds(abase, area["area_type"], have_area.get("area_type"))
-
- if area.get("authentication") and area["authentication"] != have_area.get("authentication"):
- cmds.append(("set", abase + ["authentication", area["authentication"]]))
-
- if area.get("shortcut") and area["shortcut"] != have_area.get("shortcut"):
- cmds.append(("set", abase + ["shortcut", area["shortcut"]]))
-
- want_nets = {n["address"] for n in (area.get("network") or [])}
- have_nets = {n["address"] for n in (have_area.get("network") or [])}
- for addr in want_nets - have_nets:
- cmds.append(("set", abase + ["network", addr]))
-
- want_ranges = {r["address"]: r for r in (area.get("range") or [])}
- have_ranges = {r["address"]: r for r in (have_area.get("range") or [])}
- for addr, rng in want_ranges.items():
- have_rng = have_ranges.get(addr, {})
- if addr not in have_ranges:
- cmds.append(("set", abase + ["range", addr]))
- if rng.get("cost") and rng["cost"] != have_rng.get("cost"):
- cmds.append(("set", abase + ["range", addr, "cost", str(rng["cost"])]))
- if rng.get("not_advertise") and not have_rng.get("not_advertise"):
- cmds.append(("set", abase + ["range", addr, "not-advertise"]))
- if rng.get("substitute") and rng["substitute"] != have_rng.get("substitute"):
- cmds.append(("set", abase + ["range", addr, "substitute", rng["substitute"]]))
-
- return cmds
-
-
-def _parameters_cmds(params, have_params):
- cmds = []
- have_params = have_params or {}
- pbase = _BASE + ["parameters"]
- if params.get("router_id") and params["router_id"] != have_params.get("router_id"):
- cmds.append(("set", pbase + ["router-id", params["router_id"]]))
- if params.get("abr_type") and params["abr_type"] != have_params.get("abr_type"):
- cmds.append(("set", pbase + ["abr-type", params["abr_type"]]))
- if params.get("opaque_lsa") and not have_params.get("opaque_lsa"):
- cmds.append(("set", pbase + ["opaque-lsa"]))
- if params.get("rfc1583_compatibility") and not have_params.get("rfc1583_compatibility"):
- cmds.append(("set", pbase + ["rfc1583-compatibility"]))
- return cmds
-
-
-def _redistribute_cmds(redist_list, have_redist_list):
- cmds = []
- want = {r["route_type"]: r for r in (redist_list or [])}
- have = {r["route_type"]: r for r in (have_redist_list or [])}
- for rt, entry in want.items():
- have_entry = have.get(rt, {})
- rbase = _BASE + ["redistribute", rt]
- if rt not in have:
- cmds.append(("set", rbase))
- if entry.get("metric") and entry["metric"] != have_entry.get("metric"):
- cmds.append(("set", rbase + ["metric", str(entry["metric"])]))
- if entry.get("metric_type") and entry["metric_type"] != have_entry.get("metric_type"):
- cmds.append(("set", rbase + ["metric-type", str(entry["metric_type"])]))
- if entry.get("route_map") and entry["route_map"] != have_entry.get("route_map"):
- cmds.append(("set", rbase + ["route-map", entry["route_map"]]))
- return cmds
-
-
-def _neighbor_cmds(neighbors, have_neighbors):
- cmds = []
- want = {n["neighbor_id"]: n for n in (neighbors or [])}
- have = {n["neighbor_id"]: n for n in (have_neighbors or [])}
- for nb_id, entry in want.items():
- have_entry = have.get(nb_id, {})
- nbase = _BASE + ["neighbor", nb_id]
- if nb_id not in have:
- cmds.append(("set", nbase))
- if entry.get("priority") and entry["priority"] != have_entry.get("priority"):
- cmds.append(("set", nbase + ["priority", str(entry["priority"])]))
- if entry.get("poll_interval") and entry["poll_interval"] != have_entry.get("poll_interval"):
- cmds.append(("set", nbase + ["poll-interval", str(entry["poll_interval"])]))
- return cmds
-
-
-def _default_info_cmds(di, have_di):
- cmds = []
- have_di = have_di or {}
- orig = (di or {}).get("originate") or {}
- have_orig = have_di.get("originate") or {}
- if not orig:
- return cmds
- dbase = _BASE + ["default-information", "originate"]
- if orig.get("always") and not have_orig.get("always"):
- cmds.append(("set", dbase + ["always"]))
- if orig.get("metric") and orig["metric"] != have_orig.get("metric"):
- cmds.append(("set", dbase + ["metric", str(orig["metric"])]))
- if orig.get("metric_type") and orig["metric_type"] != have_orig.get("metric_type"):
- cmds.append(("set", dbase + ["metric-type", str(orig["metric_type"])]))
- if orig.get("route_map") and orig["route_map"] != have_orig.get("route_map"):
- cmds.append(("set", dbase + ["route-map", orig["route_map"]]))
- return cmds
-
-
-def build_commands(config, have, state):
- cmds = []
- if state == "deleted":
- if have:
- cmds.append(("delete", _BASE))
- return cmds
+def get_running_config(vyos):
+ return vyos.get_config(_BASE) or {}
- if state == "replaced":
- would_set = build_commands(config, {}, "merged")
- have_set = build_commands(have, {}, "merged")
- if would_set == have_set:
- return []
- if have:
- cmds.append(("delete", _BASE))
- have = {}
- config = config or {}
+def _device_to_argspec(raw):
+ if not raw:
+ return {}
+ entry = {}
- # parameters
- if config.get("parameters"):
- cmds += _parameters_cmds(config["parameters"], have.get("parameters"))
+ area_raw = raw.get("area")
+ if area_raw:
+ entry["areas"] = _keyed_list_from_device(area_raw, "area_id", _area_entry_from_device)
- # auto_cost
- ac = config.get("auto_cost") or {}
- have_ac = have.get("auto_cost") or {}
- if ac.get("reference_bandwidth") and ac["reference_bandwidth"] != have_ac.get(
- "reference_bandwidth",
- ):
- cmds.append(
- (
- "set",
- _BASE
- + [
- "auto-cost",
- "reference-bandwidth",
- str(ac["reference_bandwidth"]),
- ],
- ),
- )
+ ac = raw.get("auto-cost") or {}
+ if ac.get("reference-bandwidth") is not None:
+ entry["auto_cost"] = {"reference_bandwidth": int(ac["reference-bandwidth"])}
- # default_information
- if config.get("default_information"):
- cmds += _default_info_cmds(
- config["default_information"],
- have.get("default_information"),
- )
+ di_raw = (raw.get("default-information") or {}).get("originate")
+ if di_raw:
+ entry["default_information"] = {"originate": from_device(di_raw)}
- # default_metric
- if config.get("default_metric") and config["default_metric"] != have.get("default_metric"):
- cmds.append(("set", _BASE + ["default-metric", str(config["default_metric"])]))
-
- # distance
- dist = config.get("distance") or {}
- have_dist = have.get("distance") or {}
- if dist.get("global") and dist["global"] != have_dist.get("global"):
- cmds.append(("set", _BASE + ["distance", "global", str(dist["global"])]))
- ospf_dist = dist.get("ospf") or {}
- have_ospf_dist = have_dist.get("ospf") or {}
- for key, api_key in [
- ("external", "external"),
- ("inter_area", "inter-area"),
- ("intra_area", "intra-area"),
- ]:
- if ospf_dist.get(key) and ospf_dist[key] != have_ospf_dist.get(key):
- cmds.append(("set", _BASE + ["distance", "ospf", api_key, str(ospf_dist[key])]))
-
- # log_adjacency_changes
- if config.get("log_adjacency_changes") and config["log_adjacency_changes"] != have.get(
- "log_adjacency_changes",
- ):
- cmds.append(("set", _BASE + ["log-adjacency-changes", config["log_adjacency_changes"]]))
-
- # neighbor
- if config.get("neighbor"):
- cmds += _neighbor_cmds(config["neighbor"], have.get("neighbor"))
-
- # redistribute
- if config.get("redistribute"):
- cmds += _redistribute_cmds(config["redistribute"], have.get("redistribute"))
-
- # passive_interface — VyOS 1.5 per-interface style
- want_passive = set(config.get("passive_interface") or [])
- have_passive = set(have.get("passive_interface") or [])
- for iface in want_passive - have_passive:
- cmds.append(("set", _BASE + ["interface", iface, "passive"]))
-
- # areas
- have_areas = {a["area_id"]: a for a in (have.get("areas") or [])}
- for area in config.get("areas") or []:
- have_area = have_areas.get(area["area_id"], {})
- cmds += _area_cmds(area, have_area)
-
- return cmds
+ if "default-metric" in raw:
+ entry["default_metric"] = int(raw["default-metric"])
+ dist = _distance_from_device(raw.get("distance"))
+ if dist:
+ entry["distance"] = dist
-ARGUMENT_SPEC = dict(
- config=dict(
+ lac = raw.get("log-adjacency-changes")
+ if lac:
+ lac_dict = to_tag_dict(lac)
+ if "detail" in lac_dict:
+ entry["log_adjacency_changes"] = "detail"
+
+ mm_raw = (raw.get("max-metric") or {}).get("router-lsa")
+ if mm_raw:
+ entry["max_metric"] = {"router_lsa": from_device(mm_raw)}
+
+ mpls_raw = raw.get("mpls-te") or {}
+ mpls_entry = {}
+ if "enable" in mpls_raw:
+ mpls_entry["enabled"] = True
+ if mpls_raw.get("router-address"):
+ mpls_entry["router_address"] = mpls_raw["router-address"]
+ if mpls_entry:
+ entry["mpls_te"] = mpls_entry
+
+ neighbor_raw = raw.get("neighbor")
+ if neighbor_raw:
+ entry["neighbor"] = _keyed_list_from_device(neighbor_raw, _NEIGHBOR_KEY)
+
+ params_raw = raw.get("parameters")
+ if params_raw:
+ entry["parameters"] = from_device(params_raw)
+
+ passive, excluded = _passive_from_device(raw.get("interface"))
+ if passive:
+ entry["passive_interface"] = passive
+ if excluded:
+ entry["passive_interface_exclude"] = excluded
+
+ redist_raw = raw.get("redistribute")
+ if redist_raw:
+ entry["redistribute"] = _keyed_list_from_device(redist_raw, _REDISTRIBUTE_KEY)
+
+ timers = _timers_from_device(raw.get("refresh"), raw.get("timers"))
+ if timers:
+ entry["timers"] = timers
+
+ return entry
+
+
+def _kebab_fields(d):
+ """autoclean, then kebab-convert the resulting keys.
+
+ Safe specifically because every call site below is a leaf-level
+ dict of schema field names (nssa/stub attributes, distance.ospf,
+ default_information.originate, max_metric.router_lsa, parameters,
+ timers.throttle.spf, virtual_link's plain fields, and range/
+ neighbor/redistribute entry fields) with no further nested
+ tag-node-keyed structure underneath -- never an opaque value like
+ an area ID or interface name used as a dict key, which must stay
+ verbatim (confirmed real corruption risk: a blanket recursive
+ conversion turns "my_area" into "my-area").
+
+ Needed because dict_op requires have's keys to already be genuine
+ device kebab-case -- it only normalizes underscores to dashes for
+ its own lookup index, but uses have's key verbatim for the output
+ path. autoclean deliberately leaves keys exactly as given (dict_op
+ is meant to convert during its own want-vs-have comparison), which
+ only works when have comes straight from the device. Here, have is
+ instead reconstructed by round-tripping through this module's own
+ entry-transforms (needed for confirmed structural exceptions like
+ area-type's "set" flag or the shared interface/passive subtree),
+ so any field passed through unconverted stays snake_case and
+ dict_op has no way to recover the real device key. Confirmed as a
+ real bug: "default_cost" appeared in a generated delete command
+ instead of "default-cost".
+ """
+ cleaned = autoclean(d)
+ return {k.replace("_", "-"): v for k, v in cleaned.items()}
+
+
+def build_commands(config, raw_have, state):
+ raw_have = raw_have or {}
+
+ if state == "deleted":
+ return [("delete", _BASE)] if raw_have else []
+
+ want = _want_to_device(config)
+ norm_have = _want_to_device(_device_to_argspec(raw_have))
+
+ commands = []
+ if state == "replaced":
+ commands += dict_op(want, norm_have, _BASE, op="purge")
+ commands += dict_op(want, norm_have, _BASE, op="set")
+ return commands
+
+
+_VLINK_AUTH_OPTIONS = dict(
+ md5=dict(
+ type="list",
+ elements="dict",
+ options=dict(
+ key_id=dict(type="int"),
+ md5_key=dict(type="str", no_log=True),
+ ),
+ ),
+ plaintext_password=dict(type="str", no_log=True),
+)
+
+_VLINK_OPTIONS = dict(
+ address=dict(type="str", required=True),
+ authentication=dict(type="dict", options=_VLINK_AUTH_OPTIONS),
+ dead_interval=dict(type="int"),
+ hello_interval=dict(type="int"),
+ retransmit_interval=dict(type="int"),
+ transmit_delay=dict(type="int"),
+)
+
+_AREA_OPTIONS = dict(
+ area_id=dict(type="str", required=True),
+ area_type=dict(
type="dict",
options=dict(
- areas=dict(
- type="list",
- elements="dict",
+ normal=dict(type="bool"),
+ nssa=dict(
+ type="dict",
options=dict(
- area_id=dict(type="str", required=True),
- area_type=dict(
- type="dict",
- options=dict(
- normal=dict(type="bool"),
- nssa=dict(
- type="dict",
- options=dict(
- set=dict(type="bool"),
- default_cost=dict(type="int"),
- no_summary=dict(type="bool"),
- translate=dict(
- type="str",
- choices=["always", "candidate", "never"],
- ),
- ),
- ),
- stub=dict(
- type="dict",
- options=dict(
- set=dict(type="bool"),
- default_cost=dict(type="int"),
- no_summary=dict(type="bool"),
- ),
- ),
- ),
- ),
- authentication=dict(
- type="str",
- choices=["plaintext-password", "md5"],
- ),
- network=dict(
- type="list",
- elements="dict",
- options=dict(
- address=dict(type="str", required=True),
- ),
- ),
- range=dict(
- type="list",
- elements="dict",
- options=dict(
- address=dict(type="str", required=True),
- cost=dict(type="int"),
- not_advertise=dict(type="bool"),
- substitute=dict(type="str"),
- ),
- ),
- shortcut=dict(type="str", choices=["default", "disable", "enable"]),
+ set=dict(type="bool"),
+ default_cost=dict(type="int"),
+ no_summary=dict(type="bool"),
+ translate=dict(type="str", choices=["always", "candidate", "never"]),
),
),
- auto_cost=dict(
+ stub=dict(
type="dict",
options=dict(
- reference_bandwidth=dict(type="int"),
+ set=dict(type="bool"),
+ default_cost=dict(type="int"),
+ no_summary=dict(type="bool"),
),
),
- default_information=dict(
+ ),
+ ),
+ authentication=dict(type="str", choices=["plaintext-password", "md5"]),
+ network=dict(
+ type="list",
+ elements="dict",
+ options=dict(address=dict(type="str", required=True)),
+ ),
+ range=dict(
+ type="list",
+ elements="dict",
+ options=dict(
+ address=dict(type="str", required=True),
+ cost=dict(type="int"),
+ not_advertise=dict(type="bool"),
+ substitute=dict(type="str"),
+ ),
+ ),
+ shortcut=dict(type="str", choices=["default", "disable", "enable"]),
+ virtual_link=dict(type="list", elements="dict", options=_VLINK_OPTIONS),
+)
+
+_CONFIG_OPTIONS = dict(
+ areas=dict(type="list", elements="dict", options=_AREA_OPTIONS),
+ auto_cost=dict(
+ type="dict",
+ options=dict(reference_bandwidth=dict(type="int")),
+ ),
+ default_information=dict(
+ type="dict",
+ options=dict(
+ originate=dict(
type="dict",
options=dict(
- originate=dict(
- type="dict",
- options=dict(
- always=dict(type="bool"),
- metric=dict(type="int"),
- metric_type=dict(type="int"),
- route_map=dict(type="str"),
- ),
- ),
+ always=dict(type="bool"),
+ metric=dict(type="int"),
+ metric_type=dict(type="int"),
+ route_map=dict(type="str"),
),
),
- default_metric=dict(type="int"),
- distance=dict(
+ ),
+ ),
+ default_metric=dict(type="int"),
+ distance=dict(
+ type="dict",
+ options=dict(
+ **{"global": dict(type="int")},
+ ospf=dict(
type="dict",
options=dict(
- **{"global": dict(type="int")},
- ospf=dict(
- type="dict",
- options=dict(
- external=dict(type="int"),
- inter_area=dict(type="int"),
- intra_area=dict(type="int"),
- ),
- ),
+ external=dict(type="int"),
+ inter_area=dict(type="int"),
+ intra_area=dict(type="int"),
),
),
- log_adjacency_changes=dict(type="str", choices=["detail"]),
- neighbor=dict(
- type="list",
- elements="dict",
+ ),
+ ),
+ log_adjacency_changes=dict(type="str", choices=["detail"]),
+ max_metric=dict(
+ type="dict",
+ options=dict(
+ router_lsa=dict(
+ type="dict",
options=dict(
- neighbor_id=dict(type="str", required=True),
- poll_interval=dict(type="int"),
- priority=dict(type="int"),
+ administrative=dict(type="bool"),
+ on_shutdown=dict(type="int"),
+ on_startup=dict(type="int"),
),
),
- parameters=dict(
+ ),
+ ),
+ mpls_te=dict(
+ type="dict",
+ options=dict(
+ enabled=dict(type="bool"),
+ router_address=dict(type="str"),
+ ),
+ ),
+ neighbor=dict(
+ type="list",
+ elements="dict",
+ options=dict(
+ neighbor_id=dict(type="str", required=True),
+ poll_interval=dict(type="int"),
+ priority=dict(type="int"),
+ ),
+ ),
+ parameters=dict(
+ type="dict",
+ options=dict(
+ abr_type=dict(type="str", choices=["cisco", "ibm", "shortcut", "standard"]),
+ opaque_lsa=dict(type="bool"),
+ rfc1583_compatibility=dict(type="bool"),
+ router_id=dict(type="str"),
+ ),
+ ),
+ passive_interface=dict(type="list", elements="str"),
+ passive_interface_exclude=dict(type="list", elements="str"),
+ redistribute=dict(
+ type="list",
+ elements="dict",
+ options=dict(
+ route_type=dict(type="str", choices=["bgp", "connected", "kernel", "rip", "static"]),
+ metric=dict(type="int"),
+ metric_type=dict(type="int"),
+ route_map=dict(type="str"),
+ ),
+ ),
+ timers=dict(
+ type="dict",
+ options=dict(
+ refresh=dict(
type="dict",
- options=dict(
- abr_type=dict(
- type="str",
- choices=["cisco", "ibm", "shortcut", "standard"],
- ),
- opaque_lsa=dict(type="bool"),
- rfc1583_compatibility=dict(type="bool"),
- router_id=dict(type="str"),
- ),
+ options=dict(timers=dict(type="int")),
),
- passive_interface=dict(type="list", elements="str"),
- redistribute=dict(
- type="list",
- elements="dict",
+ throttle=dict(
+ type="dict",
options=dict(
- route_type=dict(
- type="str",
- choices=["bgp", "connected", "kernel", "rip", "static"],
+ spf=dict(
+ type="dict",
+ options=dict(
+ delay=dict(type="int"),
+ initial_holdtime=dict(type="int"),
+ max_holdtime=dict(type="int"),
+ ),
),
- metric=dict(type="int"),
- metric_type=dict(type="int"),
- route_map=dict(type="str"),
),
),
),
),
+)
+
+ARGUMENT_SPEC = dict(
+ config=dict(type="dict", options=_CONFIG_OPTIONS),
state=dict(
default="merged",
choices=["merged", "replaced", "deleted", "gathered"],
@@ -926,23 +1140,28 @@ def main():
state = module.params["state"]
config = module.params.get("config") or {}
- have = get_running_config(vyos)
+ raw_have = get_running_config(vyos)
+ have = _device_to_argspec(raw_have)
+ cast_by_spec(have, _CONFIG_OPTIONS)
if state == "gathered":
module.exit_json(changed=False, gathered=have)
- commands = build_commands(config, have, state)
+ commands = build_commands(config, raw_have, state)
if module.check_mode:
- module.exit_json(changed=bool(commands), commands=commands, before=have)
+ module.exit_json(changed=bool(commands), commands=commands, before=have, after=have)
if commands:
response = vyos.apply_commands(commands)
saved = vyos.save_config()
+ after_raw = get_running_config(vyos)
+ after = _device_to_argspec(after_raw)
+ cast_by_spec(after, _CONFIG_OPTIONS)
module.exit_json(
changed=True,
before=have,
- after=get_running_config(vyos),
+ after=after,
commands=commands,
saved=saved,
response=response,
diff --git a/plugins/modules/vyos_ospfv3.py b/plugins/modules/vyos_ospfv3.py
index fcf404e..5923a61 100644
--- a/plugins/modules/vyos_ospfv3.py
+++ b/plugins/modules/vyos_ospfv3.py
@@ -1,6 +1,7 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
-# GNU General Public License v3.0+
+# 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
@@ -13,6 +14,22 @@ short_description: Manage OSPFv3 configuration on VyOS devices using REST API
description:
- Manages OSPFv3 configuration on VyOS devices via the REST API.
- Uses REST API (C(connection=httpapi)) instead of CLI.
+ - >-
+ Scope matches the current vyos.vyos.vyos_ospfv3 (CLI collection) module,
+ confirmed against VyOS's official documentation (1.4+/1.5 LTS/rolling).
+ - >-
+ C(areas.interface) (an area-to-interface assignment list) exists in the
+ CLI module's argspec but was deliberately NOT carried over here --
+ confirmed via VyOS's official docs across multiple versions that
+ C(set protocols ospfv3 area <id> interface <name>) is the superseded,
+ 1.3-era syntax. The current mechanism, C(set protocols ospfv3 interface
+ <name> area <id>), is a per-interface setting and is modeled in
+ M(vyos.rest.vyos_ospf_interfaces)'s C(area) field instead (and is not
+ affected by this module's C(state=replaced)).
+ - >-
+ C(distance) and C(graceful-restart) are real, confirmed OSPFv3 features
+ not modeled here, matching a genuine gap in the CLI module's own scope
+ rather than an oversight.
version_added: "1.0.0"
author:
- VyOS Community (@vyos)
@@ -37,7 +54,7 @@ options:
description: Name of import-list.
type: str
range:
- description: Summarize routes matching prefix.
+ description: Summarize routes matching prefix (border routers only).
type: list
elements: dict
suboptions:
@@ -65,6 +82,7 @@ options:
suboptions:
route_type:
description: Protocol to redistribute.
+ required: true
type: str
choices: [bgp, connected, kernel, ripng, static]
route_map:
@@ -83,6 +101,9 @@ options:
notes:
- Requires C(ansible_connection=httpapi) with the VyOS httpapi plugin.
- C(ansible_network_os) must be set to C(vyos.rest.vyos).
+seealso:
+ - module: vyos.vyos.vyos_ospfv3
+ - module: vyos.rest.vyos_ospf_interfaces
"""
EXAMPLES = r"""
@@ -139,184 +160,201 @@ response:
"""
from ansible.module_utils.basic import AnsibleModule
-from ansible_collections.vyos.rest.plugins.module_utils.vyos import VyOSModule
+from ansible_collections.vyos.rest.plugins.module_utils.vyos import (
+ VyOSModule,
+ autoclean,
+ cast_by_spec,
+ dict_op,
+ from_device,
+ to_tag_dict,
+)
_BASE = ["protocols", "ospfv3"]
+def _kebab_fields(d):
+ """autoclean, then kebab-convert the resulting keys.
+
+ Needed because dict_op requires have's keys to already be genuine
+ device kebab-case -- it only normalizes underscores to dashes for
+ its own lookup index, but uses have's key verbatim for the output
+ path. autoclean deliberately leaves keys exactly as given (dict_op
+ is meant to convert during its own want-vs-have comparison), which
+ only works when have comes straight from the device. Here, have is
+ reconstructed by round-tripping through this module's own entry-
+ transforms, so any field passed through unconverted would stay
+ snake_case and dict_op would have no way to recover the real
+ device key -- confirmed as a real bug during vyos_ospfv2's build.
+ Safe here since every call site is a leaf-level dict of schema
+ field names, never an opaque tag-node value like an area ID used
+ as a dict key.
+ """
+ cleaned = autoclean(d)
+ return {k.replace("_", "-"): v for k, v in cleaned.items()}
+
+
+def _derive_key_field(options_spec):
+ required = [k for k, spec in options_spec.items() if spec.get("required")]
+ if len(required) != 1:
+ raise ValueError(
+ "expected exactly one required suboption to serve as the key field, "
+ "found: {0}".format(required),
+ )
+ return required[0]
+
+
+def _keyed_list_to_device(items, key_field, entry_transform=None):
+ entry_transform = entry_transform or _kebab_fields
+ result = {}
+ for item in items or []:
+ if not item.get(key_field):
+ continue
+ rest = {k: v for k, v in item.items() if k != key_field}
+ result[str(item[key_field])] = entry_transform(rest)
+ return result
+
+
+def _keyed_list_from_device(raw, key_field, entry_transform=None):
+ entry_transform = entry_transform or from_device
+ return [
+ {key_field: key, **entry_transform(data or {})}
+ for key, data in sorted(to_tag_dict(raw).items())
+ ]
+
+
+# ---------------------------------------------------------------------------
+# range -- confirmed against CLI: advertise/not_advertise are presence-
+# only opposite flags (not both meaningful at once, though the argspec
+# doesn't enforce mutual exclusivity -- matching the CLI's own scope).
+# Fully generic once keyed by address.
+# ---------------------------------------------------------------------------
+
+_RANGE_KEY = "address"
+
+
+def _area_entry_to_device(rest):
+ exclude = {"range"}
+ device = _kebab_fields({k: v for k, v in rest.items() if k not in exclude})
+ ranges = rest.get("range") or []
+ if ranges:
+ device["range"] = _keyed_list_to_device(ranges, _RANGE_KEY)
+ return device
+
+
+def _area_entry_from_device(data):
+ exclude = {"range"}
+ entry = from_device({k: v for k, v in data.items() if k not in exclude})
+ range_raw = data.get("range")
+ if range_raw:
+ entry["range"] = _keyed_list_from_device(range_raw, _RANGE_KEY)
+ return entry
+
+
+def _want_to_device(config):
+ config = config or {}
+ device = {}
+
+ areas = config.get("areas") or []
+ if areas:
+ device["area"] = _keyed_list_to_device(areas, _AREA_KEY, _area_entry_to_device)
+
+ params_device = _kebab_fields(config.get("parameters") or {})
+ if params_device:
+ device["parameters"] = params_device
+
+ redist = config.get("redistribute") or []
+ if redist:
+ device["redistribute"] = _keyed_list_to_device(redist, _REDISTRIBUTE_KEY)
+
+ return device
+
+
def get_running_config(vyos):
- raw = vyos.get_config(_BASE)
+ """VyOS's REST API collapses a single-child tag node to a plain
+ string (or a list for multiple) -- confirmed as a real failure
+ mode during vyos_ospf_interfaces's build (an unguarded response
+ iterated character-by-character). Normalizing through to_tag_dict
+ unconditionally means callers always receive a genuine dict.
+ """
+ return to_tag_dict(vyos.get_config(_BASE) or {})
+
+
+def _device_to_argspec(raw):
if not raw or not isinstance(raw, dict):
return {}
- return _parse_ospfv3(raw)
+ entry = {}
+ area_raw = raw.get("area")
+ if area_raw:
+ areas = _keyed_list_from_device(area_raw, _AREA_KEY, _area_entry_from_device)
+ if areas:
+ entry["areas"] = areas
-def _parse_ospfv3(raw):
- result = {}
+ params_raw = raw.get("parameters")
+ if params_raw:
+ entry["parameters"] = from_device(params_raw)
- # parameters
- params = raw.get("parameters", {})
- if params:
- result["parameters"] = {}
- if "router-id" in params:
- result["parameters"]["router_id"] = params["router-id"]
-
- # redistribute
- redist_raw = raw.get("redistribute", {})
- if redist_raw and isinstance(redist_raw, dict):
- redist = []
- for route_type, data in sorted(redist_raw.items()):
- entry = {"route_type": route_type}
- if isinstance(data, dict) and data.get("route-map"):
- entry["route_map"] = data["route-map"]
- redist.append(entry)
- if redist:
- result["redistribute"] = redist
-
- # areas
- area_raw = raw.get("area", {})
- if area_raw and isinstance(area_raw, dict):
- areas = []
- for area_id, area_data in sorted(area_raw.items()):
- area = {"area_id": area_id}
- area_data = area_data or {}
- if area_data.get("export-list"):
- area["export_list"] = area_data["export-list"]
- if area_data.get("import-list"):
- area["import_list"] = area_data["import-list"]
- range_raw = area_data.get("range", {})
- if range_raw and isinstance(range_raw, dict):
- ranges = []
- for prefix, rdata in sorted(range_raw.items()):
- r = {"address": prefix}
- rdata = rdata or {}
- if "advertise" in rdata:
- r["advertise"] = True
- if "not-advertise" in rdata:
- r["not_advertise"] = True
- ranges.append(r)
- if ranges:
- area["range"] = ranges
- areas.append(area)
- if areas:
- result["areas"] = areas
+ redist_raw = raw.get("redistribute")
+ if redist_raw:
+ entry["redistribute"] = _keyed_list_from_device(redist_raw, _REDISTRIBUTE_KEY)
- return result
+ return entry
-def build_commands(config, have, state):
- cmds = []
+def build_commands(config, raw_have, state):
+ raw_have = raw_have or {}
if state == "deleted":
- if have:
- cmds.append(("delete", _BASE))
- return cmds
+ return [("delete", _BASE)] if raw_have else []
+
+ want = _want_to_device(config)
+ norm_have = _want_to_device(_device_to_argspec(raw_have))
+ commands = []
if state == "replaced":
- # Build what we would set from scratch and compare to have
- would_set = build_commands(config, {}, "merged")
- have_set = build_commands(have, {}, "merged")
- if would_set == have_set:
- return []
- if have:
- cmds.append(("delete", _BASE))
- have = {}
-
- # parameters
- want_params = (config or {}).get("parameters") or {}
- have_params = have.get("parameters") or {}
- if want_params.get("router_id") and want_params["router_id"] != have_params.get("router_id"):
- cmds.append(("set", _BASE + ["parameters", "router-id", want_params["router_id"]]))
-
- # redistribute
- want_redist = {r["route_type"]: r for r in ((config or {}).get("redistribute") or [])}
- have_redist = {r["route_type"]: r for r in (have.get("redistribute") or [])}
-
- for rt in set(have_redist) - set(want_redist):
- if state == "merged":
- pass # merged doesn't remove
- for rt, entry in want_redist.items():
- if rt not in have_redist:
- cmds.append(("set", _BASE + ["redistribute", rt]))
- if entry.get("route_map"):
- have_rm = have_redist.get(rt, {}).get("route_map")
- if entry["route_map"] != have_rm:
- cmds.append(("set", _BASE + ["redistribute", rt, "route-map", entry["route_map"]]))
-
- # areas
- want_areas = {a["area_id"]: a for a in ((config or {}).get("areas") or [])}
- have_areas = {a["area_id"]: a for a in (have.get("areas") or [])}
-
- for area_id, want_area in want_areas.items():
- have_area = have_areas.get(area_id, {})
- abase = _BASE + ["area", area_id]
-
- if want_area.get("export_list") and want_area["export_list"] != have_area.get(
- "export_list",
- ):
- cmds.append(("set", abase + ["export-list", want_area["export_list"]]))
- if want_area.get("import_list") and want_area["import_list"] != have_area.get(
- "import_list",
- ):
- cmds.append(("set", abase + ["import-list", want_area["import_list"]]))
-
- want_ranges = {r["address"]: r for r in (want_area.get("range") or [])}
- have_ranges = {r["address"]: r for r in (have_area.get("range") or [])}
-
- for addr in want_ranges:
- if addr not in have_ranges:
- cmds.append(("set", abase + ["range", addr]))
- r = want_ranges[addr]
- if r.get("not_advertise"):
- cmds.append(("set", abase + ["range", addr, "not-advertise"]))
- elif r.get("advertise"):
- cmds.append(("set", abase + ["range", addr, "advertise"]))
-
- return cmds
+ commands += dict_op(want, norm_have, _BASE, op="purge")
+ commands += dict_op(want, norm_have, _BASE, op="set")
+ return commands
-ARGUMENT_SPEC = dict(
- config=dict(
- type="dict",
- options=dict(
- areas=dict(
- type="list",
- elements="dict",
- options=dict(
- area_id=dict(type="str", required=True),
- export_list=dict(type="str"),
- import_list=dict(type="str"),
- range=dict(
- type="list",
- elements="dict",
- options=dict(
- address=dict(type="str", required=True),
- advertise=dict(type="bool"),
- not_advertise=dict(type="bool"),
- ),
- ),
- ),
- ),
- parameters=dict(
- type="dict",
- options=dict(
- router_id=dict(type="str"),
- ),
- ),
- redistribute=dict(
- type="list",
- elements="dict",
- options=dict(
- route_type=dict(
- type="str",
- choices=["bgp", "connected", "kernel", "ripng", "static"],
- ),
- route_map=dict(type="str"),
- ),
- ),
- ),
+_RANGE_OPTIONS = dict(
+ address=dict(type="str", required=True),
+ advertise=dict(type="bool"),
+ not_advertise=dict(type="bool"),
+)
+
+_AREA_OPTIONS = dict(
+ area_id=dict(type="str", required=True),
+ export_list=dict(type="str"),
+ import_list=dict(type="str"),
+ range=dict(type="list", elements="dict", options=_RANGE_OPTIONS),
+)
+
+_PARAMETERS_OPTIONS = dict(
+ router_id=dict(type="str"),
+)
+
+_REDISTRIBUTE_OPTIONS = dict(
+ route_type=dict(
+ type="str",
+ required=True,
+ choices=["bgp", "connected", "kernel", "ripng", "static"],
),
+ route_map=dict(type="str"),
+)
+
+_AREA_KEY = _derive_key_field(_AREA_OPTIONS)
+_REDISTRIBUTE_KEY = _derive_key_field(_REDISTRIBUTE_OPTIONS)
+
+_CONFIG_OPTIONS = dict(
+ areas=dict(type="list", elements="dict", options=_AREA_OPTIONS),
+ parameters=dict(type="dict", options=_PARAMETERS_OPTIONS),
+ redistribute=dict(type="list", elements="dict", options=_REDISTRIBUTE_OPTIONS),
+)
+
+ARGUMENT_SPEC = dict(
+ config=dict(type="dict", options=_CONFIG_OPTIONS),
state=dict(
default="merged",
choices=["merged", "replaced", "deleted", "gathered"],
@@ -331,12 +369,14 @@ def main():
state = module.params["state"]
config = module.params.get("config") or {}
- have = get_running_config(vyos)
+ raw_have = get_running_config(vyos)
+ have = _device_to_argspec(raw_have)
+ cast_by_spec(have, _CONFIG_OPTIONS)
if state == "gathered":
module.exit_json(changed=False, gathered=have)
- commands = build_commands(config, have, state)
+ commands = build_commands(config, raw_have, state)
if module.check_mode:
module.exit_json(changed=bool(commands), commands=commands, before=have)
@@ -344,10 +384,13 @@ def main():
if commands:
response = vyos.apply_commands(commands)
saved = vyos.save_config()
+ after_raw = get_running_config(vyos)
+ after = _device_to_argspec(after_raw)
+ cast_by_spec(after, _CONFIG_OPTIONS)
module.exit_json(
changed=True,
before=have,
- after=get_running_config(vyos),
+ after=after,
commands=commands,
saved=saved,
response=response,
diff --git a/tests/unit/fixtures/l3_interfaces_running.json b/tests/unit/fixtures/l3_interfaces_running.json
index 676055b..3115861 100644
--- a/tests/unit/fixtures/l3_interfaces_running.json
+++ b/tests/unit/fixtures/l3_interfaces_running.json
@@ -1,21 +1,12 @@
{
"ethernet": {
"eth0": {
- "address": "dhcp",
- "hw-id": "52:54:00:65:5a:24",
- "vif": {
- "100": {
- "address": "192.0.2.100/24"
- }
- }
- }
+ "address": ["192.0.2.1/24", "2001:db8::1/64"],
+ "vif": { "100": { "address": ["192.0.2.100/24"] } }
+ },
+ "eth1": {}
},
"loopback": {
- "lo": {
- "address": ["10.0.0.1/32", "10.0.0.2/32"]
- }
- },
- "dummy": {
- "dummy0": {}
+ "lo": { "address": ["10.0.0.1/32"] }
}
}
diff --git a/tests/unit/fixtures/ospf_interfaces_ipv4.json b/tests/unit/fixtures/ospf_interfaces_ipv4.json
index 1f199a6..9b40f66 100644
--- a/tests/unit/fixtures/ospf_interfaces_ipv4.json
+++ b/tests/unit/fixtures/ospf_interfaces_ipv4.json
@@ -1,4 +1,13 @@
{
- "eth1": { "cost": "100", "transmit-delay": "50", "priority": "26" },
- "eth2": { "cost": "200" }
+ "eth1": {
+ "cost": "100",
+ "transmit-delay": "50",
+ "priority": "26",
+ "dead-interval": "40",
+ "mtu-ignore": {},
+ "authentication": { "md5": { "key-id": { "10": { "md5-key": "secret" } } } }
+ },
+ "eth2": {
+ "cost": "200"
+ }
}
diff --git a/tests/unit/fixtures/ospf_interfaces_ipv6.json b/tests/unit/fixtures/ospf_interfaces_ipv6.json
index 580cecf..5d96817 100644
--- a/tests/unit/fixtures/ospf_interfaces_ipv6.json
+++ b/tests/unit/fixtures/ospf_interfaces_ipv6.json
@@ -1,4 +1,9 @@
{
- "eth1": { "dead-interval": "39", "passive": {} },
+ "eth1": {
+ "dead-interval": "39",
+ "passive": {},
+ "instance-id": "5",
+ "ifmtu": "1500"
+ },
"eth2": {}
}
diff --git a/tests/unit/fixtures/ospfv2_running.json b/tests/unit/fixtures/ospfv2_running.json
index 9cc64a0..e5b5d72 100644
--- a/tests/unit/fixtures/ospfv2_running.json
+++ b/tests/unit/fixtures/ospfv2_running.json
@@ -2,41 +2,70 @@
"area": {
"2": {
"area-type": { "normal": {} },
- "network": "192.0.2.0/24"
- },
- "3": {
- "area-type": { "nssa": {} }
+ "authentication": "plaintext-password",
+ "shortcut": "enable"
},
"4": {
- "area-type": { "stub": { "default-cost": "20" } },
- "range": {
- "192.0.3.0/24": { "cost": "10" },
- "192.0.4.0/24": {}
+ "area-type": { "stub": { "default-cost": "20", "no-summary": {} } },
+ "network": { "192.0.2.0/24": {} },
+ "range": { "192.0.3.0/24": { "cost": "10", "not-advertise": {} } },
+ "virtual-link": {
+ "10.0.0.1": {
+ "authentication": { "plaintext-password": "secret" },
+ "dead-interval": "40",
+ "hello-interval": "10"
+ }
}
+ },
+ "5": {
+ "area-type": { "nssa": { "default-cost": "5", "translate": "always" } }
}
},
"auto-cost": { "reference-bandwidth": "2" },
- "interface": { "eth1": { "passive": {} }, "eth2": { "passive": {} } },
+ "default-information": {
+ "originate": {
+ "always": {},
+ "metric": "10",
+ "metric-type": "2",
+ "route-map": "ingress"
+ }
+ },
+ "default-metric": "5",
+ "distance": {
+ "global": "110",
+ "ospf": { "external": "150", "inter-area": "120", "intra-area": "100" }
+ },
"log-adjacency-changes": { "detail": {} },
- "neighbor": {
- "192.0.11.12": { "priority": "2", "poll-interval": "10" }
+ "max-metric": {
+ "router-lsa": {
+ "administrative": {},
+ "on-shutdown": "10",
+ "on-startup": "20"
+ }
},
+ "mpls-te": { "enable": {}, "router-address": "192.0.11.11" },
+ "neighbor": { "192.0.11.12": { "priority": "2", "poll-interval": "30" } },
"parameters": {
"router-id": "192.0.1.1",
- "abr-type": "cisco",
"opaque-lsa": {},
- "rfc1583-compatibility": {}
+ "rfc1583-compatibility": {},
+ "abr-type": "cisco"
+ },
+ "interface": {
+ "eth1": { "passive": {} },
+ "eth2": { "passive": { "disable": {} } }
},
"redistribute": {
- "bgp": { "metric": "10", "metric-type": "2" },
- "connected": {}
+ "bgp": { "metric": "10", "metric-type": "2", "route-map": "redist-map" }
},
- "default-information": {
- "originate": {
- "always": {},
- "metric": "10",
- "metric-type": "2",
- "route-map": "ingress"
+ "refresh": { "timers": "300" },
+ "timers": {
+ "throttle": {
+ "spf": {
+ "delay": "200",
+ "initial-holdtime": "500",
+ "max-holdtime": "2000"
+ }
}
}
}
diff --git a/tests/unit/fixtures/ospfv3_running.json b/tests/unit/fixtures/ospfv3_running.json
index 44a1bcd..6588a1d 100644
--- a/tests/unit/fixtures/ospfv3_running.json
+++ b/tests/unit/fixtures/ospfv3_running.json
@@ -1,6 +1,4 @@
{
- "parameters": { "router-id": "192.0.2.10" },
- "redistribute": { "bgp": {}, "connected": { "route-map": "RM1" } },
"area": {
"2": {
"export-list": "export1",
@@ -11,9 +9,12 @@
}
},
"3": {
- "range": {
- "2001:db40::/32": {}
- }
+ "range": { "2001:db40::/32": { "advertise": {} } }
}
+ },
+ "parameters": { "router-id": "192.0.2.10" },
+ "redistribute": {
+ "bgp": { "route-map": "redist-map" },
+ "static": {}
}
}
diff --git a/tests/unit/modules/test_vyos_l3_interfaces.py b/tests/unit/modules/test_vyos_l3_interfaces.py
index 66cd1fc..79fc8cf 100644
--- a/tests/unit/modules/test_vyos_l3_interfaces.py
+++ b/tests/unit/modules/test_vyos_l3_interfaces.py
@@ -4,300 +4,216 @@ from __future__ import absolute_import, division, print_function
__metaclass__ = type
-import json
-import os
import unittest
from unittest.mock import MagicMock
from ansible_collections.vyos.rest.plugins.modules.vyos_l3_interfaces import (
_addr_cmds,
- _addr_list,
- _normalize,
- _parse_iface,
+ _device_to_argspec,
+ _guess_iface_type,
+ _iface_base,
+ _resolve_iface_type,
_split_addresses,
+ _vif_addr_cmds,
build_commands,
get_running_config,
)
+from .base import load_fixture
-def load_fixture(filename):
- fixtures_dir = os.path.join(os.path.dirname(__file__), "..", "fixtures")
- path = os.path.join(fixtures_dir, filename)
- with open(path) as f:
- return json.load(f)
+
+_BASE = ["interfaces"]
class VyOSModuleTestCase(unittest.TestCase):
def setUp(self):
self.mock_vyos = MagicMock()
- self.mock_vyos.get_config = MagicMock(return_value={})
-
- def set_running_config(self, data):
- self.mock_vyos.get_config.return_value = data
-
-
-class TestVyOSL3InterfacesAddrList(unittest.TestCase):
-
- def test_string_returns_list(self):
- self.assertEqual(_addr_list("dhcp"), ["dhcp"])
-
- def test_list_returned_sorted(self):
- result = _addr_list(["10.0.0.2/32", "10.0.0.1/32"])
- self.assertEqual(result, ["10.0.0.1/32", "10.0.0.2/32"])
-
- def test_none_returns_empty(self):
- self.assertEqual(_addr_list(None), [])
-
- def test_empty_list_returns_empty(self):
- self.assertEqual(_addr_list([]), [])
-
-
-class TestVyOSL3InterfacesSplitAddresses(unittest.TestCase):
-
- def test_dhcp_goes_to_ipv4(self):
- ipv4, ipv6 = _split_addresses(["dhcp"])
- self.assertIn("dhcp", ipv4)
- self.assertEqual(ipv6, [])
-
- def test_dhcpv6_goes_to_ipv6(self):
- ipv4, ipv6 = _split_addresses(["dhcpv6"])
- self.assertEqual(ipv4, [])
- self.assertIn("dhcpv6", ipv6)
-
- def test_ipv4_cidr(self):
- ipv4, ipv6 = _split_addresses(["192.0.2.1/24"])
- self.assertIn("192.0.2.1/24", ipv4)
- self.assertEqual(ipv6, [])
-
- def test_ipv6_cidr(self):
- ipv4, ipv6 = _split_addresses(["2001:db8::1/128"])
- self.assertEqual(ipv4, [])
- self.assertIn("2001:db8::1/128", ipv6)
-
- def test_mixed(self):
- ipv4, ipv6 = _split_addresses(["dhcp", "192.0.2.1/24", "2001:db8::1/128"])
- self.assertIn("dhcp", ipv4)
- self.assertIn("192.0.2.1/24", ipv4)
- self.assertIn("2001:db8::1/128", ipv6)
-
-
-class TestVyOSL3InterfacesParseIface(unittest.TestCase):
-
- def test_parse_dhcp(self):
- result = _parse_iface("eth0", {"address": "dhcp"})
- self.assertEqual(result["name"], "eth0")
- self.assertEqual(result["ipv4"], [{"address": "dhcp"}])
-
- def test_parse_multiple_addresses(self):
- result = _parse_iface("lo", {"address": ["10.0.0.1/32", "10.0.0.2/32"]})
- addrs = [a["address"] for a in result["ipv4"]]
- self.assertIn("10.0.0.1/32", addrs)
- self.assertIn("10.0.0.2/32", addrs)
-
- def test_parse_vif(self):
- result = _parse_iface(
- "eth0",
- {
- "address": "dhcp",
- "vif": {"100": {"address": "192.0.2.100/24"}},
- },
- )
- self.assertIn("vifs", result)
- self.assertEqual(result["vifs"][0]["vlan_id"], 100)
- self.assertEqual(result["vifs"][0]["ipv4"][0]["address"], "192.0.2.100/24")
-
- def test_parse_no_address(self):
- result = _parse_iface("lo", {})
- self.assertNotIn("ipv4", result)
- self.assertNotIn("ipv6", result)
-
- def test_hw_id_ignored(self):
- result = _parse_iface("eth0", {"hw-id": "52:54:00:65:5a:24"})
- self.assertNotIn("hw_id", result)
- self.assertNotIn("hw-id", result)
-
-
-class TestVyOSL3InterfacesGetRunningFixture(VyOSModuleTestCase):
-
- def setUp(self):
- super().setUp()
self.fixture = load_fixture("l3_interfaces_running.json")
+ self.mock_vyos.get_config = MagicMock(return_value=self.fixture)
- def test_fixture_parses_eth0_dhcp(self):
- self.set_running_config(self.fixture)
- result = get_running_config(self.mock_vyos)
- eth0 = next((e for e in result if e["name"] == "eth0"), None)
- self.assertIsNotNone(eth0)
- addrs = [a["address"] for a in eth0.get("ipv4", [])]
- self.assertIn("dhcp", addrs)
+ def gather(self):
+ return _device_to_argspec(self.fixture)
- def test_fixture_parses_loopback_addresses(self):
- self.set_running_config(self.fixture)
- result = get_running_config(self.mock_vyos)
- lo = next((e for e in result if e["name"] == "lo"), None)
- self.assertIsNotNone(lo)
- addrs = [a["address"] for a in lo.get("ipv4", [])]
- self.assertIn("10.0.0.1/32", addrs)
- self.assertIn("10.0.0.2/32", addrs)
-
- def test_fixture_parses_vif(self):
- self.set_running_config(self.fixture)
- result = get_running_config(self.mock_vyos)
- eth0 = next(e for e in result if e["name"] == "eth0")
- self.assertIn("vifs", eth0)
- self.assertEqual(eth0["vifs"][0]["vlan_id"], 100)
-
- def test_empty_interface_not_included(self):
- self.set_running_config(self.fixture)
- result = get_running_config(self.mock_vyos)
- names = [e["name"] for e in result]
- # loopback with no addresses should not appear
- self.assertNotIn("dummy0", names)
-
-
-class TestVyOSL3InterfacesGetRunning(VyOSModuleTestCase):
- def test_empty_returns_empty_list(self):
- self.set_running_config({})
+class TestGetRunningConfig(VyOSModuleTestCase):
+ def test_returns_config_directly(self):
result = get_running_config(self.mock_vyos)
- self.assertEqual(result, [])
-
- def test_interface_without_address_excluded(self):
- self.set_running_config(
- {
- "ethernet": {"eth0": {"hw-id": "52:54:00:65:5a:24"}},
- },
- )
- result = get_running_config(self.mock_vyos)
- self.assertEqual(result, [])
-
-
-class TestVyOSL3InterfacesNormalize(unittest.TestCase):
-
- def test_normalize_ipv4(self):
- config = [
- {
- "name": "lo",
- "ipv4": [{"address": "10.0.0.1/32"}],
- },
- ]
- result = _normalize(config)
- self.assertIn("lo", result)
- self.assertIn("10.0.0.1/32", result["lo"]["ipv4"])
-
- def test_normalize_vif(self):
- config = [
- {
- "name": "eth0",
- "vifs": [{"vlan_id": 100, "ipv4": [{"address": "192.0.2.100/24"}]}],
- },
- ]
- result = _normalize(config)
- self.assertIn(100, result["eth0"]["vifs"])
- self.assertIn("192.0.2.100/24", result["eth0"]["vifs"][100]["ipv4"])
-
- def test_normalize_empty(self):
- result = _normalize([])
- self.assertEqual(result, {})
-
-
-class TestVyOSL3InterfacesAddrCmds(unittest.TestCase):
-
- def test_adds_new_address(self):
- base = ["interfaces", "loopback", "lo"]
- cmds = _addr_cmds(base, ["10.0.0.1/32"], [], "merged")
- self.assertIn(("set", base + ["address", "10.0.0.1/32"]), cmds)
-
- def test_idempotent(self):
- base = ["interfaces", "loopback", "lo"]
- cmds = _addr_cmds(base, ["10.0.0.1/32"], ["10.0.0.1/32"], "merged")
- self.assertEqual(cmds, [])
-
- def test_merged_does_not_delete_extra(self):
- base = ["interfaces", "loopback", "lo"]
- cmds = _addr_cmds(base, ["10.0.0.1/32"], ["10.0.0.1/32", "10.0.0.2/32"], "merged")
- self.assertEqual(cmds, [])
-
- def test_replaced_deletes_extra(self):
- base = ["interfaces", "loopback", "lo"]
- cmds = _addr_cmds(base, ["10.0.0.1/32"], ["10.0.0.1/32", "10.0.0.2/32"], "replaced")
- self.assertIn(("delete", base + ["address", "10.0.0.2/32"]), cmds)
-
-
-class TestVyOSL3InterfacesBuildCommands(unittest.TestCase):
-
- def _have_lo(self):
- return [
- {
- "name": "lo",
- "ipv4": [
- {"address": "10.0.0.1/32"},
- {"address": "10.0.0.2/32"},
- ],
- },
- ]
-
- def test_merged_adds_address(self):
- config = [{"name": "lo", "ipv4": [{"address": "10.0.0.3/32"}]}]
- cmds = build_commands(config, [], "merged")
- self.assertIn(
- ("set", ["interfaces", "loopback", "lo", "address", "10.0.0.3/32"]),
- cmds,
- )
-
- def test_merged_idempotent(self):
- cmds = build_commands(self._have_lo(), self._have_lo(), "merged")
- self.assertEqual(cmds, [])
-
- def test_deleted_no_config_removes_all(self):
- cmds = build_commands([], self._have_lo(), "deleted")
- paths = [c[1] for c in cmds]
- self.assertIn(["interfaces", "loopback", "lo", "address", "10.0.0.1/32"], paths)
- self.assertIn(["interfaces", "loopback", "lo", "address", "10.0.0.2/32"], paths)
-
- def test_deleted_with_config_removes_interface_addresses(self):
- config = [{"name": "lo"}]
- cmds = build_commands(config, self._have_lo(), "deleted")
- paths = [c[1] for c in cmds]
- self.assertIn(["interfaces", "loopback", "lo", "address", "10.0.0.1/32"], paths)
-
- def test_deleted_idempotent_when_empty(self):
- cmds = build_commands([], [], "deleted")
+ self.assertIn("ethernet", result)
+
+ def test_empty_config(self):
+ self.mock_vyos.get_config = MagicMock(return_value=None)
+ self.assertEqual(get_running_config(self.mock_vyos), {})
+
+ def test_collapsed_response_normalized(self):
+ self.mock_vyos.get_config = MagicMock(return_value="eth0")
+ self.assertEqual(get_running_config(self.mock_vyos), {"eth0": {}})
+
+
+class TestTypeResolution(unittest.TestCase):
+ def test_resolves_from_have_when_present(self):
+ raw_have = {"loopback": {"lo": {}}}
+ self.assertEqual(_resolve_iface_type("lo", raw_have), "loopback")
+
+ def test_falls_back_to_guess_for_new_interface(self):
+ self.assertEqual(_resolve_iface_type("eth5", {}), "ethernet")
+
+ def test_guess_covers_all_11_types_aligned_with_vyos_interfaces(self):
+ """Confirmed original inconsistency: this module's own type
+ table was missing ppp/wlan compared to vyos_interfaces' table,
+ despite both modules operating on the same interface
+ namespace. Aligned here."""
+ expected = {
+ "eth0": "ethernet",
+ "bond0": "bonding",
+ "lo": "loopback",
+ "tun0": "tunnel",
+ "wg0": "wireguard",
+ "vti0": "vti",
+ "dum0": "dummy",
+ "vtun0": "openvpn",
+ "ppp0": "pppoe",
+ "wlan0": "wireless",
+ "br0": "bridge",
+ }
+ for name, itype in expected.items():
+ self.assertEqual(_guess_iface_type(name), itype)
+
+ def test_iface_base_uses_resolved_type(self):
+ raw_have = {"bonding": {"bond0": {}}}
+ self.assertEqual(_iface_base("bond0", raw_have), ["interfaces", "bonding", "bond0"])
+
+
+class TestSplitAddresses(unittest.TestCase):
+ def test_ipv4_ipv6_dhcp_dhcpv6(self):
+ ipv4, ipv6 = _split_addresses(["192.0.2.1/24", "2001:db8::1/64", "dhcp", "dhcpv6"])
+ self.assertEqual(ipv4, ["192.0.2.1/24", "dhcp"])
+ self.assertEqual(ipv6, ["2001:db8::1/64", "dhcpv6"])
+
+ def test_auto_config(self):
+ ipv4, ipv6 = _split_addresses(["auto-config"])
+ self.assertEqual(ipv6, ["auto-config"])
+
+
+class TestAddrCmds(unittest.TestCase):
+ """Confirmed scoped correctly: address commands only ever touch
+ the "address" leaf under the given base, regardless of whether
+ that base is an interface or a vif."""
+
+ def test_set_new_address(self):
+ cmds = _addr_cmds(["interfaces", "ethernet", "eth0"], ["192.0.2.1/24"], [], "merged")
+ expected = ("set", ["interfaces", "ethernet", "eth0", "address", "192.0.2.1/24"])
+ self.assertEqual(cmds, [expected])
+
+ def test_merged_never_deletes(self):
+ cmds = _addr_cmds(["interfaces", "ethernet", "eth0"], [], ["192.0.2.1/24"], "merged")
self.assertEqual(cmds, [])
- def test_replaced_removes_extra_address(self):
- config = [{"name": "lo", "ipv4": [{"address": "10.0.0.1/32"}]}]
- cmds = build_commands(config, self._have_lo(), "replaced")
- self.assertIn(
- ("delete", ["interfaces", "loopback", "lo", "address", "10.0.0.2/32"]),
- cmds,
+ def test_replaced_deletes_omitted(self):
+ cmds = _addr_cmds(["interfaces", "ethernet", "eth0"], [], ["192.0.2.1/24"], "replaced")
+ expected = ("delete", ["interfaces", "ethernet", "eth0", "address", "192.0.2.1/24"])
+ self.assertEqual(cmds, [expected])
+
+
+class TestVifAddrCmdsNeverWholeSubtree(unittest.TestCase):
+ """Primary confirmed severe bug: the original generated a whole-
+ VIF delete (`delete ... vif <id>`) for an omitted VIF under
+ replaced/overridden/deleted. Since VIFs are shared with
+ vyos_interfaces (which owns description/mtu/disable on them),
+ this would destroy that module's own fields, not just this
+ module's addresses. Confirmed 4 separate occurrences in the
+ original build_commands. Fixed: every VIF operation here is
+ scoped to exactly the address leaf, never the VIF subtree."""
+
+ def test_omitted_vif_only_deletes_its_addresses(self):
+ have_vifs = {100: {"ipv4": ["192.0.2.100/24"], "ipv6": []}}
+ cmds = _vif_addr_cmds(["interfaces", "ethernet", "eth0"], {}, have_vifs, "replaced")
+ whole_vif_delete = ("delete", ["interfaces", "ethernet", "eth0", "vif", "100"])
+ self.assertNotIn(whole_vif_delete, cmds)
+ vif_addr_delete = (
+ "delete",
+ ["interfaces", "ethernet", "eth0", "vif", "100", "address", "192.0.2.100/24"],
)
-
- def test_overridden_removes_unlisted_interface(self):
- config = [{"name": "lo", "ipv4": [{"address": "10.0.0.1/32"}]}]
- have = self._have_lo() + [
- {
- "name": "eth0",
- "ipv4": [{"address": "192.0.2.1/24"}],
- },
- ]
- cmds = build_commands(config, have, "overridden")
- self.assertIn(
- ("delete", ["interfaces", "ethernet", "eth0", "address", "192.0.2.1/24"]),
- cmds,
- )
-
- def test_vif_added(self):
+ self.assertIn(vif_addr_delete, cmds)
+
+ def test_deleted_state_also_never_whole_subtree(self):
+ have_vifs = {100: {"ipv4": ["192.0.2.100/24"], "ipv6": []}}
+ cmds = _vif_addr_cmds(["interfaces", "ethernet", "eth0"], {}, have_vifs, "deleted")
+ whole_vif_delete = ("delete", ["interfaces", "ethernet", "eth0", "vif", "100"])
+ self.assertNotIn(whole_vif_delete, cmds)
+
+ def test_overridden_also_never_whole_subtree(self):
+ have_vifs = {100: {"ipv4": ["192.0.2.100/24"], "ipv6": []}}
+ cmds = _vif_addr_cmds(["interfaces", "ethernet", "eth0"], {}, have_vifs, "overridden")
+ whole_vif_delete = ("delete", ["interfaces", "ethernet", "eth0", "vif", "100"])
+ self.assertNotIn(whole_vif_delete, cmds)
+
+
+class TestDeviceToArgspecFixture(VyOSModuleTestCase):
+ def test_all_interfaces_with_addresses_present(self):
+ have = self.gather()
+ names = {e["name"] for e in have}
+ # eth1 has no addresses at all -- must not appear
+ self.assertEqual(names, {"eth0", "lo"})
+
+ def test_eth0_addresses_and_vif_parsed(self):
+ have = self.gather()
+ eth0 = next(e for e in have if e["name"] == "eth0")
+ self.assertEqual(eth0["ipv4"][0]["address"], "192.0.2.1/24")
+ self.assertEqual(eth0["ipv6"][0]["address"], "2001:db8::1/64")
+ self.assertEqual(eth0["vifs"][0]["vlan_id"], 100)
+ self.assertEqual(eth0["vifs"][0]["ipv4"][0]["address"], "192.0.2.100/24")
+
+ def test_collapsed_single_address_string(self):
+ entry = _device_to_argspec({"ethernet": {"eth0": {"address": "192.0.2.1/24"}}})
+ self.assertEqual(entry[0]["ipv4"][0]["address"], "192.0.2.1/24")
+
+
+class TestBuildCommands(VyOSModuleTestCase):
+ def test_merged_idempotent_against_own_fixture(self):
+ have = self.gather()
+ self.assertEqual(build_commands(have, self.fixture, "merged"), [])
+
+ def test_replaced_idempotent_against_own_fixture(self):
+ have = self.gather()
+ self.assertEqual(build_commands(have, self.fixture, "replaced"), [])
+
+ def test_deleted_all_never_whole_interface_subtree(self):
+ raw_have = {"ethernet": {"eth0": {"address": ["192.0.2.1/24"]}}}
+ cmds = build_commands([], raw_have, "deleted")
+ self.assertNotIn(("delete", _BASE + ["ethernet", "eth0"]), cmds)
+ self.assertIn(("delete", _BASE + ["ethernet", "eth0", "address", "192.0.2.1/24"]), cmds)
+
+ def test_deleted_named_never_whole_interface_subtree(self):
+ raw_have = {"ethernet": {"eth0": {"address": ["192.0.2.1/24"]}}}
+ cmds = build_commands([{"name": "eth0"}], raw_have, "deleted")
+ self.assertNotIn(("delete", _BASE + ["ethernet", "eth0"]), cmds)
+
+ def test_overridden_omitted_interface_never_whole_subtree(self):
+ raw_have = {"ethernet": {"eth0": {"address": ["192.0.2.1/24"]}}}
+ cmds = build_commands([], raw_have, "overridden")
+ self.assertNotIn(("delete", _BASE + ["ethernet", "eth0"]), cmds)
+
+ def test_merged_adds_without_removing(self):
+ raw_have = {"ethernet": {"eth0": {"address": ["192.0.2.1/24"]}}}
+ config = [{"name": "eth0", "ipv4": [{"address": "192.0.2.2/24"}]}]
+ cmds = build_commands(config, raw_have, "merged")
+ self.assertIn(("set", _BASE + ["ethernet", "eth0", "address", "192.0.2.2/24"]), cmds)
+ self.assertFalse(any(c[0] == "delete" for c in cmds))
+
+ def test_replaced_removes_unlisted_address(self):
+ raw_have = {"ethernet": {"eth0": {"address": ["192.0.2.1/24", "192.0.2.2/24"]}}}
+ config = [{"name": "eth0", "ipv4": [{"address": "192.0.2.1/24"}]}]
+ cmds = build_commands(config, raw_have, "replaced")
+ self.assertIn(("delete", _BASE + ["ethernet", "eth0", "address", "192.0.2.2/24"]), cmds)
+
+ def test_merged_new_vif_address(self):
config = [
- {
- "name": "eth0",
- "vifs": [{"vlan_id": 100, "ipv4": [{"address": "192.0.2.100/24"}]}],
- },
+ {"name": "eth0", "vifs": [{"vlan_id": 200, "ipv4": [{"address": "192.0.2.200/24"}]}]},
]
- cmds = build_commands(config, [], "merged")
+ cmds = build_commands(config, {}, "merged")
self.assertIn(
- ("set", ["interfaces", "ethernet", "eth0", "vif", "100", "address", "192.0.2.100/24"]),
+ ("set", _BASE + ["ethernet", "eth0", "vif", "200", "address", "192.0.2.200/24"]),
cmds,
)
diff --git a/tests/unit/modules/test_vyos_ospf_interfaces.py b/tests/unit/modules/test_vyos_ospf_interfaces.py
index 4d6be0d..ee58f23 100644
--- a/tests/unit/modules/test_vyos_ospf_interfaces.py
+++ b/tests/unit/modules/test_vyos_ospf_interfaces.py
@@ -4,30 +4,34 @@ from __future__ import absolute_import, division, print_function
__metaclass__ = type
-import json
-import os
import unittest
from unittest.mock import MagicMock
from ansible_collections.vyos.rest.plugins.modules.vyos_ospf_interfaces import (
- _parse_ipv4_iface,
- _parse_ipv6_iface,
+ _AF_OPTIONS,
+ ARGUMENT_SPEC,
+ _af4_entry_from_device,
+ _af4_entry_to_device,
+ _af6_entry_from_device,
+ _af6_entry_to_device,
+ _auth_from_device,
+ _auth_to_device,
+ _derive_key_field,
+ _device_to_argspec,
+ _kebab_fields,
build_commands,
+ cast_by_spec,
get_running_config,
)
+from .base import load_fixture
+
_BASE4 = ["protocols", "ospf", "interface"]
_BASE6 = ["protocols", "ospfv3", "interface"]
-def load_fixture(filename):
- fixtures_dir = os.path.join(os.path.dirname(__file__), "..", "fixtures")
- with open(os.path.join(fixtures_dir, filename)) as f:
- return json.load(f)
-
-
class VyOSModuleTestCase(unittest.TestCase):
def setUp(self):
self.mock_vyos = MagicMock()
@@ -37,111 +41,217 @@ class VyOSModuleTestCase(unittest.TestCase):
side_effect=lambda path: (self.ipv4_fixture if path == _BASE4 else self.ipv6_fixture),
)
+ def gather(self):
+ raw4, raw6 = get_running_config(self.mock_vyos)
+ have = _device_to_argspec(raw4, raw6)
+ for iface in have:
+ for af in iface.get("address_family") or []:
+ cast_by_spec(af, _AF_OPTIONS)
+ return have
+
+
+class TestGetRunningConfig(VyOSModuleTestCase):
+ def test_returns_both_afi_trees_separately(self):
+ raw4, raw6 = get_running_config(self.mock_vyos)
+ self.assertIn("eth1", raw4)
+ self.assertIn("eth1", raw6)
+
+ def test_empty_config(self):
+ self.mock_vyos.get_config = MagicMock(return_value=None)
+ raw4, raw6 = get_running_config(self.mock_vyos)
+ self.assertEqual(raw4, {})
+ self.assertEqual(raw6, {})
+
+
+class TestDeriveKeyField(unittest.TestCase):
+ def test_derives_name(self):
+ opts = ARGUMENT_SPEC["config"]["options"]
+ self.assertEqual(_derive_key_field(opts), "name")
+
+ def test_derives_afi(self):
+ af_opts = ARGUMENT_SPEC["config"]["options"]["address_family"]["options"]
+ self.assertEqual(_derive_key_field(af_opts), "afi")
+
+
+class TestKebabFields(unittest.TestCase):
+ """Regression coverage for the confirmed bug from vyos_ospfv2's
+ build, applied here from the start: dict_op requires have's keys
+ to already be genuine device kebab-case, but autoclean deliberately
+ leaves keys as given."""
+
+ def test_converts_multiword_keys(self):
+ result = _kebab_fields({"dead_interval": 40, "mtu_ignore": True})
+ self.assertEqual(result, {"dead-interval": 40, "mtu-ignore": {}})
+
+
+class TestAuthentication(unittest.TestCase):
+ """IPv4-only. Confirmed against vyos-1x: md5 is a fixed "key-id"
+ node containing a single tagNode entry, not a list."""
+
+ def test_plaintext_to_device(self):
+ result = _auth_to_device({"plaintext_password": "pw"})
+ self.assertEqual(result, {"plaintext-password": "pw"})
+
+ def test_md5_to_device(self):
+ result = _auth_to_device({"md5_key": {"key_id": 10, "key": "secret"}})
+ self.assertEqual(result, {"md5": {"key-id": {"10": {"md5-key": "secret"}}}})
-class TestVyOSOspfInterfacesParse(VyOSModuleTestCase):
-
- def test_parse_ipv4_scalars(self):
- raw = {"cost": "100", "transmit-delay": "50", "priority": "26"}
- result = _parse_ipv4_iface(raw)
- self.assertEqual(result["afi"], "ipv4")
- self.assertEqual(result["cost"], 100)
- self.assertEqual(result["transmit_delay"], 50)
- self.assertEqual(result["priority"], 26)
-
- def test_parse_ipv4_md5_auth(self):
- raw = {"authentication": {"md5": {"key-id": {"10": {"md5-key": "secret"}}}}}
- result = _parse_ipv4_iface(raw)
- self.assertEqual(result["authentication"]["md5_key"]["key_id"], 10)
- self.assertEqual(result["authentication"]["md5_key"]["key"], "secret")
-
- def test_parse_ipv6_scalars(self):
- raw = {"dead-interval": "39", "passive": {}}
- result = _parse_ipv6_iface(raw)
- self.assertEqual(result["afi"], "ipv6")
- self.assertEqual(result["dead_interval"], 39)
- self.assertTrue(result["passive"])
-
- def test_get_running_config(self):
- result = get_running_config(self.mock_vyos)
- names = [e["name"] for e in result]
+ def test_md5_from_device(self):
+ entry = _auth_from_device({"md5": {"key-id": {"10": {"md5-key": "secret"}}}})
+ self.assertEqual(entry["md5_key"], {"key_id": 10, "key": "secret"})
+
+ def test_empty(self):
+ self.assertEqual(_auth_to_device({}), {})
+ self.assertIsNone(_auth_from_device({}))
+
+
+class TestAf6InstanceRename(unittest.TestCase):
+ """Confirmed genuine rename: the argspec's "instance" (matching
+ the CLI collection's naming) maps to the device's actual leaf
+ name "instance-id"."""
+
+ def test_to_device(self):
+ result = _af6_entry_to_device({"instance": "5"})
+ self.assertEqual(result, {"instance-id": "5"})
+
+ def test_from_device(self):
+ entry = _af6_entry_from_device({"instance-id": "5"})
+ self.assertEqual(entry["instance"], "5")
+
+
+class TestAf4EntryToDeviceFromDevice(unittest.TestCase):
+ def test_generic_fields(self):
+ result = _af4_entry_to_device({"cost": 100, "priority": 26})
+ self.assertEqual(result, {"cost": 100, "priority": 26})
+
+ def test_mtu_ignore_presence(self):
+ result = _af4_entry_to_device({"mtu_ignore": True})
+ self.assertEqual(result, {"mtu-ignore": {}})
+
+ def test_from_device_with_authentication(self):
+ entry = _af4_entry_from_device(
+ {"cost": "100", "authentication": {"plaintext-password": "pw"}},
+ )
+ self.assertEqual(entry["authentication"]["plaintext_password"], "pw")
+
+
+class TestDeviceToArgspecFixture(VyOSModuleTestCase):
+ def test_both_interfaces_present(self):
+ have = self.gather()
+ names = [i["name"] for i in have]
self.assertIn("eth1", names)
self.assertIn("eth2", names)
- eth1 = next(e for e in result if e["name"] == "eth1")
- afis = {af["afi"] for af in eth1["address_family"]}
- self.assertIn("ipv4", afis)
- self.assertIn("ipv6", afis)
- def test_get_running_config_empty(self):
- self.mock_vyos.get_config = MagicMock(return_value={})
- result = get_running_config(self.mock_vyos)
- self.assertEqual(result, [])
+ def test_eth1_has_both_afis(self):
+ have = self.gather()
+ eth1 = next(i for i in have if i["name"] == "eth1")
+ afis = {af["afi"] for af in eth1["address_family"]}
+ self.assertEqual(afis, {"ipv4", "ipv6"})
+ def test_eth2_ipv4_only_minimal(self):
+ """eth2's ipv6 fixture entry is empty ({}) -- confirms an
+ interface with config on only one AFI doesn't spuriously
+ appear on the other."""
+ have = self.gather()
+ eth2 = next(i for i in have if i["name"] == "eth2")
+ afis = {af["afi"] for af in eth2["address_family"]}
+ self.assertEqual(afis, {"ipv4"})
-class TestVyOSOspfInterfacesBuildCommands(unittest.TestCase):
+ def test_ipv4_fields_parsed(self):
+ have = self.gather()
+ eth1 = next(i for i in have if i["name"] == "eth1")
+ af4 = next(af for af in eth1["address_family"] if af["afi"] == "ipv4")
+ self.assertEqual(af4["cost"], 100)
+ self.assertTrue(af4["mtu_ignore"])
+ self.assertEqual(af4["authentication"]["md5_key"]["key_id"], 10)
- def test_deleted_all(self):
- have = [
- {
- "name": "eth1",
- "address_family": [{"afi": "ipv4", "cost": 100}, {"afi": "ipv6", "passive": True}],
- },
- ]
- cmds = build_commands([], have, "deleted")
- self.assertIn(("delete", _BASE4 + ["eth1"]), cmds)
- self.assertIn(("delete", _BASE6 + ["eth1"]), cmds)
+ def test_ipv6_fields_parsed(self):
+ have = self.gather()
+ eth1 = next(i for i in have if i["name"] == "eth1")
+ af6 = next(af for af in eth1["address_family"] if af["afi"] == "ipv6")
+ self.assertTrue(af6["passive"])
+ self.assertEqual(af6["instance"], "5")
+ self.assertEqual(af6["ifmtu"], 1500)
- def test_deleted_specific(self):
- have = [
- {"name": "eth1", "address_family": [{"afi": "ipv4", "cost": 100}]},
- {"name": "eth2", "address_family": [{"afi": "ipv4", "cost": 200}]},
- ]
- cmds = build_commands([{"name": "eth1"}], have, "deleted")
- self.assertIn(("delete", _BASE4 + ["eth1"]), cmds)
- paths = [c[1] for c in cmds]
- self.assertNotIn(_BASE4 + ["eth2"], paths)
+ def test_empty(self):
+ self.assertEqual(_device_to_argspec({}, {}), [])
- def test_merged_adds_cost(self):
- config = [{"name": "eth1", "address_family": [{"afi": "ipv4", "cost": 100}]}]
- cmds = build_commands(config, [], "merged")
- self.assertIn(("set", _BASE4 + ["eth1", "cost", "100"]), cmds)
- def test_merged_idempotent(self):
- have = [{"name": "eth1", "address_family": [{"afi": "ipv4", "cost": 100}]}]
- config = [{"name": "eth1", "address_family": [{"afi": "ipv4", "cost": 100}]}]
- cmds = build_commands(config, have, "merged")
- self.assertEqual(cmds, [])
+class TestBuildCommands(VyOSModuleTestCase):
+ def test_merged_idempotent_against_own_fixture(self):
+ have = self.gather()
+ raw4, raw6 = get_running_config(self.mock_vyos)
+ self.assertEqual(build_commands(have, (raw4, raw6), "merged"), [])
- def test_merged_ipv6_passive(self):
- config = [{"name": "eth1", "address_family": [{"afi": "ipv6", "passive": True}]}]
- cmds = build_commands(config, [], "merged")
- self.assertIn(("set", _BASE6 + ["eth1", "passive"]), cmds)
+ def test_replaced_idempotent_against_own_fixture(self):
+ have = self.gather()
+ raw4, raw6 = get_running_config(self.mock_vyos)
+ self.assertEqual(build_commands(have, (raw4, raw6), "replaced"), [])
- def test_overridden_removes_extra_interface(self):
- have = [
- {"name": "eth1", "address_family": [{"afi": "ipv4", "cost": 100}]},
- {"name": "eth2", "address_family": [{"afi": "ipv4", "cost": 200}]},
- ]
+ def test_clear_omitted_attribute_on_replaced(self):
+ """Primary confirmed bug: the original's clear-on-omit logic
+ only fired for op="replace", which was never actually passed
+ anywhere in build_commands -- genuinely dead code, since
+ "replaced" already deleted and recreated the whole AFI block
+ before calling it with an empty have."""
+ raw4 = {"eth1": {"cost": "100", "priority": "26"}}
config = [{"name": "eth1", "address_family": [{"afi": "ipv4", "cost": 100}]}]
- cmds = build_commands(config, have, "overridden")
- self.assertIn(("delete", _BASE4 + ["eth2"]), cmds)
+ cmds = build_commands(config, (raw4, {}), "replaced")
+ self.assertIn(("delete", _BASE4 + ["eth1", "priority"]), cmds)
- def test_replaced_idempotent(self):
- have = [{"name": "eth1", "address_family": [{"afi": "ipv4", "cost": 200}]}]
- config = [{"name": "eth1", "address_family": [{"afi": "ipv4", "cost": 200}]}]
- cmds = build_commands(config, have, "replaced")
+ def test_replaced_does_not_touch_unrelated_interfaces(self):
+ """Confirmed fix for the original's disruptive "delete
+ everything and recreate" replaced heuristic."""
+ raw4 = {"eth1": {"cost": "100"}, "eth2": {"cost": "200"}}
+ config = [{"name": "eth1", "address_family": [{"afi": "ipv4", "cost": 100}]}]
+ cmds = build_commands(config, (raw4, {}), "replaced")
self.assertEqual(cmds, [])
- def test_replaced_rebuilds_on_change(self):
- have = [
+ def test_ipv4_and_ipv6_target_separate_device_trees(self):
+ config = [
{
"name": "eth1",
- "address_family": [{"afi": "ipv4", "cost": 100, "transmit_delay": 50}],
+ "address_family": [
+ {"afi": "ipv4", "cost": 100},
+ {"afi": "ipv6", "passive": True},
+ ],
},
]
- config = [{"name": "eth1", "address_family": [{"afi": "ipv4", "cost": 200}]}]
- cmds = build_commands(config, have, "replaced")
+ cmds = build_commands(config, ({}, {}), "merged")
+ self.assertIn(("set", _BASE4 + ["eth1", "cost", "100"]), cmds)
+ self.assertIn(("set", _BASE6 + ["eth1", "passive"]), cmds)
+
+ def test_overridden_removes_omitted_interface(self):
+ raw4 = {"eth1": {"cost": "100"}, "eth3": {"cost": "50"}}
+ config = [{"name": "eth1", "address_family": [{"afi": "ipv4", "cost": 100}]}]
+ cmds = build_commands(config, (raw4, {}), "overridden")
+ self.assertIn(("delete", _BASE4 + ["eth3"]), cmds)
+
+ def test_deleted_all_covers_both_trees(self):
+ cmds = build_commands([], ({"eth1": {}}, {"eth1": {}}), "deleted")
self.assertIn(("delete", _BASE4 + ["eth1"]), cmds)
- self.assertIn(("set", _BASE4 + ["eth1", "cost", "200"]), cmds)
+ self.assertIn(("delete", _BASE6 + ["eth1"]), cmds)
+ self.assertEqual(len(cmds), 2)
+
+ def test_deleted_named_no_afi_removes_both(self):
+ cmds = build_commands([{"name": "eth1"}], ({"eth1": {}}, {"eth1": {}}), "deleted")
+ self.assertIn(("delete", _BASE4 + ["eth1"]), cmds)
+ self.assertIn(("delete", _BASE6 + ["eth1"]), cmds)
+ self.assertEqual(len(cmds), 2)
+
+ def test_deleted_named_specific_afi_only(self):
+ config = [{"name": "eth1", "address_family": [{"afi": "ipv4"}]}]
+ cmds = build_commands(config, ({"eth1": {}}, {"eth1": {}}), "deleted")
+ self.assertEqual(cmds, [("delete", _BASE4 + ["eth1"])])
+
+ def test_deleted_named_nonexistent_is_noop(self):
+ cmds = build_commands([{"name": "eth99"}], ({"eth1": {}}, {}), "deleted")
+ self.assertEqual(cmds, [])
+
+ def test_merged_adds_cost(self):
+ config = [{"name": "eth1", "address_family": [{"afi": "ipv4", "cost": 100}]}]
+ cmds = build_commands(config, ({}, {}), "merged")
+ self.assertIn(("set", _BASE4 + ["eth1", "cost", "100"]), cmds)
if __name__ == "__main__":
diff --git a/tests/unit/modules/test_vyos_ospfv2.py b/tests/unit/modules/test_vyos_ospfv2.py
index c05dd77..01738a3 100644
--- a/tests/unit/modules/test_vyos_ospfv2.py
+++ b/tests/unit/modules/test_vyos_ospfv2.py
@@ -4,30 +4,36 @@ from __future__ import absolute_import, division, print_function
__metaclass__ = type
-import json
-import os
import unittest
from unittest.mock import MagicMock
from ansible_collections.vyos.rest.plugins.modules.vyos_ospfv2 import (
- _parse_areas,
- _parse_default_information,
- _parse_neighbor,
- _parse_parameters,
- _parse_redistribute,
+ _CONFIG_OPTIONS,
+ ARGUMENT_SPEC,
+ _area_type_from_device,
+ _area_type_to_device,
+ _derive_key_field,
+ _device_to_argspec,
+ _distance_from_device,
+ _distance_to_device,
+ _kebab_fields,
+ _passive_from_device,
+ _passive_to_device,
+ _timers_from_device,
+ _timers_to_device_refresh,
+ _timers_to_device_throttle,
+ _vlink_auth_from_device,
+ _vlink_auth_to_device,
build_commands,
+ cast_by_spec,
get_running_config,
)
-
-_BASE = ["protocols", "ospf"]
+from .base import load_fixture
-def load_fixture(filename):
- fixtures_dir = os.path.join(os.path.dirname(__file__), "..", "fixtures")
- with open(os.path.join(fixtures_dir, filename)) as f:
- return json.load(f)
+_BASE = ["protocols", "ospf"]
class VyOSModuleTestCase(unittest.TestCase):
@@ -36,144 +42,278 @@ class VyOSModuleTestCase(unittest.TestCase):
self.fixture = load_fixture("ospfv2_running.json")
self.mock_vyos.get_config = MagicMock(return_value=self.fixture)
+ def gather(self):
+ have = _device_to_argspec(self.fixture)
+ cast_by_spec(have, _CONFIG_OPTIONS)
+ return have
-class TestVyOSOspfv2Parse(VyOSModuleTestCase):
-
- def test_parse_parameters(self):
- result = _parse_parameters(self.fixture["parameters"])
- self.assertEqual(result["router_id"], "192.0.1.1")
- self.assertEqual(result["abr_type"], "cisco")
- self.assertTrue(result["opaque_lsa"])
- self.assertTrue(result["rfc1583_compatibility"])
-
- def test_parse_redistribute(self):
- result = _parse_redistribute(self.fixture["redistribute"])
- route_types = [r["route_type"] for r in result]
- self.assertIn("bgp", route_types)
- self.assertIn("connected", route_types)
- bgp = next(r for r in result if r["route_type"] == "bgp")
- self.assertEqual(bgp["metric"], 10)
- self.assertEqual(bgp["metric_type"], 2)
-
- def test_parse_neighbor(self):
- result = _parse_neighbor(self.fixture["neighbor"])
- self.assertEqual(len(result), 1)
- nb = result[0]
- self.assertEqual(nb["neighbor_id"], "192.0.11.12")
- self.assertEqual(nb["priority"], 2)
- self.assertEqual(nb["poll_interval"], 10)
-
- def test_parse_default_information(self):
- result = _parse_default_information(self.fixture["default-information"])
- orig = result["originate"]
- self.assertTrue(orig["always"])
- self.assertEqual(orig["metric"], 10)
- self.assertEqual(orig["metric_type"], 2)
- self.assertEqual(orig["route_map"], "ingress")
-
- def test_parse_areas(self):
- result = _parse_areas(self.fixture["area"])
- self.assertEqual(len(result), 3)
- area2 = next(a for a in result if a["area_id"] == "2")
- self.assertTrue(area2["area_type"]["normal"])
- self.assertEqual(area2["network"][0]["address"], "192.0.2.0/24")
-
- area3 = next(a for a in result if a["area_id"] == "3")
- self.assertTrue(area3["area_type"]["nssa"]["set"])
-
- area4 = next(a for a in result if a["area_id"] == "4")
- self.assertEqual(area4["area_type"]["stub"]["default_cost"], 20)
- self.assertEqual(len(area4["range"]), 2)
- r = next(r for r in area4["range"] if r["address"] == "192.0.3.0/24")
- self.assertEqual(r["cost"], 10)
-
- def test_get_running_config(self):
- result = get_running_config(self.mock_vyos)
- self.assertEqual(result["parameters"]["router_id"], "192.0.1.1")
- self.assertIn("eth1", result["passive_interface"])
- self.assertIn("eth2", result["passive_interface"])
- self.assertEqual(result["auto_cost"]["reference_bandwidth"], 2)
- self.assertEqual(result["log_adjacency_changes"], "detail")
-
- def test_get_running_config_empty(self):
- self.mock_vyos.get_config = MagicMock(return_value={})
+
+class TestGetRunningConfig(VyOSModuleTestCase):
+ def test_returns_config_directly(self):
result = get_running_config(self.mock_vyos)
+ self.assertIn("area", result)
+
+ def test_empty_config(self):
+ self.mock_vyos.get_config = MagicMock(return_value=None)
+ self.assertEqual(get_running_config(self.mock_vyos), {})
+
+
+class TestDeriveKeyField(unittest.TestCase):
+ def test_derives_area_id(self):
+ area_opts = ARGUMENT_SPEC["config"]["options"]["areas"]["options"]
+ self.assertEqual(_derive_key_field(area_opts), "area_id")
+
+ def test_derives_neighbor_id(self):
+ nb_opts = ARGUMENT_SPEC["config"]["options"]["neighbor"]["options"]
+ self.assertEqual(_derive_key_field(nb_opts), "neighbor_id")
+
+ def test_raises_if_none_required(self):
+ with self.assertRaises(ValueError):
+ _derive_key_field({"a": {"type": "str"}})
+
+
+class TestKebabFields(unittest.TestCase):
+ """Regression tests for the primary confirmed bug found during
+ development: dict_op requires have's keys to already be genuine
+ device kebab-case (it only normalizes underscores for its own
+ lookup index, not for the output path). autoclean deliberately
+ leaves keys as given, so any multi-word field reconstructed via
+ this module's own entry-transforms (rather than coming straight
+ from the device) needs explicit conversion, or a generated delete
+ command uses the wrong (snake_case) path segment."""
+
+ def test_converts_multiword_keys(self):
+ result = _kebab_fields({"default_cost": 20, "no_summary": True})
+ self.assertEqual(result, {"default-cost": 20, "no-summary": {}})
+
+ def test_drops_none_and_false(self):
+ result = _kebab_fields({"default_cost": None, "no_summary": False})
self.assertEqual(result, {})
+ def test_single_word_keys_unaffected(self):
+ result = _kebab_fields({"cost": 10})
+ self.assertEqual(result, {"cost": 10})
-class TestVyOSOspfv2BuildCommands(unittest.TestCase):
- def test_deleted_with_have(self):
- have = {"parameters": {"router_id": "192.0.1.1"}}
- cmds = build_commands({}, have, "deleted")
- self.assertEqual(cmds, [("delete", _BASE)])
+class TestAreaType(unittest.TestCase):
+ def test_nssa_set_flag_is_node_presence_not_a_device_leaf(self):
+ """Confirmed genuine structural exception: the argspec's
+ nssa.set/stub.set boolean doesn't exist as a device leaf --
+ the node's own presence IS the set flag."""
+ result = _area_type_to_device({"nssa": {"set": True, "default_cost": 5}})
+ self.assertEqual(result, {"nssa": {"default-cost": 5}})
+ self.assertNotIn("set", result["nssa"])
- def test_deleted_without_have(self):
- cmds = build_commands({}, {}, "deleted")
- self.assertEqual(cmds, [])
+ def test_stub_no_summary(self):
+ """Regression test for the confirmed original bug: stub's
+ no_summary was declared in ARGUMENT_SPEC but never checked
+ anywhere in the original hand-rolled command-building logic."""
+ result = _area_type_to_device({"stub": {"no_summary": True}})
+ self.assertEqual(result, {"stub": {"no-summary": {}}})
- def test_merged_parameters(self):
- config = {"parameters": {"router_id": "192.0.1.1"}}
- cmds = build_commands(config, {}, "merged")
- self.assertIn(("set", _BASE + ["parameters", "router-id", "192.0.1.1"]), cmds)
+ def test_from_device_restores_set_flag(self):
+ entry = _area_type_from_device({"nssa": {"default-cost": "5"}})
+ self.assertTrue(entry["nssa"]["set"])
- def test_merged_redistribute(self):
- config = {"redistribute": [{"route_type": "bgp", "metric": 10}]}
- cmds = build_commands(config, {}, "merged")
- self.assertIn(("set", _BASE + ["redistribute", "bgp"]), cmds)
- self.assertIn(("set", _BASE + ["redistribute", "bgp", "metric", "10"]), cmds)
+ def test_normal_presence(self):
+ result = _area_type_to_device({"normal": True})
+ self.assertEqual(result, {"normal": {}})
- def test_merged_passive_interface(self):
- config = {"passive_interface": ["eth1"]}
- cmds = build_commands(config, {}, "merged")
- self.assertIn(("set", _BASE + ["interface", "eth1", "passive"]), cmds)
+ def test_empty(self):
+ self.assertEqual(_area_type_to_device({}), {})
+ self.assertIsNone(_area_type_from_device({}))
- def test_merged_area_normal(self):
- config = {"areas": [{"area_id": "2", "area_type": {"normal": True}}]}
- cmds = build_commands(config, {}, "merged")
- self.assertIn(("set", _BASE + ["area", "2", "area-type", "normal"]), cmds)
- def test_merged_area_stub_with_cost(self):
- config = {"areas": [{"area_id": "4", "area_type": {"stub": {"default_cost": 20}}}]}
- cmds = build_commands(config, {}, "merged")
- self.assertIn(
- ("set", _BASE + ["area", "4", "area-type", "stub", "default-cost", "20"]),
- cmds,
- )
+class TestDistance(unittest.TestCase):
+ def test_global_and_ospf(self):
+ result = _distance_to_device({"global": 110, "ospf": {"inter_area": 120}})
+ self.assertEqual(result, {"global": 110, "ospf": {"inter-area": 120}})
- def test_merged_idempotent(self):
- have = {"parameters": {"router_id": "192.0.1.1"}}
- config = {"parameters": {"router_id": "192.0.1.1"}}
- cmds = build_commands(config, have, "merged")
- self.assertEqual(cmds, [])
-
- def test_replaced_idempotent(self):
- have = {"parameters": {"router_id": "192.0.1.1"}}
- config = {"parameters": {"router_id": "192.0.1.1"}}
- cmds = build_commands(config, have, "replaced")
- self.assertEqual(cmds, [])
-
- def test_replaced_rebuilds_on_change(self):
- have = {"parameters": {"router_id": "192.0.1.1"}}
- config = {"parameters": {"router_id": "192.0.1.2"}}
- cmds = build_commands(config, have, "replaced")
- self.assertEqual(cmds[0], ("delete", _BASE))
- self.assertIn(
- ("set", _BASE + ["parameters", "router-id", "192.0.1.2"]),
- cmds,
- )
+ def test_from_device(self):
+ entry = _distance_from_device({"global": "110", "ospf": {"external": "150"}})
+ self.assertEqual(entry["global"], 110)
+ self.assertEqual(entry["ospf"]["external"], "150") # cast_by_spec's job downstream
+
+
+class TestTimers(unittest.TestCase):
+ """Confirmed genuine structural exception: the argspec groups
+ "refresh" and "throttle" both under one "timers" parent, but the
+ device has them as two separate top-level nodes."""
+
+ def test_refresh_maps_to_separate_top_level_node(self):
+ result = _timers_to_device_refresh({"refresh": {"timers": 300}})
+ self.assertEqual(result, {"timers": 300})
+
+ def test_throttle_maps_to_device_timers_node(self):
+ result = _timers_to_device_throttle({"throttle": {"spf": {"delay": 200}}})
+ self.assertEqual(result, {"throttle": {"spf": {"delay": 200}}})
+
+ def test_from_device_recombines_both(self):
+ entry = _timers_from_device({"timers": "300"}, {"throttle": {"spf": {"delay": "200"}}})
+ self.assertEqual(entry["refresh"]["timers"], 300)
+ self.assertEqual(entry["throttle"]["spf"]["delay"], "200")
- def test_merged_neighbor(self):
- config = {"neighbor": [{"neighbor_id": "192.0.11.12", "priority": 2}]}
+
+class TestPassiveInterface(unittest.TestCase):
+ """Confirmed genuine structural exception: passive_interface and
+ passive_interface_exclude both map onto the same per-interface
+ "interface <name> passive" device subtree -- presence alone means
+ enabled, "passive.disable" means explicitly excluded."""
+
+ def test_to_device_both(self):
+ result = _passive_to_device(["eth1"], ["eth2"])
+ self.assertEqual(result, {"eth1": {"passive": {}}, "eth2": {"passive": {"disable": {}}}})
+
+ def test_from_device_both(self):
+ passive, excluded = _passive_from_device(
+ {"eth1": {"passive": {}}, "eth2": {"passive": {"disable": {}}}},
+ )
+ self.assertEqual(passive, ["eth1"])
+ self.assertEqual(excluded, ["eth2"])
+
+ def test_from_device_ignores_non_passive_interfaces(self):
+ passive, excluded = _passive_from_device({"eth3": {"some-other-key": {}}})
+ self.assertEqual(passive, [])
+ self.assertEqual(excluded, [])
+
+
+class TestVirtualLinkAuth(unittest.TestCase):
+ def test_md5_to_device(self):
+ result = _vlink_auth_to_device({"md5": [{"key_id": 10, "md5_key": "secret"}]})
+ self.assertEqual(result, {"md5": {"10": {"md5-key": "secret"}}})
+
+ def test_plaintext_to_device(self):
+ result = _vlink_auth_to_device({"plaintext_password": "pw"})
+ self.assertEqual(result, {"plaintext-password": "pw"})
+
+ def test_md5_from_device(self):
+ entry = _vlink_auth_from_device({"md5": {"10": {"md5-key": "secret"}}})
+ self.assertEqual(entry["md5"], [{"key_id": 10, "md5_key": "secret"}])
+
+
+class TestDeviceToArgspecFixture(VyOSModuleTestCase):
+ def test_areas_parsed(self):
+ have = self.gather()
+ area_ids = [a["area_id"] for a in have["areas"]]
+ self.assertEqual(set(area_ids), {"2", "4", "5"})
+
+ def test_stub_area_no_summary_parsed(self):
+ have = self.gather()
+ area4 = next(a for a in have["areas"] if a["area_id"] == "4")
+ self.assertTrue(area4["area_type"]["stub"]["no_summary"])
+
+ def test_virtual_link_parsed(self):
+ have = self.gather()
+ area4 = next(a for a in have["areas"] if a["area_id"] == "4")
+ vlink = area4["virtual_link"][0]
+ self.assertEqual(vlink["address"], "10.0.0.1")
+ self.assertEqual(vlink["authentication"]["plaintext_password"], "secret")
+ self.assertEqual(vlink["dead_interval"], 40)
+
+ def test_passive_interface_and_exclude_parsed(self):
+ have = self.gather()
+ self.assertEqual(have["passive_interface"], ["eth1"])
+ self.assertEqual(have["passive_interface_exclude"], ["eth2"])
+
+ def test_timers_parsed(self):
+ have = self.gather()
+ self.assertEqual(have["timers"]["refresh"]["timers"], 300)
+ self.assertEqual(have["timers"]["throttle"]["spf"]["delay"], 200)
+
+ def test_max_metric_parsed(self):
+ have = self.gather()
+ self.assertTrue(have["max_metric"]["router_lsa"]["administrative"])
+ self.assertEqual(have["max_metric"]["router_lsa"]["on_shutdown"], 10)
+
+ def test_mpls_te_parsed(self):
+ have = self.gather()
+ self.assertTrue(have["mpls_te"]["enabled"])
+ self.assertEqual(have["mpls_te"]["router_address"], "192.0.11.11")
+
+ def test_empty_config(self):
+ self.assertEqual(_device_to_argspec({}), {})
+ self.assertEqual(_device_to_argspec(None), {})
+
+
+class TestBuildCommands(VyOSModuleTestCase):
+ def test_merged_idempotent_against_own_fixture(self):
+ have = self.gather()
+ self.assertEqual(build_commands(have, self.fixture, "merged"), [])
+
+ def test_replaced_idempotent_against_own_fixture(self):
+ have = self.gather()
+ self.assertEqual(build_commands(have, self.fixture, "replaced"), [])
+
+ def test_clear_omitted_attribute_on_replaced(self):
+ """Primary confirmed bug from the original hand-rolled
+ implementation: clearing an omitted attribute never generated
+ a delete command. Also the exact scenario that caught the
+ kebab-key regression during development."""
+ raw_have = {"area": {"4": {"area-type": {"stub": {"default-cost": "20"}}}}}
+ config = {"areas": [{"area_id": "4", "area_type": {"stub": {"set": True}}}]}
+ cmds = build_commands(config, raw_have, "replaced")
+ expected = ("delete", _BASE + ["area", "4", "area-type", "stub", "default-cost"])
+ self.assertIn(expected, cmds)
+
+ def test_stub_no_summary_now_works(self):
+ config = {"areas": [{"area_id": "4", "area_type": {"stub": {"no_summary": True}}}]}
+ cmds = build_commands(config, {}, "merged")
+ expected = ("set", _BASE + ["area", "4", "area-type", "stub", "no-summary"])
+ self.assertIn(expected, cmds)
+
+ def test_replaced_only_touches_what_changed(self):
+ """Confirmed fix for the original's disruptive "delete
+ everything and recreate" replaced heuristic -- a targeted
+ dict_op purge only touches the sections that actually
+ differ."""
+ raw_have = {
+ "area": {"2": {"area-type": {"normal": {}}}},
+ "parameters": {"router-id": "1.1.1.1"},
+ }
+ config = {
+ "areas": [{"area_id": "2", "area_type": {"normal": True}}],
+ "parameters": {"router_id": "2.2.2.2"},
+ }
+ cmds = build_commands(config, raw_have, "replaced")
+ self.assertFalse(any("area" in str(c) for c in cmds))
+ expected = ("set", _BASE + ["parameters", "router-id", "2.2.2.2"])
+ self.assertIn(expected, cmds)
+
+ def test_passive_interface_and_exclude_together(self):
+ config = {"passive_interface": ["eth1"], "passive_interface_exclude": ["eth2"]}
cmds = build_commands(config, {}, "merged")
- self.assertIn(("set", _BASE + ["neighbor", "192.0.11.12"]), cmds)
- self.assertIn(("set", _BASE + ["neighbor", "192.0.11.12", "priority", "2"]), cmds)
+ self.assertIn(("set", _BASE + ["interface", "eth1", "passive"]), cmds)
+ self.assertIn(("set", _BASE + ["interface", "eth2", "passive", "disable"]), cmds)
+
+ def test_deleted_with_have(self):
+ cmds = build_commands({}, {"parameters": {"router-id": "1.1.1.1"}}, "deleted")
+ self.assertEqual(cmds, [("delete", _BASE)])
- def test_merged_default_information(self):
- config = {"default_information": {"originate": {"always": True, "metric": 10}}}
+ def test_deleted_no_have_is_noop(self):
+ self.assertEqual(build_commands({}, {}, "deleted"), [])
+
+ def test_merged_new_virtual_link(self):
+ config = {
+ "areas": [
+ {
+ "area_id": "4",
+ "virtual_link": [
+ {"address": "10.0.0.1", "authentication": {"plaintext_password": "pw"}},
+ ],
+ },
+ ],
+ }
cmds = build_commands(config, {}, "merged")
- self.assertIn(("set", _BASE + ["default-information", "originate", "always"]), cmds)
- self.assertIn(("set", _BASE + ["default-information", "originate", "metric", "10"]), cmds)
+ vlink_path = _BASE + [
+ "area",
+ "4",
+ "virtual-link",
+ "10.0.0.1",
+ "authentication",
+ "plaintext-password",
+ "pw",
+ ]
+ self.assertIn(("set", vlink_path), cmds)
if __name__ == "__main__":
diff --git a/tests/unit/modules/test_vyos_ospfv3.py b/tests/unit/modules/test_vyos_ospfv3.py
index a84041b..a19b5fa 100644
--- a/tests/unit/modules/test_vyos_ospfv3.py
+++ b/tests/unit/modules/test_vyos_ospfv3.py
@@ -4,136 +4,218 @@ from __future__ import absolute_import, division, print_function
__metaclass__ = type
-import json
-import os
import unittest
from unittest.mock import MagicMock
from ansible_collections.vyos.rest.plugins.modules.vyos_ospfv3 import (
+ _CONFIG_OPTIONS,
+ ARGUMENT_SPEC,
+ _area_entry_from_device,
+ _area_entry_to_device,
+ _derive_key_field,
+ _device_to_argspec,
+ _kebab_fields,
build_commands,
+ cast_by_spec,
get_running_config,
)
+from .base import load_fixture
-def load_fixture(filename):
- fixtures_dir = os.path.join(os.path.dirname(__file__), "..", "fixtures")
- with open(os.path.join(fixtures_dir, filename)) as f:
- return json.load(f)
+
+_BASE = ["protocols", "ospfv3"]
class VyOSModuleTestCase(unittest.TestCase):
def setUp(self):
self.mock_vyos = MagicMock()
- self.mock_vyos.get_config = MagicMock(return_value={})
-
- def set_running_config(self, data):
- self.mock_vyos.get_config.return_value = data
-
-
-class TestVyOSOspfv3Parse(VyOSModuleTestCase):
-
- def setUp(self):
- super().setUp()
self.fixture = load_fixture("ospfv3_running.json")
+ self.mock_vyos.get_config = MagicMock(return_value=self.fixture)
- def test_parses_parameters(self):
- self.set_running_config(self.fixture)
- result = get_running_config(self.mock_vyos)
- self.assertEqual(result["parameters"]["router_id"], "192.0.2.10")
+ def gather(self):
+ have = _device_to_argspec(self.fixture)
+ cast_by_spec(have, _CONFIG_OPTIONS)
+ return have
- def test_parses_redistribute(self):
- self.set_running_config(self.fixture)
+
+class TestGetRunningConfig(VyOSModuleTestCase):
+ def test_returns_config_directly(self):
result = get_running_config(self.mock_vyos)
- route_types = [r["route_type"] for r in result["redistribute"]]
- self.assertIn("bgp", route_types)
- self.assertIn("connected", route_types)
- connected = next(r for r in result["redistribute"] if r["route_type"] == "connected")
- self.assertEqual(connected["route_map"], "RM1")
-
- def test_parses_areas(self):
- self.set_running_config(self.fixture)
+ self.assertIn("area", result)
+
+ def test_empty_config(self):
+ self.mock_vyos.get_config = MagicMock(return_value=None)
+ self.assertEqual(get_running_config(self.mock_vyos), {})
+
+ def test_collapsed_response_normalized(self):
+ """Regression coverage for the confirmed failure mode from
+ vyos_ospf_interfaces's build: an unguarded collapsed response
+ (VyOS's single-value tag-node quirk) would otherwise be
+ iterated character-by-character downstream."""
+ self.mock_vyos.get_config = MagicMock(return_value="eth1")
result = get_running_config(self.mock_vyos)
- self.assertEqual(len(result["areas"]), 2)
- area2 = next(a for a in result["areas"] if a["area_id"] == "2")
- self.assertEqual(area2["export_list"], "export1")
- self.assertEqual(area2["import_list"], "import1")
- self.assertEqual(len(area2["range"]), 2)
- not_adv = next(r for r in area2["range"] if r["address"] == "2001:db20::/32")
- self.assertTrue(not_adv["not_advertise"])
+ self.assertEqual(result, {"eth1": {}})
- def test_empty_config_returns_empty(self):
- self.set_running_config({})
- result = get_running_config(self.mock_vyos)
- self.assertEqual(result, {})
+class TestDeriveKeyField(unittest.TestCase):
+ def test_derives_area_id(self):
+ area_opts = ARGUMENT_SPEC["config"]["options"]["areas"]["options"]
+ self.assertEqual(_derive_key_field(area_opts), "area_id")
-class TestVyOSOspfv3BuildCommands(unittest.TestCase):
+ def test_raises_if_none_required(self):
+ with self.assertRaises(ValueError):
+ _derive_key_field({"a": {"type": "str"}})
- def test_deleted_with_have(self):
- have = {"parameters": {"router_id": "192.0.2.10"}}
- cmds = build_commands({}, have, "deleted")
- self.assertEqual(cmds, [("delete", ["protocols", "ospfv3"])])
- def test_deleted_without_have(self):
- cmds = build_commands({}, {}, "deleted")
- self.assertEqual(cmds, [])
+class TestKebabFields(unittest.TestCase):
+ def test_converts_multiword_keys(self):
+ result = _kebab_fields({"router_id": "1.1.1.1"})
+ self.assertEqual(result, {"router-id": "1.1.1.1"})
- def test_merged_parameters(self):
- config = {"parameters": {"router_id": "192.0.2.10"}}
- cmds = build_commands(config, {}, "merged")
- self.assertIn(
- ("set", ["protocols", "ospfv3", "parameters", "router-id", "192.0.2.10"]),
- cmds,
+
+class TestAreaEntry(unittest.TestCase):
+ def test_export_import_list_to_device(self):
+ result = _area_entry_to_device({"export_list": "el1", "import_list": "il1"})
+ self.assertEqual(result, {"export-list": "el1", "import-list": "il1"})
+
+ def test_range_to_device(self):
+ result = _area_entry_to_device(
+ {"range": [{"address": "2001:db1::/32", "not_advertise": True}]},
)
+ self.assertEqual(result, {"range": {"2001:db1::/32": {"not-advertise": {}}}})
- def test_merged_redistribute(self):
- config = {"redistribute": [{"route_type": "bgp"}]}
- cmds = build_commands(config, {}, "merged")
- self.assertIn(
- ("set", ["protocols", "ospfv3", "redistribute", "bgp"]),
- cmds,
+ def test_from_device(self):
+ entry = _area_entry_from_device(
+ {"export-list": "el1", "range": {"2001:db1::/32": {"advertise": {}}}},
)
+ self.assertEqual(entry["export_list"], "el1")
+ self.assertEqual(entry["range"][0]["address"], "2001:db1::/32")
+ self.assertTrue(entry["range"][0]["advertise"])
+
+ def test_empty(self):
+ self.assertEqual(_area_entry_to_device({}), {})
+ self.assertEqual(_area_entry_from_device({}), {})
- def test_merged_idempotent(self):
- config = {"parameters": {"router_id": "192.0.2.10"}}
- have = {"parameters": {"router_id": "192.0.2.10"}}
- cmds = build_commands(config, have, "merged")
- self.assertEqual(cmds, [])
- def test_merged_area_range(self):
+class TestDeviceToArgspecFixture(VyOSModuleTestCase):
+ def test_areas_parsed(self):
+ have = self.gather()
+ area_ids = {a["area_id"] for a in have["areas"]}
+ self.assertEqual(area_ids, {"2", "3"})
+
+ def test_area_export_import_list_parsed(self):
+ have = self.gather()
+ area2 = next(a for a in have["areas"] if a["area_id"] == "2")
+ self.assertEqual(area2["export_list"], "export1")
+ self.assertEqual(area2["import_list"], "import1")
+
+ def test_range_advertise_not_advertise_parsed(self):
+ have = self.gather()
+ area2 = next(a for a in have["areas"] if a["area_id"] == "2")
+ r1 = next(r for r in area2["range"] if r["address"] == "2001:db10::/32")
+ r2 = next(r for r in area2["range"] if r["address"] == "2001:db20::/32")
+ self.assertNotIn("advertise", r1)
+ self.assertNotIn("not_advertise", r1)
+ self.assertTrue(r2["not_advertise"])
+
+ area3 = next(a for a in have["areas"] if a["area_id"] == "3")
+ self.assertTrue(area3["range"][0]["advertise"])
+
+ def test_parameters_parsed(self):
+ have = self.gather()
+ self.assertEqual(have["parameters"]["router_id"], "192.0.2.10")
+
+ def test_redistribute_parsed(self):
+ have = self.gather()
+ route_types = {r["route_type"] for r in have["redistribute"]}
+ self.assertEqual(route_types, {"bgp", "static"})
+ bgp = next(r for r in have["redistribute"] if r["route_type"] == "bgp")
+ self.assertEqual(bgp["route_map"], "redist-map")
+
+ def test_empty(self):
+ self.assertEqual(_device_to_argspec({}), {})
+ self.assertEqual(_device_to_argspec(None), {})
+
+
+class TestBuildCommands(VyOSModuleTestCase):
+ def test_merged_idempotent_against_own_fixture(self):
+ have = self.gather()
+ self.assertEqual(build_commands(have, self.fixture, "merged"), [])
+
+ def test_replaced_idempotent_against_own_fixture(self):
+ have = self.gather()
+ self.assertEqual(build_commands(have, self.fixture, "replaced"), [])
+
+ def test_changing_existing_range_flag_via_replaced(self):
+ """Primary confirmed bug from the original hand-rolled
+ implementation: range advertise/not_advertise were only ever
+ set on initial range creation (an "if addr not in have_ranges"
+ guard) -- changing an existing range's flag generated no
+ command under either state. dict_op's purge (via "replaced")
+ correctly handles this generically."""
+ raw_have = {"area": {"2": {"range": {"2001:db20::/32": {"not-advertise": {}}}}}}
config = {
- "areas": [{"area_id": "2", "range": [{"address": "2001:db10::/32"}]}],
+ "areas": [
+ {
+ "area_id": "2",
+ "range": [
+ {"address": "2001:db20::/32", "advertise": True},
+ ],
+ },
+ ],
}
- cmds = build_commands(config, {}, "merged")
+ cmds = build_commands(config, raw_have, "replaced")
self.assertIn(
- ("set", ["protocols", "ospfv3", "area", "2", "range", "2001:db10::/32"]),
+ ("delete", _BASE + ["area", "2", "range", "2001:db20::/32", "not-advertise"]),
cmds,
)
-
- def test_replaced_idempotent(self):
- config = {"parameters": {"router_id": "192.0.2.10"}}
- have = {"parameters": {"router_id": "192.0.2.10"}}
- cmds = build_commands(config, have, "replaced")
- self.assertEqual(cmds, [])
-
- def test_replaced_rebuilds_on_change(self):
- config = {"parameters": {"router_id": "192.0.2.11"}}
- have = {"parameters": {"router_id": "192.0.2.10"}}
- cmds = build_commands(config, have, "replaced")
- self.assertEqual(cmds[0], ("delete", ["protocols", "ospfv3"]))
self.assertIn(
- ("set", ["protocols", "ospfv3", "parameters", "router-id", "192.0.2.11"]),
+ ("set", _BASE + ["area", "2", "range", "2001:db20::/32", "advertise"]),
cmds,
)
- def test_merged_area_export_list(self):
- config = {"areas": [{"area_id": "2", "export_list": "export1"}]}
+ def test_merged_does_not_clear_conflicting_flag(self):
+ """merged never runs a purge pass, by design (established,
+ consistent semantic throughout this collection) -- confirms
+ it correctly does NOT clear not-advertise even when advertise
+ is set instead, unlike replaced above."""
+ raw_have = {"area": {"2": {"range": {"2001:db20::/32": {"not-advertise": {}}}}}}
+ config = {
+ "areas": [
+ {
+ "area_id": "2",
+ "range": [
+ {"address": "2001:db20::/32", "advertise": True},
+ ],
+ },
+ ],
+ }
+ cmds = build_commands(config, raw_have, "merged")
+ self.assertFalse(any(c[0] == "delete" and "not-advertise" in str(c) for c in cmds))
+
+ def test_replaced_is_whole_resource_not_scoped(self):
+ """Confirmed matching this module's own documented contract
+ ("replaced replaces the module-managed OSPFv3 configuration") and
+ vyos_ospfv2's own established precedent -- unlike
+ vyos_static_routes/vyos_route_maps, which scope replaced per
+ named item."""
+ raw_have = {"parameters": {"router-id": "1.1.1.1"}, "redistribute": {"bgp": {}}}
+ config = {"parameters": {"router_id": "1.1.1.1"}}
+ cmds = build_commands(config, raw_have, "replaced")
+ self.assertIn(("delete", _BASE + ["redistribute"]), cmds)
+
+ def test_deleted_with_have(self):
+ cmds = build_commands({}, {"parameters": {"router-id": "1.1.1.1"}}, "deleted")
+ self.assertEqual(cmds, [("delete", _BASE)])
+
+ def test_deleted_no_have_is_noop(self):
+ self.assertEqual(build_commands({}, {}, "deleted"), [])
+
+ def test_merged_new_area(self):
+ config = {"areas": [{"area_id": "5", "export_list": "el5"}]}
cmds = build_commands(config, {}, "merged")
- self.assertIn(
- ("set", ["protocols", "ospfv3", "area", "2", "export-list", "export1"]),
- cmds,
- )
+ self.assertIn(("set", _BASE + ["area", "5", "export-list", "el5"]), cmds)
if __name__ == "__main__":
diff --git a/tests/unit/test_module_utils_vyos.py b/tests/unit/test_module_utils_vyos.py
index 920e9a5..213a2fb 100644
--- a/tests/unit/test_module_utils_vyos.py
+++ b/tests/unit/test_module_utils_vyos.py
@@ -14,7 +14,12 @@ __metaclass__ = type
import unittest
-from ansible_collections.vyos.rest.plugins.module_utils.vyos import cast_by_spec
+from unittest.mock import MagicMock
+
+from ansible_collections.vyos.rest.plugins.module_utils.vyos import (
+ VyOSModule,
+ cast_by_spec,
+)
class TestCastBySpecIntCollapse(unittest.TestCase):
@@ -51,5 +56,86 @@ class TestCastBySpecIntCollapse(unittest.TestCase):
self.assertIsNone(entry["distance"])
+class TestGetValue(unittest.TestCase):
+ """Regression tests for a confirmed bug (Copilot): get_value()
+ previously did `result.get("data") or ""`, which incorrectly
+ converts any falsy-but-valid scalar (0, False, an already-empty
+ string) into an empty string -- indistinguishable from the value
+ being genuinely unset. Fixed to only treat a missing "data" key
+ (None) as unset, leaving every other value -- including falsy
+ ones -- exactly as returned."""
+
+ def _vyos_with_data(self, data):
+ vyos = VyOSModule.__new__(VyOSModule)
+ vyos._client = MagicMock()
+ vyos._client.retrieve_return_value.return_value = {"data": data}
+ return vyos
+
+ def test_zero_preserved_not_emptied(self):
+ vyos = self._vyos_with_data(0)
+ result = vyos.get_value(["some", "path"])
+ self.assertEqual(result, 0)
+ self.assertIs(type(result), int)
+
+ def test_false_preserved_not_emptied(self):
+ vyos = self._vyos_with_data(False)
+ self.assertIs(vyos.get_value(["some", "path"]), False)
+
+ def test_genuine_string_value_passes_through(self):
+ vyos = self._vyos_with_data("vyos-core-01")
+ self.assertEqual(vyos.get_value(["some", "path"]), "vyos-core-01")
+
+ def test_already_empty_string_stays_empty(self):
+ vyos = self._vyos_with_data("")
+ self.assertEqual(vyos.get_value(["some", "path"]), "")
+
+ def test_missing_data_key_becomes_empty_string(self):
+ vyos = VyOSModule.__new__(VyOSModule)
+ vyos._client = MagicMock()
+ vyos._client.retrieve_return_value.return_value = {}
+ self.assertEqual(vyos.get_value(["some", "path"]), "")
+
+
+class TestShow(unittest.TestCase):
+ """Regression tests for the same confirmed bug class as
+ TestGetValue, found independently in show(): `result.get("data")
+ or ""` incorrectly converts any falsy-but-valid scalar (0, False,
+ an already-empty string) from an operational show command into an
+ empty string -- indistinguishable from the command genuinely
+ returning nothing. Fixed to only treat a missing "data" key
+ (None) as empty, leaving every other value -- including falsy
+ ones -- exactly as returned."""
+
+ def _vyos_with_data(self, data):
+ vyos = VyOSModule.__new__(VyOSModule)
+ vyos._client = MagicMock()
+ vyos._client.show.return_value = {"data": data}
+ return vyos
+
+ def test_zero_preserved_not_emptied(self):
+ vyos = self._vyos_with_data(0)
+ result = vyos.show(["some", "op", "path"])
+ self.assertEqual(result, 0)
+ self.assertIs(type(result), int)
+
+ def test_false_preserved_not_emptied(self):
+ vyos = self._vyos_with_data(False)
+ self.assertIs(vyos.show(["some", "op", "path"]), False)
+
+ def test_genuine_output_passes_through(self):
+ vyos = self._vyos_with_data("interface eth0 up")
+ self.assertEqual(vyos.show(["some", "op", "path"]), "interface eth0 up")
+
+ def test_already_empty_string_stays_empty(self):
+ vyos = self._vyos_with_data("")
+ self.assertEqual(vyos.show(["some", "op", "path"]), "")
+
+ def test_missing_data_key_becomes_empty_string(self):
+ vyos = VyOSModule.__new__(VyOSModule)
+ vyos._client = MagicMock()
+ vyos._client.show.return_value = {}
+ self.assertEqual(vyos.show(["some", "op", "path"]), "")
+
+
if __name__ == "__main__":
unittest.main()