summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJohn Estabrook <jestabro@vyos.io>2026-08-21 14:06:28 -0500
committerGitHub <noreply@github.com>2026-08-21 14:06:28 -0500
commitff5224728484eb4f3bbf6ae52fe0b79f64959660 (patch)
tree71b3e53e0080da60abf9fd2381705971dfe4d507
parent7a6b5e4f3a7a021cfa75faa7bf833741dfc09cff (diff)
parent45dc95873fd906c582fbbd5e6ca3838caf867399 (diff)
downloadrest.vyos-ff5224728484eb4f3bbf6ae52fe0b79f64959660.tar.gz
rest.vyos-ff5224728484eb4f3bbf6ae52fe0b79f64959660.zip
Merge pull request #16 from vyos/T8989_wave4
T8989: wave4 vyos_command, dict_op refactor
-rw-r--r--README.md6
-rw-r--r--changelogs/fragments/t8989_wave4.yml24
-rw-r--r--docs/vyos.rest.vyos_bgp_address_family_module.rst4
-rw-r--r--docs/vyos.rest.vyos_bgp_global_module.rst4
-rw-r--r--docs/vyos.rest.vyos_command_module.rst252
-rw-r--r--docs/vyos.rest.vyos_config_module.rst228
-rw-r--r--docs/vyos.rest.vyos_firewall_global_module.rst4
-rw-r--r--docs/vyos.rest.vyos_firewall_interfaces_module.rst4
-rw-r--r--docs/vyos.rest.vyos_firewall_rules_module.rst5
-rw-r--r--docs/vyos.rest.vyos_ha_module.rst1668
-rw-r--r--docs/vyos.rest.vyos_logging_global_module.rst239
-rw-r--r--docs/vyos.rest.vyos_nat_module.rst208
-rw-r--r--docs/vyos.rest.vyos_ntp_global_module.rst50
-rw-r--r--docs/vyos.rest.vyos_route_maps_module.rst922
-rw-r--r--docs/vyos.rest.vyos_snmp_server_module.rst12
-rw-r--r--docs/vyos.rest.vyos_system_module.rst254
-rw-r--r--docs/vyos.rest.vyos_user_module.rst4
-rw-r--r--docs/vyos.rest.vyos_vlan_module.rst303
-rw-r--r--plugins/modules/vyos_bgp_address_family.py369
-rw-r--r--plugins/modules/vyos_bgp_global.py391
-rw-r--r--plugins/modules/vyos_command.py228
-rw-r--r--plugins/modules/vyos_config.py238
-rw-r--r--plugins/modules/vyos_firewall_global.py156
-rw-r--r--plugins/modules/vyos_firewall_interfaces.py288
-rw-r--r--plugins/modules/vyos_firewall_rules.py367
-rw-r--r--plugins/modules/vyos_ha.py783
-rw-r--r--plugins/modules/vyos_logging_global.py460
-rw-r--r--plugins/modules/vyos_nat.py492
-rw-r--r--plugins/modules/vyos_ntp_global.py310
-rw-r--r--plugins/modules/vyos_route_maps.py842
-rw-r--r--plugins/modules/vyos_snmp_server.py829
-rw-r--r--plugins/modules/vyos_system.py146
-rw-r--r--plugins/modules/vyos_user.py174
-rw-r--r--plugins/modules/vyos_vlan.py241
-rw-r--r--tests/integration/targets/vyos_banner/tasks/httpapi.yaml2
-rw-r--r--tests/integration/targets/vyos_banner/tests/httpapi/deleted.yaml4
-rw-r--r--tests/integration/targets/vyos_banner/tests/httpapi/gathered.yaml4
-rw-r--r--tests/integration/targets/vyos_banner/tests/httpapi/merged.yaml4
-rw-r--r--tests/integration/targets/vyos_bgp_address_family/tasks/httpapi.yaml2
-rw-r--r--tests/integration/targets/vyos_bgp_address_family/tests/httpapi/deleted.yaml4
-rw-r--r--tests/integration/targets/vyos_bgp_address_family/tests/httpapi/gathered.yaml4
-rw-r--r--tests/integration/targets/vyos_bgp_address_family/tests/httpapi/overridden.yaml4
-rw-r--r--tests/integration/targets/vyos_bgp_address_family/tests/httpapi/replaced.yaml4
-rw-r--r--tests/integration/targets/vyos_bgp_global/tasks/httpapi.yaml2
-rw-r--r--tests/integration/targets/vyos_bgp_global/tests/httpapi/deleted.yaml4
-rw-r--r--tests/integration/targets/vyos_bgp_global/tests/httpapi/gathered.yaml4
-rw-r--r--tests/integration/targets/vyos_bgp_global/tests/httpapi/overridden.yaml4
-rw-r--r--tests/integration/targets/vyos_bgp_global/tests/httpapi/replaced.yaml4
-rw-r--r--tests/integration/targets/vyos_command/aliases1
-rw-r--r--tests/integration/targets/vyos_command/defaults/main.yaml3
-rw-r--r--tests/integration/targets/vyos_command/tasks/httpapi.yaml21
-rw-r--r--tests/integration/targets/vyos_command/tasks/main.yaml5
-rw-r--r--tests/integration/targets/vyos_command/tests/httpapi/show.yaml45
-rw-r--r--tests/integration/targets/vyos_command/tests/httpapi/wait_for.yaml36
-rw-r--r--tests/integration/targets/vyos_command/vars/main.yaml2
-rw-r--r--tests/integration/targets/vyos_config/aliases1
-rw-r--r--tests/integration/targets/vyos_config/defaults/main.yaml3
-rw-r--r--tests/integration/targets/vyos_config/tasks/httpapi.yaml21
-rw-r--r--tests/integration/targets/vyos_config/tasks/main.yaml5
-rw-r--r--tests/integration/targets/vyos_config/tests/httpapi/_remove_config.yaml6
-rw-r--r--tests/integration/targets/vyos_config/tests/httpapi/delete.yaml37
-rw-r--r--tests/integration/targets/vyos_config/tests/httpapi/lines.yaml44
-rw-r--r--tests/integration/targets/vyos_config/vars/main.yaml2
-rw-r--r--tests/integration/targets/vyos_configure/tasks/httpapi.yaml2
-rw-r--r--tests/integration/targets/vyos_facts/tasks/httpapi.yaml2
-rw-r--r--tests/integration/targets/vyos_facts/tests/httpapi/_populate_config.yaml16
-rw-r--r--tests/integration/targets/vyos_facts/tests/httpapi/_remove_config.yaml8
-rw-r--r--tests/integration/targets/vyos_facts/tests/httpapi/all.yaml5
-rw-r--r--tests/integration/targets/vyos_facts/tests/httpapi/default.yaml4
-rw-r--r--tests/integration/targets/vyos_facts/tests/httpapi/subsets.yaml25
-rw-r--r--tests/integration/targets/vyos_firewall_global/tasks/httpapi.yaml2
-rw-r--r--tests/integration/targets/vyos_firewall_global/tests/httpapi/deleted.yaml4
-rw-r--r--tests/integration/targets/vyos_firewall_global/tests/httpapi/gathered.yaml4
-rw-r--r--tests/integration/targets/vyos_firewall_global/tests/httpapi/overridden.yaml4
-rw-r--r--tests/integration/targets/vyos_firewall_global/tests/httpapi/replaced.yaml4
-rw-r--r--tests/integration/targets/vyos_firewall_interfaces/tasks/httpapi.yaml2
-rw-r--r--tests/integration/targets/vyos_firewall_interfaces/tests/httpapi/deleted.yaml4
-rw-r--r--tests/integration/targets/vyos_firewall_interfaces/tests/httpapi/gathered.yaml4
-rw-r--r--tests/integration/targets/vyos_firewall_interfaces/tests/httpapi/overridden.yaml4
-rw-r--r--tests/integration/targets/vyos_firewall_interfaces/tests/httpapi/replaced.yaml4
-rw-r--r--tests/integration/targets/vyos_firewall_rules/tasks/httpapi.yaml2
-rw-r--r--tests/integration/targets/vyos_firewall_rules/tests/httpapi/deleted.yaml4
-rw-r--r--tests/integration/targets/vyos_firewall_rules/tests/httpapi/gathered.yaml4
-rw-r--r--tests/integration/targets/vyos_firewall_rules/tests/httpapi/overridden.yaml4
-rw-r--r--tests/integration/targets/vyos_firewall_rules/tests/httpapi/replaced.yaml4
-rw-r--r--tests/integration/targets/vyos_ha/aliases1
-rw-r--r--tests/integration/targets/vyos_ha/defaults/main.yaml3
-rw-r--r--tests/integration/targets/vyos_ha/tasks/httpapi.yaml18
-rw-r--r--tests/integration/targets/vyos_ha/tasks/main.yaml3
-rw-r--r--tests/integration/targets/vyos_ha/tests/httpapi/_populate_config.yaml40
-rw-r--r--tests/integration/targets/vyos_ha/tests/httpapi/_remove_config.yaml4
-rw-r--r--tests/integration/targets/vyos_ha/tests/httpapi/deleted.yaml36
-rw-r--r--tests/integration/targets/vyos_ha/tests/httpapi/gathered.yaml29
-rw-r--r--tests/integration/targets/vyos_ha/tests/httpapi/merged.yaml54
-rw-r--r--tests/integration/targets/vyos_ha/tests/httpapi/overridden.yaml50
-rw-r--r--tests/integration/targets/vyos_ha/tests/httpapi/replaced.yaml48
-rw-r--r--tests/integration/targets/vyos_ha/vars/main.yaml2
-rw-r--r--tests/integration/targets/vyos_hostname/tasks/httpapi.yaml2
-rw-r--r--tests/integration/targets/vyos_hostname/tests/httpapi/gathered.yaml4
-rw-r--r--tests/integration/targets/vyos_hostname/tests/httpapi/replaced.yaml4
-rw-r--r--tests/integration/targets/vyos_lldp_global/tasks/httpapi.yaml2
-rw-r--r--tests/integration/targets/vyos_lldp_global/tests/httpapi/deleted.yaml4
-rw-r--r--tests/integration/targets/vyos_lldp_global/tests/httpapi/gathered.yaml4
-rw-r--r--tests/integration/targets/vyos_lldp_global/tests/httpapi/replaced.yaml4
-rw-r--r--tests/integration/targets/vyos_logging_global/tasks/httpapi.yaml2
-rw-r--r--tests/integration/targets/vyos_logging_global/tests/httpapi/deleted.yaml4
-rw-r--r--tests/integration/targets/vyos_logging_global/tests/httpapi/gathered.yaml4
-rw-r--r--tests/integration/targets/vyos_logging_global/tests/httpapi/replaced.yaml4
-rw-r--r--tests/integration/targets/vyos_nat/aliases1
-rw-r--r--tests/integration/targets/vyos_nat/defaults/main.yaml3
-rw-r--r--tests/integration/targets/vyos_nat/tasks/httpapi.yaml18
-rw-r--r--tests/integration/targets/vyos_nat/tasks/main.yaml5
-rw-r--r--tests/integration/targets/vyos_nat/tests/httpapi/_populate_config.yaml39
-rw-r--r--tests/integration/targets/vyos_nat/tests/httpapi/_remove_config.yaml5
-rw-r--r--tests/integration/targets/vyos_nat/tests/httpapi/deleted.yaml36
-rw-r--r--tests/integration/targets/vyos_nat/tests/httpapi/gathered.yaml30
-rw-r--r--tests/integration/targets/vyos_nat/tests/httpapi/merged.yaml49
-rw-r--r--tests/integration/targets/vyos_nat/tests/httpapi/overridden.yaml49
-rw-r--r--tests/integration/targets/vyos_nat/tests/httpapi/replaced.yaml49
-rw-r--r--tests/integration/targets/vyos_nat/vars/main.yaml2
-rw-r--r--tests/integration/targets/vyos_ntp_global/tasks/httpapi.yaml2
-rw-r--r--tests/integration/targets/vyos_ntp_global/tests/httpapi/deleted.yaml6
-rw-r--r--tests/integration/targets/vyos_ntp_global/tests/httpapi/gathered.yaml8
-rw-r--r--tests/integration/targets/vyos_ntp_global/tests/httpapi/overridden.yaml4
-rw-r--r--tests/integration/targets/vyos_ntp_global/tests/httpapi/replaced.yaml8
-rw-r--r--tests/integration/targets/vyos_ntp_global/tests/httpapi/rtt.yaml54
-rw-r--r--tests/integration/targets/vyos_ospf_interfaces/tasks/httpapi.yaml2
-rw-r--r--tests/integration/targets/vyos_prefix_lists/tests/httpapi/deleted.yaml4
-rw-r--r--tests/integration/targets/vyos_prefix_lists/tests/httpapi/gathered.yaml4
-rw-r--r--tests/integration/targets/vyos_prefix_lists/tests/httpapi/replaced.yaml4
-rw-r--r--tests/integration/targets/vyos_route_maps/tasks/httpapi.yaml2
-rw-r--r--tests/integration/targets/vyos_route_maps/tests/httpapi/deleted.yaml4
-rw-r--r--tests/integration/targets/vyos_route_maps/tests/httpapi/gathered.yaml4
-rw-r--r--tests/integration/targets/vyos_route_maps/tests/httpapi/replaced.yaml4
-rw-r--r--tests/integration/targets/vyos_route_maps/tests/httpapi/rtt.yaml128
-rw-r--r--tests/integration/targets/vyos_snmp_server/tasks/httpapi.yaml2
-rw-r--r--tests/integration/targets/vyos_snmp_server/tests/httpapi/deleted.yaml4
-rw-r--r--tests/integration/targets/vyos_snmp_server/tests/httpapi/gathered.yaml4
-rw-r--r--tests/integration/targets/vyos_snmp_server/tests/httpapi/replaced.yaml4
-rw-r--r--tests/integration/targets/vyos_snmp_server/tests/httpapi/rtt.yaml77
-rw-r--r--tests/integration/targets/vyos_system/aliases1
-rw-r--r--tests/integration/targets/vyos_system/defaults/main.yaml3
-rw-r--r--tests/integration/targets/vyos_system/tasks/httpapi.yaml21
-rw-r--r--tests/integration/targets/vyos_system/tasks/main.yaml5
-rw-r--r--tests/integration/targets/vyos_system/tests/httpapi/_remove_config.yaml12
-rw-r--r--tests/integration/targets/vyos_system/tests/httpapi/absent.yaml48
-rw-r--r--tests/integration/targets/vyos_system/tests/httpapi/gathered.yaml14
-rw-r--r--tests/integration/targets/vyos_system/tests/httpapi/present.yaml63
-rw-r--r--tests/integration/targets/vyos_system/vars/main.yaml2
-rw-r--r--tests/integration/targets/vyos_user/tasks/httpapi.yaml2
-rw-r--r--tests/integration/targets/vyos_vlan/aliases1
-rw-r--r--tests/integration/targets/vyos_vlan/defaults/main.yaml3
-rw-r--r--tests/integration/targets/vyos_vlan/tasks/httpapi.yaml21
-rw-r--r--tests/integration/targets/vyos_vlan/tasks/main.yaml5
-rw-r--r--tests/integration/targets/vyos_vlan/tests/httpapi/_remove_config.yaml9
-rw-r--r--tests/integration/targets/vyos_vlan/tests/httpapi/absent.yaml45
-rw-r--r--tests/integration/targets/vyos_vlan/tests/httpapi/gathered.yaml28
-rw-r--r--tests/integration/targets/vyos_vlan/tests/httpapi/present.yaml44
-rw-r--r--tests/integration/targets/vyos_vlan/tests/httpapi/rtt.yaml43
-rw-r--r--tests/integration/targets/vyos_vlan/vars/main.yaml2
-rw-r--r--tests/unit/fixtures/bgp_af_running.json17
-rw-r--r--tests/unit/fixtures/bgp_global_running.json14
-rw-r--r--tests/unit/fixtures/firewall_interfaces_running.json14
-rw-r--r--tests/unit/fixtures/ha_running.json44
-rw-r--r--tests/unit/fixtures/nat_running.json80
-rw-r--r--tests/unit/fixtures/route_maps_running.json11
-rw-r--r--tests/unit/fixtures/snmp_server_running.json11
-rw-r--r--tests/unit/fixtures/system_running.json11
-rw-r--r--tests/unit/fixtures/vlan_running.json24
-rw-r--r--tests/unit/modules/base.py15
-rw-r--r--tests/unit/modules/test_vyos_bgp_address_family.py358
-rw-r--r--tests/unit/modules/test_vyos_bgp_global.py341
-rw-r--r--tests/unit/modules/test_vyos_command.py111
-rw-r--r--tests/unit/modules/test_vyos_config.py107
-rw-r--r--tests/unit/modules/test_vyos_facts.py8
-rw-r--r--tests/unit/modules/test_vyos_firewall_global.py380
-rw-r--r--tests/unit/modules/test_vyos_firewall_interfaces.py306
-rw-r--r--tests/unit/modules/test_vyos_firewall_rules.py345
-rw-r--r--tests/unit/modules/test_vyos_ha.py345
-rw-r--r--tests/unit/modules/test_vyos_logging_global.py416
-rw-r--r--tests/unit/modules/test_vyos_nat.py558
-rw-r--r--tests/unit/modules/test_vyos_ntp_global.py265
-rw-r--r--tests/unit/modules/test_vyos_route_maps.py506
-rw-r--r--tests/unit/modules/test_vyos_snmp_server.py644
-rw-r--r--tests/unit/modules/test_vyos_system.py93
-rw-r--r--tests/unit/modules/test_vyos_user.py278
-rw-r--r--tests/unit/modules/test_vyos_vlan.py116
187 files changed, 13896 insertions, 3738 deletions
diff --git a/README.md b/README.md
index 4c701af..50d4aad 100644
--- a/README.md
+++ b/README.md
@@ -79,11 +79,14 @@ Name | Description
[vyos.rest.vyos_banner](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_banner_module.rst)|Manage multiline banners on VyOS devices via REST API.
[vyos.rest.vyos_bgp_address_family](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_bgp_address_family_module.rst)|Manage BGP address-family configuration on VyOS devices using REST API
[vyos.rest.vyos_bgp_global](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_bgp_global_module.rst)|Manage BGP global configuration on VyOS devices using REST API
+[vyos.rest.vyos_command](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_command_module.rst)|Run show commands on VyOS devices using REST API
+[vyos.rest.vyos_config](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_config_module.rst)|Manage VyOS configuration using REST API
[vyos.rest.vyos_configure](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_configure_module.rst)|Send raw set/delete commands to a VyOS device via REST API.
[vyos.rest.vyos_facts](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_facts_module.rst)|Get facts about VyOS devices using REST API
[vyos.rest.vyos_firewall_global](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_firewall_global_module.rst)|Manage global firewall configuration on VyOS devices using REST API
[vyos.rest.vyos_firewall_interfaces](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_firewall_interfaces_module.rst)|Manage firewall hook filters on VyOS devices using REST API
[vyos.rest.vyos_firewall_rules](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_firewall_rules_module.rst)|Manage firewall rule sets on VyOS devices using REST API
+[vyos.rest.vyos_ha](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_ha_module.rst)|Manage VRRP and load balancer configuration on VyOS via REST API
[vyos.rest.vyos_hostname](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_hostname_module.rst)|Manage the system hostname on a VyOS device via the REST API.
[vyos.rest.vyos_interfaces](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_interfaces_module.rst)|Manage interface configuration on VyOS devices via REST API.
[vyos.rest.vyos_l3_interfaces](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_l3_interfaces_module.rst)|Manage L3 interface configuration on VyOS devices via REST API.
@@ -92,6 +95,7 @@ Name | Description
[vyos.rest.vyos_lldp_interfaces](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_lldp_int
erfaces_module.rst)|Manage LLDP interface configuration on VyOS devices via REST API.
[vyos.rest.vyos_logging_global](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_logging_global_module.rst)|Manage syslog configuration on VyOS devices using REST API
+[vyos.rest.vyos_nat](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_nat_module.rst)|Manage NAT configuration on VyOS devices using REST API
[vyos.rest.vyos_ntp_global](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_ntp_global_module.rst)|Manage NTP configuration on VyOS devices using REST API
[vyos.rest.vyos_ospf_interfaces](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_ospf_interfaces_module.rst)|Manage OSPF interface configuration on VyOS devices using REST API
[vyos.rest.vyos_ospfv2](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_ospfv2_module.rst)|Manage OSPFv2 configuration on VyOS devices using REST API
@@ -100,7 +104,9 @@ erfaces_module.rst)|Manage LLDP interface configuration on VyOS devices via REST
[vyos.rest.vyos_route_maps](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_route_maps_module.rst)|Manage route-map configuration on VyOS devices using REST API
[vyos.rest.vyos_snmp_server](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_snmp_server_module.rst)|Manage SNMP server configuration on VyOS devices using REST API
[vyos.rest.vyos_static_routes](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_static_routes_module.rst)|Manage static routes on VyOS devices via REST API.
+[vyos.rest.vyos_system](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_system_module.rst)|Manage system settings on VyOS devices using REST API
[vyos.rest.vyos_user](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_user_module.rst)|Manage user accounts on VyOS devices using REST API
+[vyos.rest.vyos_vlan](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_vlan_module.rst)|Manage VLAN (vif) configuration on VyOS devices using REST API
<!--end collection content-->
diff --git a/changelogs/fragments/t8989_wave4.yml b/changelogs/fragments/t8989_wave4.yml
new file mode 100644
index 0000000..80678fe
--- /dev/null
+++ b/changelogs/fragments/t8989_wave4.yml
@@ -0,0 +1,24 @@
+---
+minor_changes:
+ - vyos_banner - Add new module and tests.
+ - vyos_bgp_address_family - Add new module and tests.
+ - vyos_bgp_global - Add new module and tests.
+ - vyos_command - Add new module and tests.
+ - vyos_config - Add new module and tests.
+ - vyos_facts - Add new module and tests.
+ - vyos_firewall_global - Add new module and tests.
+ - vyos_firewall_interfaces - Add new module and tests.
+ - vyos_firewall_rules - Add new module and tests.
+ - vyos_ha - Add new module and tests.
+ - vyos_hostname - Add new module and tests.
+ - vyos_lldp_global - Add new module and tests.
+ - vyos_logging_global - Add new module and tests.
+ - vyos_nat - Add new module and tests.
+ - vyos_ntp_global - Add new module and tests.
+ - vyos_prefix_lists - Add new module and tests.
+ - vyos_route_maps - Add new module and tests.
+ - vyos_snmp_server - Add new module and tests.
+ - vyos_static_routes - Add new module and tests.
+ - vyos_system - Add new module and tests.
+ - vyos_user - Add new module and tests.
+ - vyos_vlan - Add new module and tests.
diff --git a/docs/vyos.rest.vyos_bgp_address_family_module.rst b/docs/vyos.rest.vyos_bgp_address_family_module.rst
index d9d45d7..f55fa15 100644
--- a/docs/vyos.rest.vyos_bgp_address_family_module.rst
+++ b/docs/vyos.rest.vyos_bgp_address_family_module.rst
@@ -989,7 +989,7 @@ Common return values are documented `here <https://docs.ansible.com/ansible/late
<span style="color: purple">dictionary</span>
</div>
</td>
- <td>when changes are applied</td>
+ <td>always</td>
<td>
<div>Raw API response.</div>
<br/>
@@ -1004,7 +1004,7 @@ Common return values are documented `here <https://docs.ansible.com/ansible/late
<span style="color: purple">boolean</span>
</div>
</td>
- <td>when changes are applied</td>
+ <td>when changed</td>
<td>
<div>Whether the config was saved after changes.</div>
<br/>
diff --git a/docs/vyos.rest.vyos_bgp_global_module.rst b/docs/vyos.rest.vyos_bgp_global_module.rst
index 01bcefe..db5a759 100644
--- a/docs/vyos.rest.vyos_bgp_global_module.rst
+++ b/docs/vyos.rest.vyos_bgp_global_module.rst
@@ -841,7 +841,7 @@ Common return values are documented `here <https://docs.ansible.com/ansible/late
<span style="color: purple">dictionary</span>
</div>
</td>
- <td>when changes are applied</td>
+ <td>always</td>
<td>
<div>Raw API response.</div>
<br/>
@@ -856,7 +856,7 @@ Common return values are documented `here <https://docs.ansible.com/ansible/late
<span style="color: purple">boolean</span>
</div>
</td>
- <td>when changes are applied</td>
+ <td>when changed</td>
<td>
<div>Whether the config was saved after changes.</div>
<br/>
diff --git a/docs/vyos.rest.vyos_command_module.rst b/docs/vyos.rest.vyos_command_module.rst
new file mode 100644
index 0000000..7075ec6
--- /dev/null
+++ b/docs/vyos.rest.vyos_command_module.rst
@@ -0,0 +1,252 @@
+.. _vyos.rest.vyos_command_module:
+
+
+**********************
+vyos.rest.vyos_command
+**********************
+
+**Run show commands on VyOS devices using REST API**
+
+
+Version added: 1.0.0
+
+.. contents::
+ :local:
+ :depth: 1
+
+
+Synopsis
+--------
+- Sends show commands to VyOS devices via the REST API ``/show`` endpoint and returns the output.
+- Equivalent to ``vyos_command`` in the CLI collection but uses the REST API.
+- Uses REST API (``connection=httpapi``) instead of CLI.
+
+
+
+
+Parameters
+----------
+
+.. raw:: html
+
+ <table border=0 cellpadding=0 class="documentation-table">
+ <tr>
+ <th colspan="1">Parameter</th>
+ <th>Choices/<font color="blue">Defaults</font></th>
+ <th width="100%">Comments</th>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>commands</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=raw</span>
+ / <span style="color: red">required</span>
+ </div>
+ </td>
+ <td>
+ </td>
+ <td>
+ <div>List of show commands to run on the device.</div>
+ <div>Each command is a list of path elements passed to the <code>/show</code> endpoint.</div>
+ <div>Commands may be specified as a string (space-separated) or a list.</div>
+ </td>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>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>
+ <b>Default:</b><br/><div style="color: blue">1</div>
+ </td>
+ <td>
+ <div>Configures the interval in seconds to wait between retries of the command.</div>
+ </td>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>match</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">string</span>
+ </div>
+ </td>
+ <td>
+ <ul style="margin: 0; padding: 0"><b>Choices:</b>
+ <li>any</li>
+ <li><div style="color: blue"><b>all</b>&nbsp;&larr;</div></li>
+ </ul>
+ </td>
+ <td>
+ <div>The <code>match</code> argument is used in conjunction with the <code>wait_for</code> argument to specify the match policy.</div>
+ </td>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>retries</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>
+ <b>Default:</b><br/><div style="color: blue">10</div>
+ </td>
+ <td>
+ <div>Specifies the number of retries a command should be run before it is considered failed.</div>
+ </td>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>wait_for</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>Specifies what to evaluate from the output of the command and what conditionals to apply. This argument will cause the task to wait for a particular conditional to be true before moving forward.</div>
+ <div style="font-size: small; color: darkgreen"><br/>aliases: waitfor</div>
+ </td>
+ </tr>
+ </table>
+ <br/>
+
+
+Notes
+-----
+
+.. note::
+ - Requires ``ansible_connection=httpapi`` with the VyOS httpapi plugin.
+ - ``ansible_network_os`` must be set to ``vyos.rest.vyos``.
+ - Only ``show`` commands are supported via the REST API.
+ - Commands are passed as path lists to the ``/show`` endpoint.
+
+
+
+Examples
+--------
+
+.. code-block:: yaml
+
+ - name: Run show version
+ vyos.rest.vyos_command:
+ commands:
+ - - version
+ register: result
+
+ - name: Run multiple show commands
+ vyos.rest.vyos_command:
+ commands:
+ - - interfaces
+ - - ip
+ - route
+ - - system
+ - uptime
+ register: result
+
+ - name: Run show commands as strings
+ vyos.rest.vyos_command:
+ commands:
+ - "interfaces"
+ - "ip route"
+ - "version"
+ register: result
+
+ - name: Wait for BGP to establish
+ vyos.rest.vyos_command:
+ commands:
+ - - ip
+ - bgp
+ - summary
+ wait_for:
+ - result[0] contains Established
+ retries: 10
+ interval: 5
+
+
+
+Return Values
+-------------
+Common return values are documented `here <https://docs.ansible.com/ansible/latest/reference_appendices/common_return_values.html#common-return-values>`_, the following are the fields unique to this module:
+
+.. raw:: html
+
+ <table border=0 cellpadding=0 class="documentation-table">
+ <tr>
+ <th colspan="1">Key</th>
+ <th>Returned</th>
+ <th width="100%">Description</th>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="return-"></div>
+ <b>failed_conditions</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>failed</td>
+ <td>
+ <div>List of conditions that failed.</div>
+ <br/>
+ </td>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="return-"></div>
+ <b>stdout</b>
+ <a class="ansibleOptionLink" href="#return-" title="Permalink to this return value"></a>
+ <div style="font-size: small">
+ <span style="color: purple">list</span>
+ </div>
+ </td>
+ <td>always</td>
+ <td>
+ <div>List of output from each command.</div>
+ <br/>
+ <div style="font-size: smaller"><b>Sample:</b></div>
+ <div style="font-size: smaller; color: blue; word-wrap: break-word; word-break: break-all;">[&#x27;VyOS 1.5.0\n...&#x27;, &#x27;Interface IP Address\n...&#x27;]</div>
+ </td>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="return-"></div>
+ <b>stdout_lines</b>
+ <a class="ansibleOptionLink" href="#return-" title="Permalink to this return value"></a>
+ <div style="font-size: small">
+ <span style="color: purple">list</span>
+ </div>
+ </td>
+ <td>always</td>
+ <td>
+ <div>List of output split into lines for each command.</div>
+ <br/>
+ </td>
+ </tr>
+ </table>
+ <br/><br/>
+
+
+Status
+------
+
+
+Authors
+~~~~~~~
+
+- VyOS Community (@vyos)
diff --git a/docs/vyos.rest.vyos_config_module.rst b/docs/vyos.rest.vyos_config_module.rst
new file mode 100644
index 0000000..9753ba3
--- /dev/null
+++ b/docs/vyos.rest.vyos_config_module.rst
@@ -0,0 +1,228 @@
+.. _vyos.rest.vyos_config_module:
+
+
+*********************
+vyos.rest.vyos_config
+*********************
+
+**Manage VyOS configuration using REST API**
+
+
+Version added: 1.0.0
+
+.. contents::
+ :local:
+ :depth: 1
+
+
+Synopsis
+--------
+- Manages VyOS device configuration via the REST API.
+- Accepts configuration commands in CLI ``set``/``delete`` string format and applies them via the REST ``/configure`` endpoint.
+- Uses REST API (``connection=httpapi``) instead of CLI.
+
+
+
+
+Parameters
+----------
+
+.. raw:: html
+
+ <table border=0 cellpadding=0 class="documentation-table">
+ <tr>
+ <th colspan="1">Parameter</th>
+ <th>Choices/<font color="blue">Defaults</font></th>
+ <th width="100%">Comments</th>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>lines</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>Ordered list of <code>set</code> or <code>delete</code> commands to apply.</div>
+ <div>Commands should be in standard VyOS CLI format, e.g. <code>set system host-name router1</code> or <code>delete protocols bgp</code>.</div>
+ </td>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>match</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">string</span>
+ </div>
+ </td>
+ <td>
+ <ul style="margin: 0; padding: 0"><b>Choices:</b>
+ <li><div style="color: blue"><b>line</b>&nbsp;&larr;</div></li>
+ <li>none</li>
+ </ul>
+ </td>
+ <td>
+ <div>Controls how commands are matched against the running configuration.</div>
+ <div><code>line</code> checks each command against the running config and only applies commands that would change the configuration.</div>
+ <div><code>none</code> applies all commands without checking the running config.</div>
+ </td>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>save</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">boolean</span>
+ </div>
+ </td>
+ <td>
+ <ul style="margin: 0; padding: 0"><b>Choices:</b>
+ <li><div style="color: blue"><b>no</b>&nbsp;&larr;</div></li>
+ <li>yes</li>
+ </ul>
+ </td>
+ <td>
+ <div>Save the configuration to disk after applying changes.</div>
+ </td>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>src</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">path</span>
+ </div>
+ </td>
+ <td>
+ </td>
+ <td>
+ <div>Path to a file containing <code>set</code>/<code>delete</code> commands, one per line.</div>
+ <div>Blank lines and lines starting with <code>#</code> are ignored.</div>
+ <div>Mutually exclusive with <code>lines</code>.</div>
+ </td>
+ </tr>
+ </table>
+ <br/>
+
+
+Notes
+-----
+
+.. note::
+ - Requires ``ansible_connection=httpapi`` with the VyOS httpapi plugin.
+ - ``ansible_network_os`` must be set to ``vyos.rest.vyos``.
+ - Unlike the CLI collection's ``vyos_config``, this module does not support ``backup``, ``confirm``, or ``comment`` options as these are CLI-specific.
+ - Commands are parsed from CLI string format into REST API path arrays.
+
+
+
+Examples
+--------
+
+.. code-block:: yaml
+
+ - name: Apply configuration lines
+ vyos.rest.vyos_config:
+ lines:
+ - set system host-name router1
+ - set system domain-name example.com
+ - set interfaces ethernet eth0 description "WAN"
+ save: true
+
+ - name: Delete configuration
+ vyos.rest.vyos_config:
+ lines:
+ - delete protocols bgp
+ save: true
+
+ - name: Apply config from file
+ vyos.rest.vyos_config:
+ src: /tmp/vyos_config.txt
+ match: none
+ save: true
+
+ - name: Always apply without matching
+ vyos.rest.vyos_config:
+ lines:
+ - set system host-name router1
+ match: none
+
+
+
+Return Values
+-------------
+Common return values are documented `here <https://docs.ansible.com/ansible/latest/reference_appendices/common_return_values.html#common-return-values>`_, the following are the fields unique to this module:
+
+.. raw:: html
+
+ <table border=0 cellpadding=0 class="documentation-table">
+ <tr>
+ <th colspan="1">Key</th>
+ <th>Returned</th>
+ <th width="100%">Description</th>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="return-"></div>
+ <b>commands</b>
+ <a class="ansibleOptionLink" href="#return-" title="Permalink to this return value"></a>
+ <div style="font-size: small">
+ <span style="color: purple">list</span>
+ </div>
+ </td>
+ <td>always</td>
+ <td>
+ <div>List of commands applied to the device.</div>
+ <br/>
+ </td>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="return-"></div>
+ <b>response</b>
+ <a class="ansibleOptionLink" href="#return-" title="Permalink to this return value"></a>
+ <div style="font-size: small">
+ <span style="color: purple">dictionary</span>
+ </div>
+ </td>
+ <td>always</td>
+ <td>
+ <div>Raw API response from the device.</div>
+ <br/>
+ </td>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="return-"></div>
+ <b>saved</b>
+ <a class="ansibleOptionLink" href="#return-" title="Permalink to this return value"></a>
+ <div style="font-size: small">
+ <span style="color: purple">boolean</span>
+ </div>
+ </td>
+ <td>when save is true and changes were made</td>
+ <td>
+ <div>Whether the configuration was saved to disk.</div>
+ <br/>
+ </td>
+ </tr>
+ </table>
+ <br/><br/>
+
+
+Status
+------
+
+
+Authors
+~~~~~~~
+
+- VyOS Community (@vyos)
diff --git a/docs/vyos.rest.vyos_firewall_global_module.rst b/docs/vyos.rest.vyos_firewall_global_module.rst
index d90c96a..ff119a8 100644
--- a/docs/vyos.rest.vyos_firewall_global_module.rst
+++ b/docs/vyos.rest.vyos_firewall_global_module.rst
@@ -607,7 +607,7 @@ Common return values are documented `here <https://docs.ansible.com/ansible/late
<span style="color: purple">dictionary</span>
</div>
</td>
- <td>when changes are applied</td>
+ <td>always</td>
<td>
<div>Raw API response.</div>
<br/>
@@ -622,7 +622,7 @@ Common return values are documented `here <https://docs.ansible.com/ansible/late
<span style="color: purple">boolean</span>
</div>
</td>
- <td>when changes are applied</td>
+ <td>when changed</td>
<td>
<div>Whether the config was saved after changes.</div>
<br/>
diff --git a/docs/vyos.rest.vyos_firewall_interfaces_module.rst b/docs/vyos.rest.vyos_firewall_interfaces_module.rst
index 56effe1..9ec4a3b 100644
--- a/docs/vyos.rest.vyos_firewall_interfaces_module.rst
+++ b/docs/vyos.rest.vyos_firewall_interfaces_module.rst
@@ -597,7 +597,7 @@ Common return values are documented `here <https://docs.ansible.com/ansible/late
<span style="color: purple">dictionary</span>
</div>
</td>
- <td>when changes are applied</td>
+ <td>always</td>
<td>
<div>Raw API response.</div>
<br/>
@@ -612,7 +612,7 @@ Common return values are documented `here <https://docs.ansible.com/ansible/late
<span style="color: purple">boolean</span>
</div>
</td>
- <td>when changes are applied</td>
+ <td>when changed</td>
<td>
<div>Whether the config was saved after changes.</div>
<br/>
diff --git a/docs/vyos.rest.vyos_firewall_rules_module.rst b/docs/vyos.rest.vyos_firewall_rules_module.rst
index b0e32c8..1f316fd 100644
--- a/docs/vyos.rest.vyos_firewall_rules_module.rst
+++ b/docs/vyos.rest.vyos_firewall_rules_module.rst
@@ -564,6 +564,7 @@ Notes
- Requires ``ansible_connection=httpapi`` with the VyOS httpapi plugin.
- ``ansible_network_os`` must be set to ``vyos.rest.vyos``.
- Rule sets are identified by AFI and name. Deleting a rule set removes all its rules.
+ - The ``group`` suboption can only reference an address-group. VyOS also supports network-group/port-group/domain-group references, which this module can read back (via ``gathered``) if already configured by other means, but cannot create -- the argspec has no group-type discriminator.
@@ -690,7 +691,7 @@ Common return values are documented `here <https://docs.ansible.com/ansible/late
<span style="color: purple">dictionary</span>
</div>
</td>
- <td>when changes are applied</td>
+ <td>always</td>
<td>
<div>Raw API response.</div>
<br/>
@@ -705,7 +706,7 @@ Common return values are documented `here <https://docs.ansible.com/ansible/late
<span style="color: purple">boolean</span>
</div>
</td>
- <td>when changes are applied</td>
+ <td>when changed</td>
<td>
<div>Whether the config was saved after changes.</div>
<br/>
diff --git a/docs/vyos.rest.vyos_ha_module.rst b/docs/vyos.rest.vyos_ha_module.rst
new file mode 100644
index 0000000..68370cf
--- /dev/null
+++ b/docs/vyos.rest.vyos_ha_module.rst
@@ -0,0 +1,1668 @@
+.. _vyos.rest.vyos_ha_module:
+
+
+*****************
+vyos.rest.vyos_ha
+*****************
+
+**Manage VRRP and load balancer configuration on VyOS via REST API**
+
+
+Version added: 1.0.0
+
+.. contents::
+ :local:
+ :depth: 1
+
+
+Synopsis
+--------
+- Manages VRRP groups, global VRRP parameters, sync-groups, virtual servers, and LVS real servers on VyOS devices via the REST API.
+- Uses REST API (``connection=httpapi``) instead of CLI.
+- Targets VyOS 1.4+.
+
+
+
+
+Parameters
+----------
+
+.. raw:: html
+
+ <table border=0 cellpadding=0 class="documentation-table">
+ <tr>
+ <th colspan="5">Parameter</th>
+ <th>Choices/<font color="blue">Defaults</font></th>
+ <th width="100%">Comments</th>
+ </tr>
+ <tr>
+ <td colspan="5">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>config</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">dictionary</span>
+ </div>
+ </td>
+ <td>
+ </td>
+ <td>
+ <div>High-availability configuration.</div>
+ </td>
+ </tr>
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td colspan="4">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>disable</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">boolean</span>
+ </div>
+ </td>
+ <td>
+ <ul style="margin: 0; padding: 0"><b>Choices:</b>
+ <li><div style="color: blue"><b>no</b>&nbsp;&larr;</div></li>
+ <li>yes</li>
+ </ul>
+ </td>
+ <td>
+ <div>Disable all high-availability configuration.</div>
+ </td>
+ </tr>
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td colspan="4">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>virtual_servers</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">list</span>
+ / <span style="color: purple">elements=dictionary</span>
+ </div>
+ </td>
+ <td>
+ </td>
+ <td>
+ <div>List of load balancer virtual server definitions.</div>
+ </td>
+ </tr>
+ <tr>
+ <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>
+ </div>
+ </td>
+ <td>
+ </td>
+ <td>
+ <div>Address.</div>
+ </td>
+ </tr>
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td colspan="3">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>algorithm</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>Algorithm.</div>
+ </td>
+ </tr>
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td colspan="3">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>delay_loop</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 loop.</div>
+ </td>
+ </tr>
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td colspan="3">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>forward_method</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">string</span>
+ </div>
+ </td>
+ <td>
+ <ul style="margin: 0; padding: 0"><b>Choices:</b>
+ <li>direct</li>
+ <li>nat</li>
+ </ul>
+ </td>
+ <td>
+ <div>Forward method.</div>
+ </td>
+ </tr>
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td colspan="3">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>fwmark</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>Fwmark.</div>
+ </td>
+ </tr>
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td colspan="3">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>name</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">string</span>
+ / <span style="color: red">required</span>
+ </div>
+ </td>
+ <td>
+ </td>
+ <td>
+ <div>Name.</div>
+ </td>
+ </tr>
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td colspan="3">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>persistence_timeout</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>Persistence timeout.</div>
+ </td>
+ </tr>
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td colspan="3">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>port</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>Port.</div>
+ </td>
+ </tr>
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td colspan="3">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>protocol</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">string</span>
+ </div>
+ </td>
+ <td>
+ <ul style="margin: 0; padding: 0"><b>Choices:</b>
+ <li>tcp</li>
+ <li>udp</li>
+ </ul>
+ </td>
+ <td>
+ <div>Protocol.</div>
+ </td>
+ </tr>
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td colspan="3">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>real_server</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>Real server.</div>
+ </td>
+ </tr>
+ <tr>
+ <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>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>Address.</div>
+ </td>
+ </tr>
+ <tr>
+ <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>connection_timeout</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>Connection timeout.</div>
+ </td>
+ </tr>
+ <tr>
+ <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>health_check_script</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>Health check script.</div>
+ </td>
+ </tr>
+ <tr>
+ <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>port</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>Port.</div>
+ </td>
+ </tr>
+
+
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td colspan="4">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>vrrp</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>VRRP configuration.</div>
+ </td>
+ </tr>
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td colspan="3">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>global_parameters</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">dictionary</span>
+ </div>
+ </td>
+ <td>
+ </td>
+ <td>
+ <div>Global parameters.</div>
+ </td>
+ </tr>
+ <tr>
+ <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>garp</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>Garp.</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="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>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.</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="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>master_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>Master delay.</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="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>master_refresh</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>Master refresh.</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="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>master_refresh_repeat</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>Master refresh repeat.</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="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>master_repeat</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>Master repeat.</div>
+ </td>
+ </tr>
+
+ <tr>
+ <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>startup_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>Startup delay.</div>
+ </td>
+ </tr>
+ <tr>
+ <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>version</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>Version.</div>
+ </td>
+ </tr>
+
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td colspan="3">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>groups</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>Groups.</div>
+ </td>
+ </tr>
+ <tr>
+ <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>address</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>Address.</div>
+ </td>
+ </tr>
+ <tr>
+ <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>advertise_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>Advertise interval.</div>
+ </td>
+ </tr>
+ <tr>
+ <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>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>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="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>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>Password.</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="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>type</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>Type.</div>
+ </td>
+ </tr>
+
+ <tr>
+ <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>description</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">string</span>
+ </div>
+ </td>
+ <td>
+ </td>
+ <td>
+ <div>Description.</div>
+ </td>
+ </tr>
+ <tr>
+ <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>disable</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">boolean</span>
+ </div>
+ </td>
+ <td>
+ <ul style="margin: 0; padding: 0"><b>Choices:</b>
+ <li><div style="color: blue"><b>no</b>&nbsp;&larr;</div></li>
+ <li>yes</li>
+ </ul>
+ </td>
+ <td>
+ <div>Disable.</div>
+ </td>
+ </tr>
+ <tr>
+ <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>excluded_address</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>Excluded address.</div>
+ </td>
+ </tr>
+ <tr>
+ <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>garp</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>Garp.</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="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>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.</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="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>master_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>Master delay.</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="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>master_refresh</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>Master refresh.</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="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>master_refresh_repeat</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>Master refresh repeat.</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="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>master_repeat</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>Master repeat.</div>
+ </td>
+ </tr>
+
+ <tr>
+ <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>health_check</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>Health check.</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="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>failure_count</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>Failure count.</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="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>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.</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="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>ping</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>Ping.</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="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>script</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>Script.</div>
+ </td>
+ </tr>
+
+ <tr>
+ <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>hello_source_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>Hello source address.</div>
+ </td>
+ </tr>
+ <tr>
+ <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>interface</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">string</span>
+ </div>
+ </td>
+ <td>
+ </td>
+ <td>
+ <div>Interface.</div>
+ </td>
+ </tr>
+ <tr>
+ <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>name</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">string</span>
+ / <span style="color: red">required</span>
+ </div>
+ </td>
+ <td>
+ </td>
+ <td>
+ <div>Name.</div>
+ </td>
+ </tr>
+ <tr>
+ <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>no_preempt</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">boolean</span>
+ </div>
+ </td>
+ <td>
+ <ul style="margin: 0; padding: 0"><b>Choices:</b>
+ <li><div style="color: blue"><b>no</b>&nbsp;&larr;</div></li>
+ <li>yes</li>
+ </ul>
+ </td>
+ <td>
+ <div>No preempt.</div>
+ </td>
+ </tr>
+ <tr>
+ <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>peer_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>Peer address.</div>
+ </td>
+ </tr>
+ <tr>
+ <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>preempt_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>Preempt delay.</div>
+ </td>
+ </tr>
+ <tr>
+ <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>priority</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>Priority.</div>
+ </td>
+ </tr>
+ <tr>
+ <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>rfc3768_compatibility</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">boolean</span>
+ </div>
+ </td>
+ <td>
+ <ul style="margin: 0; padding: 0"><b>Choices:</b>
+ <li><div style="color: blue"><b>no</b>&nbsp;&larr;</div></li>
+ <li>yes</li>
+ </ul>
+ </td>
+ <td>
+ <div>Rfc3768 compatibility.</div>
+ </td>
+ </tr>
+ <tr>
+ <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>track</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>Track.</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="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>exclude_vrrp_interface</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>Exclude vrrp interface.</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="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>interface</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">list</span>
+ / <span style="color: purple">elements=string</span>
+ </div>
+ </td>
+ <td>
+ </td>
+ <td>
+ <div>Interface.</div>
+ </td>
+ </tr>
+
+ <tr>
+ <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>transition_script</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>Transition script.</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="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>backup</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>Backup.</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="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>fault</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>Fault.</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="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>master</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>Master.</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="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>stop</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>Stop.</div>
+ </td>
+ </tr>
+
+ <tr>
+ <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>vrid</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>Vrid.</div>
+ </td>
+ </tr>
+
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td colspan="3">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>snmp</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">string</span>
+ </div>
+ </td>
+ <td>
+ <ul style="margin: 0; padding: 0"><b>Choices:</b>
+ <li>enabled</li>
+ <li>disabled</li>
+ </ul>
+ </td>
+ <td>
+ <div>Snmp.</div>
+ </td>
+ </tr>
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td colspan="3">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>sync_groups</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>Sync groups.</div>
+ </td>
+ </tr>
+ <tr>
+ <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>health_check</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>Health check.</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="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>failure_count</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>Failure count.</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="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>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.</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="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>ping</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>Ping.</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="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>script</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>Script.</div>
+ </td>
+ </tr>
+
+ <tr>
+ <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>member</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>Member.</div>
+ </td>
+ </tr>
+ <tr>
+ <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>name</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">string</span>
+ / <span style="color: red">required</span>
+ </div>
+ </td>
+ <td>
+ </td>
+ <td>
+ <div>Name.</div>
+ </td>
+ </tr>
+ <tr>
+ <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>transition_script</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>Transition script.</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="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>backup</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>Backup.</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="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>fault</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>Fault.</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="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>master</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>Master.</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="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>stop</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>Stop.</div>
+ </td>
+ </tr>
+
+
+
+
+ <tr>
+ <td colspan="5">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>state</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">string</span>
+ </div>
+ </td>
+ <td>
+ <ul style="margin: 0; padding: 0"><b>Choices:</b>
+ <li><div style="color: blue"><b>merged</b>&nbsp;&larr;</div></li>
+ <li>replaced</li>
+ <li>overridden</li>
+ <li>deleted</li>
+ <li>gathered</li>
+ </ul>
+ </td>
+ <td>
+ <div>Desired end state of the configuration.</div>
+ </td>
+ </tr>
+ </table>
+ <br/>
+
+
+
+
+Examples
+--------
+
+.. code-block:: yaml
+
+ - name: Merge VRRP configuration
+ vyos.rest.vyos_ha:
+ config:
+ vrrp:
+ global_parameters:
+ startup_delay: 30
+ groups:
+ - name: g1
+ interface: eth0
+ vrid: 20
+ priority: 100
+ address:
+ - 192.168.1.100/24
+ sync_groups:
+ - name: sg1
+ member: [g1]
+ snmp: enabled
+ state: merged
+
+ - name: Delete all HA configuration
+ vyos.rest.vyos_ha:
+ state: deleted
+
+ - name: Gather current HA configuration
+ vyos.rest.vyos_ha:
+ state: gathered
+
+
+
+Return Values
+-------------
+Common return values are documented `here <https://docs.ansible.com/ansible/latest/reference_appendices/common_return_values.html#common-return-values>`_, the following are the fields unique to this module:
+
+.. raw:: html
+
+ <table border=0 cellpadding=0 class="documentation-table">
+ <tr>
+ <th colspan="1">Key</th>
+ <th>Returned</th>
+ <th width="100%">Description</th>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="return-"></div>
+ <b>after</b>
+ <a class="ansibleOptionLink" href="#return-" title="Permalink to this return value"></a>
+ <div style="font-size: small">
+ <span style="color: purple">dictionary</span>
+ </div>
+ </td>
+ <td>when changed</td>
+ <td>
+ <div>HA configuration after this module ran.</div>
+ <br/>
+ </td>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="return-"></div>
+ <b>before</b>
+ <a class="ansibleOptionLink" href="#return-" title="Permalink to this return value"></a>
+ <div style="font-size: small">
+ <span style="color: purple">dictionary</span>
+ </div>
+ </td>
+ <td>always</td>
+ <td>
+ <div>HA configuration before this module ran.</div>
+ <br/>
+ </td>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="return-"></div>
+ <b>commands</b>
+ <a class="ansibleOptionLink" href="#return-" title="Permalink to this return value"></a>
+ <div style="font-size: small">
+ <span style="color: purple">list</span>
+ </div>
+ </td>
+ <td>always</td>
+ <td>
+ <div>List of API commands sent to the device.</div>
+ <br/>
+ </td>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="return-"></div>
+ <b>gathered</b>
+ <a class="ansibleOptionLink" href="#return-" title="Permalink to this return value"></a>
+ <div style="font-size: small">
+ <span style="color: purple">dictionary</span>
+ </div>
+ </td>
+ <td>when state is gathered</td>
+ <td>
+ <div>Current HA configuration as structured data.</div>
+ <br/>
+ </td>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="return-"></div>
+ <b>saved</b>
+ <a class="ansibleOptionLink" href="#return-" title="Permalink to this return value"></a>
+ <div style="font-size: small">
+ <span style="color: purple">boolean</span>
+ </div>
+ </td>
+ <td>when changed</td>
+ <td>
+ <div>Whether the config was saved after changes.</div>
+ <br/>
+ </td>
+ </tr>
+ </table>
+ <br/><br/>
+
+
+Status
+------
+
+
+Authors
+~~~~~~~
+
+- Evgeny Molotkov (@omnom62)
diff --git a/docs/vyos.rest.vyos_logging_global_module.rst b/docs/vyos.rest.vyos_logging_global_module.rst
index da5572e..b529dd0 100644
--- a/docs/vyos.rest.vyos_logging_global_module.rst
+++ b/docs/vyos.rest.vyos_logging_global_module.rst
@@ -18,7 +18,7 @@ Version added: 1.0.0
Synopsis
--------
- Manages syslog (logging) configuration on VyOS devices via the REST API.
-- Supports console, file, host, user, and global logging targets with per-target facility and severity configuration.
+- Targets VyOS 1.5+ syslog schema under ``system syslog``.
- Uses REST API (``connection=httpapi``) instead of CLI.
@@ -126,150 +126,6 @@ Parameters
<td class="elbow-placeholder"></td>
<td colspan="3">
<div class="ansibleOptionAnchor" id="parameter-"></div>
- <b>files</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>Logging to local files.</div>
- </td>
- </tr>
- <tr>
- <td class="elbow-placeholder"></td>
- <td class="elbow-placeholder"></td>
- <td colspan="2">
- <div class="ansibleOptionAnchor" id="parameter-"></div>
- <b>archive</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>Log file archive/rotation settings.</div>
- </td>
- </tr>
- <tr>
- <td class="elbow-placeholder"></td>
- <td class="elbow-placeholder"></td>
- <td class="elbow-placeholder"></td>
- <td colspan="1">
- <div class="ansibleOptionAnchor" id="parameter-"></div>
- <b>file_num</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>Number of archived log files to keep.</div>
- </td>
- </tr>
- <tr>
- <td class="elbow-placeholder"></td>
- <td class="elbow-placeholder"></td>
- <td class="elbow-placeholder"></td>
- <td colspan="1">
- <div class="ansibleOptionAnchor" id="parameter-"></div>
- <b>size</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 size of log file in kilobytes before rotation.</div>
- </td>
- </tr>
-
- <tr>
- <td class="elbow-placeholder"></td>
- <td class="elbow-placeholder"></td>
- <td colspan="2">
- <div class="ansibleOptionAnchor" id="parameter-"></div>
- <b>facilities</b>
- <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
- <div style="font-size: small">
- <span style="color: purple">list</span>
- / <span style="color: purple">elements=dictionary</span>
- </div>
- </td>
- <td>
- </td>
- <td>
- <div>List of syslog facilities to log to this file.</div>
- </td>
- </tr>
- <tr>
- <td class="elbow-placeholder"></td>
- <td class="elbow-placeholder"></td>
- <td class="elbow-placeholder"></td>
- <td colspan="1">
- <div class="ansibleOptionAnchor" id="parameter-"></div>
- <b>facility</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>Syslog facility name.</div>
- </td>
- </tr>
- <tr>
- <td class="elbow-placeholder"></td>
- <td class="elbow-placeholder"></td>
- <td class="elbow-placeholder"></td>
- <td colspan="1">
- <div class="ansibleOptionAnchor" id="parameter-"></div>
- <b>severity</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>Minimum severity level to log.</div>
- </td>
- </tr>
-
- <tr>
- <td class="elbow-placeholder"></td>
- <td class="elbow-placeholder"></td>
- <td colspan="2">
- <div class="ansibleOptionAnchor" id="parameter-"></div>
- <b>path</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>Path to the log file on the device.</div>
- </td>
- </tr>
-
- <tr>
- <td class="elbow-placeholder"></td>
- <td colspan="3">
- <div class="ansibleOptionAnchor" id="parameter-"></div>
<b>global_params</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
<div style="font-size: small">
@@ -279,66 +135,12 @@ Parameters
<td>
</td>
<td>
- <div>Global syslog parameters (maps to <code>system syslog global</code>).</div>
- </td>
- </tr>
- <tr>
- <td class="elbow-placeholder"></td>
- <td class="elbow-placeholder"></td>
- <td colspan="2">
- <div class="ansibleOptionAnchor" id="parameter-"></div>
- <b>archive</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>Global log archive/rotation settings.</div>
+ <div>Global syslog parameters (maps to <code>system syslog local</code> on device).</div>
</td>
</tr>
<tr>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td class="elbow-placeholder"></td>
- <td colspan="1">
- <div class="ansibleOptionAnchor" id="parameter-"></div>
- <b>file_num</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>Number of archived log files to keep.</div>
- </td>
- </tr>
- <tr>
- <td class="elbow-placeholder"></td>
- <td class="elbow-placeholder"></td>
- <td class="elbow-placeholder"></td>
- <td colspan="1">
- <div class="ansibleOptionAnchor" id="parameter-"></div>
- <b>size</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 size of log file in kilobytes before rotation.</div>
- </td>
- </tr>
-
- <tr>
- <td class="elbow-placeholder"></td>
- <td class="elbow-placeholder"></td>
<td colspan="2">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>facilities</b>
@@ -351,7 +153,7 @@ Parameters
<td>
</td>
<td>
- <div>List of syslog facilities for global logging.</div>
+ <div>List of syslog facilities for local logging.</div>
</td>
</tr>
<tr>
@@ -444,7 +246,7 @@ Parameters
<td>
</td>
<td>
- <div>Logging to remote syslog hosts.</div>
+ <div>Logging to remote syslog hosts (maps to <code>system syslog remote</code>).</div>
</td>
</tr>
<tr>
@@ -666,21 +468,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">
@@ -694,8 +481,6 @@ Parameters
<li>overridden</li>
<li>deleted</li>
<li>gathered</li>
- <li>rendered</li>
- <li>parsed</li>
</ul>
</td>
<td>
@@ -720,30 +505,18 @@ Examples
facilities:
- facility: local7
severity: err
- files:
- - path: logFile
- archive:
- file_num: 2
- facilities:
- - facility: local6
- severity: emerg
hosts:
- hostname: 172.16.0.1
- port: 223
+ port: 514
facilities:
- facility: local7
severity: all
- - facility: all
- protocol: udp
users:
- username: vyos
facilities:
- facility: local7
severity: debug
global_params:
- archive:
- file_num: 2
- size: 111
facilities:
- facility: cron
severity: debug
@@ -842,7 +615,7 @@ Common return values are documented `here <https://docs.ansible.com/ansible/late
<span style="color: purple">dictionary</span>
</div>
</td>
- <td>when changes are applied</td>
+ <td>when changed</td>
<td>
<div>Result of save_config after applying changes.</div>
<br/>
diff --git a/docs/vyos.rest.vyos_nat_module.rst b/docs/vyos.rest.vyos_nat_module.rst
new file mode 100644
index 0000000..1200416
--- /dev/null
+++ b/docs/vyos.rest.vyos_nat_module.rst
@@ -0,0 +1,208 @@
+.. _vyos.rest.vyos_nat_module:
+
+
+******************
+vyos.rest.vyos_nat
+******************
+
+**Manage NAT configuration on VyOS devices using REST API**
+
+
+Version added: 1.0.0
+
+.. contents::
+ :local:
+ :depth: 1
+
+
+Synopsis
+--------
+- Manages NAT configuration on VyOS devices via the REST API.
+- Supports source, destination, static, CGNAT, NAT64, and NAT66.
+- Uses REST API (``connection=httpapi``) instead of CLI.
+- Targets VyOS 1.5+.
+
+
+
+
+Parameters
+----------
+
+.. raw:: html
+
+ <table border=0 cellpadding=0 class="documentation-table">
+ <tr>
+ <th colspan="1">Parameter</th>
+ <th>Choices/<font color="blue">Defaults</font></th>
+ <th width="100%">Comments</th>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>config</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">dictionary</span>
+ </div>
+ </td>
+ <td>
+ </td>
+ <td>
+ <div>NAT configuration.</div>
+ </td>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>state</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">string</span>
+ </div>
+ </td>
+ <td>
+ <ul style="margin: 0; padding: 0"><b>Choices:</b>
+ <li><div style="color: blue"><b>merged</b>&nbsp;&larr;</div></li>
+ <li>replaced</li>
+ <li>overridden</li>
+ <li>deleted</li>
+ <li>gathered</li>
+ </ul>
+ </td>
+ <td>
+ <div>The desired state of the NAT configuration.</div>
+ </td>
+ </tr>
+ </table>
+ <br/>
+
+
+
+
+Examples
+--------
+
+.. code-block:: yaml
+
+ - name: Merge source NAT rule
+ vyos.rest.vyos_nat:
+ config:
+ nat:
+ source:
+ rule:
+ - id: 100
+ outbound_interface:
+ name: eth0
+ translation:
+ address: masquerade
+ state: merged
+
+ - name: Delete all NAT
+ vyos.rest.vyos_nat:
+ state: deleted
+
+ - name: Gather NAT configuration
+ vyos.rest.vyos_nat:
+ state: gathered
+
+
+
+Return Values
+-------------
+Common return values are documented `here <https://docs.ansible.com/ansible/latest/reference_appendices/common_return_values.html#common-return-values>`_, the following are the fields unique to this module:
+
+.. raw:: html
+
+ <table border=0 cellpadding=0 class="documentation-table">
+ <tr>
+ <th colspan="1">Key</th>
+ <th>Returned</th>
+ <th width="100%">Description</th>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="return-"></div>
+ <b>after</b>
+ <a class="ansibleOptionLink" href="#return-" title="Permalink to this return value"></a>
+ <div style="font-size: small">
+ <span style="color: purple">dictionary</span>
+ </div>
+ </td>
+ <td>when changed</td>
+ <td>
+ <div>NAT configuration after this module ran.</div>
+ <br/>
+ </td>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="return-"></div>
+ <b>before</b>
+ <a class="ansibleOptionLink" href="#return-" title="Permalink to this return value"></a>
+ <div style="font-size: small">
+ <span style="color: purple">dictionary</span>
+ </div>
+ </td>
+ <td>always</td>
+ <td>
+ <div>NAT configuration before this module ran.</div>
+ <br/>
+ </td>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="return-"></div>
+ <b>commands</b>
+ <a class="ansibleOptionLink" href="#return-" title="Permalink to this return value"></a>
+ <div style="font-size: small">
+ <span style="color: purple">list</span>
+ </div>
+ </td>
+ <td>always</td>
+ <td>
+ <div>List of API commands sent to the device.</div>
+ <br/>
+ </td>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="return-"></div>
+ <b>gathered</b>
+ <a class="ansibleOptionLink" href="#return-" title="Permalink to this return value"></a>
+ <div style="font-size: small">
+ <span style="color: purple">dictionary</span>
+ </div>
+ </td>
+ <td>when state is gathered</td>
+ <td>
+ <div>Current NAT configuration as structured data.</div>
+ <br/>
+ </td>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="return-"></div>
+ <b>saved</b>
+ <a class="ansibleOptionLink" href="#return-" title="Permalink to this return value"></a>
+ <div style="font-size: small">
+ <span style="color: purple">boolean</span>
+ </div>
+ </td>
+ <td>when changed</td>
+ <td>
+ <div>Whether the config was saved after changes.</div>
+ <br/>
+ </td>
+ </tr>
+ </table>
+ <br/><br/>
+
+
+Status
+------
+
+
+Authors
+~~~~~~~
+
+- Evgeny Molotkov (@omnom62)
diff --git a/docs/vyos.rest.vyos_ntp_global_module.rst b/docs/vyos.rest.vyos_ntp_global_module.rst
index 9643d5a..ba209a8 100644
--- a/docs/vyos.rest.vyos_ntp_global_module.rst
+++ b/docs/vyos.rest.vyos_ntp_global_module.rst
@@ -155,22 +155,6 @@ Parameters
<tr>
<td colspan="3">
<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>
- <div>Provide the output of <code>show configuration commands | grep ntp</code>.</div>
- </td>
- </tr>
- <tr>
- <td colspan="3">
- <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">
@@ -184,8 +168,6 @@ Parameters
<li>overridden</li>
<li>deleted</li>
<li>gathered</li>
- <li>rendered</li>
- <li>parsed</li>
</ul>
</td>
<td>
@@ -314,43 +296,13 @@ 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">dictionary</span>
- </div>
- </td>
- <td>when state is parsed</td>
- <td>
- <div>Structured data parsed from running_config.</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>CLI commands generated for the provided config (offline).</div>
- <br/>
- </td>
- </tr>
- <tr>
- <td colspan="1">
- <div class="ansibleOptionAnchor" id="return-"></div>
<b>saved</b>
<a class="ansibleOptionLink" href="#return-" title="Permalink to this return value"></a>
<div style="font-size: small">
<span style="color: purple">boolean</span>
</div>
</td>
- <td>when changes are applied</td>
+ <td>when changed</td>
<td>
<div>Whether the config was saved after changes.</div>
<br/>
diff --git a/docs/vyos.rest.vyos_route_maps_module.rst b/docs/vyos.rest.vyos_route_maps_module.rst
index 4fc0801..675b0af 100644
--- a/docs/vyos.rest.vyos_route_maps_module.rst
+++ b/docs/vyos.rest.vyos_route_maps_module.rst
@@ -19,6 +19,7 @@ Synopsis
--------
- Manages route maps on VyOS via the REST API.
- Uses REST API (``connection=httpapi``) instead of CLI.
+- Covers the commonly used match/set fields (as documented below). VyOS's route-map schema is considerably larger than this (EVPN attributes, extended communities, RPKI matching, on-match goto/next, route-source, source-peer, source-vrf, and more) -- those are not modeled by this module and are a real, documented limitation, not an oversight.
@@ -30,12 +31,12 @@ Parameters
<table border=0 cellpadding=0 class="documentation-table">
<tr>
- <th colspan="3">Parameter</th>
+ <th colspan="5">Parameter</th>
<th>Choices/<font color="blue">Defaults</font></th>
<th width="100%">Comments</th>
</tr>
<tr>
- <td colspan="3">
+ <td colspan="5">
<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="2">
+ <td colspan="4">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>entries</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="1">
+ <td colspan="3">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>action</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -79,6 +80,10 @@ Parameters
</div>
</td>
<td>
+ <ul style="margin: 0; padding: 0"><b>Choices:</b>
+ <li>permit</li>
+ <li>deny</li>
+ </ul>
</td>
<td>
<div>Permit or deny.</div>
@@ -87,7 +92,7 @@ Parameters
<tr>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="1">
+ <td colspan="3">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>call</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -104,7 +109,7 @@ Parameters
<tr>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="1">
+ <td colspan="3">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>continue_sequence</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -121,7 +126,7 @@ Parameters
<tr>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="1">
+ <td colspan="3">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>description</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -138,7 +143,7 @@ Parameters
<tr>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="1">
+ <td colspan="3">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>match</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -149,14 +154,255 @@ Parameters
<td>
</td>
<td>
- <div>Match conditions (passed through to VyOS API).</div>
+ <div>Match conditions.</div>
+ </td>
+ </tr>
+ <tr>
+ <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>interface</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">string</span>
+ </div>
+ </td>
+ <td>
+ </td>
+ <td>
+ <div>Interface to match.</div>
</td>
</tr>
<tr>
<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>ip</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>IPv4 next-hop match parameters.</div>
+ </td>
+ </tr>
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>nexthop_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>IPv4 next-hop address to match.</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="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>nexthop_prefix_list</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>IPv4 next-hop prefix-list to match.</div>
+ </td>
+ </tr>
+
+ <tr>
+ <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>ipv6</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>IPv6 next-hop match parameters.</div>
+ </td>
+ </tr>
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
<td colspan="1">
<div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>nexthop_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>IPv6 next-hop address to match.</div>
+ </td>
+ </tr>
+
+ <tr>
+ <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>metric</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>Metric of route to match.</div>
+ </td>
+ </tr>
+ <tr>
+ <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>origin</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">string</span>
+ </div>
+ </td>
+ <td>
+ <ul style="margin: 0; padding: 0"><b>Choices:</b>
+ <li>egp</li>
+ <li>igp</li>
+ <li>incomplete</li>
+ </ul>
+ </td>
+ <td>
+ <div>BGP origin code to match.</div>
+ </td>
+ </tr>
+ <tr>
+ <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>peer</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>Peer address to match.</div>
+ </td>
+ </tr>
+ <tr>
+ <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>prefix_list</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>IPv4 prefix-list to match.</div>
+ </td>
+ </tr>
+ <tr>
+ <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>prefix_list6</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>IPv6 prefix-list to match.</div>
+ </td>
+ </tr>
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td colspan="2">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>protocol</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">string</span>
+ </div>
+ </td>
+ <td>
+ <ul style="margin: 0; padding: 0"><b>Choices:</b>
+ <li>babel</li>
+ <li>bgp</li>
+ <li>connected</li>
+ <li>isis</li>
+ <li>kernel</li>
+ <li>ospf</li>
+ <li>ospfv3</li>
+ <li>rip</li>
+ <li>ripng</li>
+ <li>static</li>
+ <li>table</li>
+ <li>vnc</li>
+ </ul>
+ </td>
+ <td>
+ <div>Match protocol via which the route was learnt.</div>
+ </td>
+ </tr>
+
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td colspan="3">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
<b>sequence</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
<div style="font-size: small">
@@ -173,7 +419,7 @@ Parameters
<tr>
<td class="elbow-placeholder"></td>
<td class="elbow-placeholder"></td>
- <td colspan="1">
+ <td colspan="3">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>set</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -184,14 +430,657 @@ Parameters
<td>
</td>
<td>
- <div>Route parameters to set (passed through to VyOS API).</div>
+ <div>Route parameters to set.</div>
+ </td>
+ </tr>
+ <tr>
+ <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>aggregator</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">dictionary</span>
+ </div>
+ </td>
+ <td>
+ </td>
+ <td>
+ <div>BGP aggregator attribute.</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="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>as_</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">integer</span>
+ </div>
+ </td>
+ <td>
+ </td>
+ <td>
+ <div>AS number of an aggregation.</div>
+ <div style="font-size: small; color: darkgreen"><br/>aliases: as</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="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>ip</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>IP address of an aggregation.</div>
+ </td>
+ </tr>
+
+ <tr>
+ <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>as_path_exclude</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>AS number(s) to remove from the as-path attribute.</div>
+ </td>
+ </tr>
+ <tr>
+ <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>as_path_prepend</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>AS number(s) to prepend to the as-path attribute.</div>
+ </td>
+ </tr>
+ <tr>
+ <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>as_path_prepend_last_as</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">integer</span>
+ </div>
+ </td>
+ <td>
+ </td>
+ <td>
+ <div>Number of times to prepend the last AS number in the as-path.</div>
+ </td>
+ </tr>
+ <tr>
+ <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>atomic_aggregate</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>Set the BGP atomic aggregate attribute.</div>
+ </td>
+ </tr>
+ <tr>
+ <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>community</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">dictionary</span>
+ </div>
+ </td>
+ <td>
+ </td>
+ <td>
+ <div>BGP community attribute.</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="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>add</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>Communities to add to a prefix.</div>
+ </td>
+ </tr>
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>delete</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>Remove communities defined in a list from a prefix.</div>
+ </td>
+ </tr>
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>none</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>Completely remove the communities attribute from a prefix.</div>
+ </td>
+ </tr>
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>replace</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>Communities to set for a prefix.</div>
+ </td>
+ </tr>
+
+ <tr>
+ <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>distance</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">integer</span>
+ </div>
+ </td>
+ <td>
+ </td>
+ <td>
+ <div>Locally significant administrative distance.</div>
+ </td>
+ </tr>
+ <tr>
+ <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>ip_next_hop</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>IPv4 next-hop address to set.</div>
+ </td>
+ </tr>
+ <tr>
+ <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>ipv6_next_hop</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>IPv6 next-hop to set.</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="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>global</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>Nexthop IPv6 global address.</div>
+ </td>
+ </tr>
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>local</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>Nexthop IPv6 local address.</div>
+ </td>
+ </tr>
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>peer_address</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>Use the peer address (BGP only) as the nexthop.</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="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>prefer_global</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>Prefer the global address as the nexthop.</div>
</td>
</tr>
<tr>
<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>large_community</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">dictionary</span>
+ </div>
+ </td>
+ <td>
+ </td>
+ <td>
+ <div>BGP large community attribute.</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="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>add</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>Large communities to add to a prefix.</div>
+ </td>
+ </tr>
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>delete</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>Remove large communities defined in a list from a prefix.</div>
+ </td>
+ </tr>
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>none</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>Completely remove the large-community attribute from a prefix.</div>
+ </td>
+ </tr>
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td class="elbow-placeholder"></td>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>replace</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>Large communities to set for a prefix.</div>
+ </td>
+ </tr>
+
+ <tr>
+ <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>local_preference</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">integer</span>
+ </div>
+ </td>
+ <td>
+ </td>
+ <td>
+ <div>BGP local preference.</div>
+ </td>
+ </tr>
+ <tr>
+ <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>metric</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>Metric of route.</div>
+ </td>
+ </tr>
+ <tr>
+ <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>metric_type</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>Metric type.</div>
+ </td>
+ </tr>
+ <tr>
+ <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>origin</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">string</span>
+ </div>
+ </td>
+ <td>
+ <ul style="margin: 0; padding: 0"><b>Choices:</b>
+ <li>egp</li>
+ <li>igp</li>
+ <li>incomplete</li>
+ </ul>
+ </td>
+ <td>
+ <div>BGP origin code to set.</div>
+ </td>
+ </tr>
+ <tr>
+ <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>originator_id</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">string</span>
+ </div>
+ </td>
+ <td>
+ </td>
+ <td>
+ <div>BGP originator ID.</div>
+ </td>
+ </tr>
+ <tr>
+ <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>src</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>Source address for route.</div>
+ </td>
+ </tr>
+ <tr>
+ <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>table</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>Non-main kernel routing table.</div>
+ </td>
+ </tr>
+ <tr>
+ <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>tag</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>Route tag value.</div>
+ </td>
+ </tr>
+ <tr>
+ <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>weight</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">integer</span>
+ </div>
+ </td>
+ <td>
+ </td>
+ <td>
+ <div>BGP weight.</div>
+ </td>
+ </tr>
+
+
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td colspan="4">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
<b>route_map</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
<div style="font-size: small">
@@ -207,7 +1096,7 @@ Parameters
</tr>
<tr>
- <td colspan="3">
+ <td colspan="5">
<div class="ansibleOptionAnchor" id="parameter-"></div>
<b>state</b>
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
@@ -243,7 +1132,6 @@ Notes
.. note::
- Requires ``ansible_connection=httpapi`` with the VyOS httpapi plugin.
- ``ansible_network_os`` must be set to ``vyos.rest.vyos``.
- - Input validation is delegated to the VyOS API.
@@ -262,10 +1150,10 @@ Examples
match:
peer: 192.0.2.32
set:
- metric: "5"
+ metric: 5
as_path_exclude: "111"
aggregator:
- as: 100
+ as_: 100
state: merged
- name: Delete all route maps
@@ -365,7 +1253,7 @@ Common return values are documented `here <https://docs.ansible.com/ansible/late
<span style="color: purple">dictionary</span>
</div>
</td>
- <td>when changes are applied</td>
+ <td>always</td>
<td>
<div>Raw API response.</div>
<br/>
@@ -380,7 +1268,7 @@ Common return values are documented `here <https://docs.ansible.com/ansible/late
<span style="color: purple">boolean</span>
</div>
</td>
- <td>when changes are applied</td>
+ <td>when changed</td>
<td>
<div>Whether the config was saved after changes.</div>
<br/>
diff --git a/docs/vyos.rest.vyos_snmp_server_module.rst b/docs/vyos.rest.vyos_snmp_server_module.rst
index 29a64c6..d183f88 100644
--- a/docs/vyos.rest.vyos_snmp_server_module.rst
+++ b/docs/vyos.rest.vyos_snmp_server_module.rst
@@ -257,7 +257,7 @@ Parameters
<td>
</td>
<td>
- <div>Register a subtree for SMUX-based processing.</div>
+ <div>Register a subtree for SMUX-based processing. The device supports multiple values here; this module manages a single value only.</div>
</td>
</tr>
<tr>
@@ -421,6 +421,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>
@@ -907,7 +908,7 @@ Parameters
<td>
</td>
<td>
- <div>SNMPv3 view configuration.</div>
+ <div>SNMPv3 view configuration. The device supports multiple OIDs (each with its own exclude/mask) per view; this module manages a single OID entry per view only.</div>
</td>
</tr>
<tr>
@@ -1014,7 +1015,7 @@ Parameters
<td>
</td>
<td>
- <div>SNMP trap target.</div>
+ <div>SNMP (v2) trap target. The device supports multiple trap targets; this module manages a single one only.</div>
</td>
</tr>
<tr>
@@ -1026,6 +1027,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>
@@ -1192,7 +1194,7 @@ Common return values are documented `here <https://docs.ansible.com/ansible/late
</td>
<td>always</td>
<td>
- <div>List of API command dicts sent to the device.</div>
+ <div>List of API command tuples sent to the device.</div>
<br/>
</td>
</tr>
@@ -1220,7 +1222,7 @@ Common return values are documented `here <https://docs.ansible.com/ansible/late
<span style="color: purple">boolean</span>
</div>
</td>
- <td>when changes are applied</td>
+ <td>when changed</td>
<td>
<div>Whether the config was saved after changes.</div>
<br/>
diff --git a/docs/vyos.rest.vyos_system_module.rst b/docs/vyos.rest.vyos_system_module.rst
new file mode 100644
index 0000000..0aa5f4f
--- /dev/null
+++ b/docs/vyos.rest.vyos_system_module.rst
@@ -0,0 +1,254 @@
+.. _vyos.rest.vyos_system_module:
+
+
+*********************
+vyos.rest.vyos_system
+*********************
+
+**Manage system settings on VyOS devices using REST API**
+
+
+Version added: 1.0.0
+
+.. contents::
+ :local:
+ :depth: 1
+
+
+Synopsis
+--------
+- Manages basic system settings on VyOS devices via the REST API.
+- Uses REST API (``connection=httpapi``) instead of CLI.
+
+
+
+
+Parameters
+----------
+
+.. raw:: html
+
+ <table border=0 cellpadding=0 class="documentation-table">
+ <tr>
+ <th colspan="1">Parameter</th>
+ <th>Choices/<font color="blue">Defaults</font></th>
+ <th width="100%">Comments</th>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>domain_name</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>Device domain name.</div>
+ </td>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>domain_search</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>List of domain search suffixes.</div>
+ </td>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>host_name</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>Device hostname.</div>
+ </td>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>name_server</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>List of DNS name servers.</div>
+ <div style="font-size: small; color: darkgreen"><br/>aliases: name_servers</div>
+ </td>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>state</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">string</span>
+ </div>
+ </td>
+ <td>
+ <ul style="margin: 0; padding: 0"><b>Choices:</b>
+ <li><div style="color: blue"><b>present</b>&nbsp;&larr;</div></li>
+ <li>absent</li>
+ </ul>
+ </td>
+ <td>
+ <div><code>present</code> applies the configuration.</div>
+ <div><code>absent</code> removes the configuration.</div>
+ </td>
+ </tr>
+ </table>
+ <br/>
+
+
+Notes
+-----
+
+.. note::
+ - Requires ``ansible_connection=httpapi`` with the VyOS httpapi plugin.
+ - ``ansible_network_os`` must be set to ``vyos.rest.vyos``.
+
+
+
+Examples
+--------
+
+.. code-block:: yaml
+
+ - name: Configure hostname and domain
+ vyos.rest.vyos_system:
+ host_name: router1
+ domain_name: example.com
+ name_server:
+ - 8.8.8.8
+ - 8.8.4.4
+ state: present
+
+ - name: Remove domain name and name servers
+ vyos.rest.vyos_system:
+ domain_name: example.com
+ name_server:
+ - 8.8.8.8
+ state: absent
+
+
+
+Return Values
+-------------
+Common return values are documented `here <https://docs.ansible.com/ansible/latest/reference_appendices/common_return_values.html#common-return-values>`_, the following are the fields unique to this module:
+
+.. raw:: html
+
+ <table border=0 cellpadding=0 class="documentation-table">
+ <tr>
+ <th colspan="1">Key</th>
+ <th>Returned</th>
+ <th width="100%">Description</th>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="return-"></div>
+ <b>after</b>
+ <a class="ansibleOptionLink" href="#return-" title="Permalink to this return value"></a>
+ <div style="font-size: small">
+ <span style="color: purple">dictionary</span>
+ </div>
+ </td>
+ <td>when changed</td>
+ <td>
+ <div>Module-owned system configuration after this module ran.</div>
+ <br/>
+ </td>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="return-"></div>
+ <b>before</b>
+ <a class="ansibleOptionLink" href="#return-" title="Permalink to this return value"></a>
+ <div style="font-size: small">
+ <span style="color: purple">dictionary</span>
+ </div>
+ </td>
+ <td>always</td>
+ <td>
+ <div>Module-owned system configuration before this module ran.</div>
+ <br/>
+ </td>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="return-"></div>
+ <b>commands</b>
+ <a class="ansibleOptionLink" href="#return-" title="Permalink to this return value"></a>
+ <div style="font-size: small">
+ <span style="color: purple">list</span>
+ </div>
+ </td>
+ <td>always</td>
+ <td>
+ <div>List of API command tuples sent to the device.</div>
+ <br/>
+ </td>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="return-"></div>
+ <b>response</b>
+ <a class="ansibleOptionLink" href="#return-" title="Permalink to this return value"></a>
+ <div style="font-size: small">
+ <span style="color: purple">dictionary</span>
+ </div>
+ </td>
+ <td>always</td>
+ <td>
+ <div>Raw API response.</div>
+ <br/>
+ </td>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="return-"></div>
+ <b>saved</b>
+ <a class="ansibleOptionLink" href="#return-" title="Permalink to this return value"></a>
+ <div style="font-size: small">
+ <span style="color: purple">boolean</span>
+ </div>
+ </td>
+ <td>when changed</td>
+ <td>
+ <div>Whether the config was saved after changes.</div>
+ <br/>
+ </td>
+ </tr>
+ </table>
+ <br/><br/>
+
+
+Status
+------
+
+
+Authors
+~~~~~~~
+
+- VyOS Community (@vyos)
diff --git a/docs/vyos.rest.vyos_user_module.rst b/docs/vyos.rest.vyos_user_module.rst
index 1ad08f4..8cf2cda 100644
--- a/docs/vyos.rest.vyos_user_module.rst
+++ b/docs/vyos.rest.vyos_user_module.rst
@@ -358,7 +358,7 @@ Common return values are documented `here <https://docs.ansible.com/ansible/late
<span style="color: purple">dictionary</span>
</div>
</td>
- <td>when changes are applied</td>
+ <td>always</td>
<td>
<div>Raw API response.</div>
<br/>
@@ -373,7 +373,7 @@ Common return values are documented `here <https://docs.ansible.com/ansible/late
<span style="color: purple">boolean</span>
</div>
</td>
- <td>when changes are applied</td>
+ <td>when changed</td>
<td>
<div>Whether the config was saved after changes.</div>
<br/>
diff --git a/docs/vyos.rest.vyos_vlan_module.rst b/docs/vyos.rest.vyos_vlan_module.rst
new file mode 100644
index 0000000..06ca1ab
--- /dev/null
+++ b/docs/vyos.rest.vyos_vlan_module.rst
@@ -0,0 +1,303 @@
+.. _vyos.rest.vyos_vlan_module:
+
+
+*******************
+vyos.rest.vyos_vlan
+*******************
+
+**Manage VLAN (vif) configuration on VyOS devices using REST API**
+
+
+Version added: 1.0.0
+
+.. contents::
+ :local:
+ :depth: 1
+
+
+Synopsis
+--------
+- Manages VLAN sub-interface configuration on VyOS Ethernet interfaces via the REST API.
+- Uses REST API (``connection=httpapi``) instead of CLI.
+
+
+
+
+Parameters
+----------
+
+.. raw:: html
+
+ <table border=0 cellpadding=0 class="documentation-table">
+ <tr>
+ <th colspan="2">Parameter</th>
+ <th>Choices/<font color="blue">Defaults</font></th>
+ <th width="100%">Comments</th>
+ </tr>
+ <tr>
+ <td colspan="2">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>config</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">list</span>
+ / <span style="color: purple">elements=dictionary</span>
+ </div>
+ </td>
+ <td>
+ </td>
+ <td>
+ <div>List of VLAN configurations.</div>
+ </td>
+ </tr>
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>address</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">string</span>
+ </div>
+ </td>
+ <td>
+ </td>
+ <td>
+ <div>IP address for the VLAN interface.</div>
+ </td>
+ </tr>
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>description</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">string</span>
+ </div>
+ </td>
+ <td>
+ </td>
+ <td>
+ <div>VLAN description.</div>
+ </td>
+ </tr>
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>interfaces</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>
+ / <span style="color: red">required</span>
+ </div>
+ </td>
+ <td>
+ </td>
+ <td>
+ <div>List of Ethernet interfaces to configure this VLAN on.</div>
+ </td>
+ </tr>
+ <tr>
+ <td class="elbow-placeholder"></td>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>vlan_id</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">integer</span>
+ / <span style="color: red">required</span>
+ </div>
+ </td>
+ <td>
+ </td>
+ <td>
+ <div>VLAN ID (0-4094).</div>
+ </td>
+ </tr>
+
+ <tr>
+ <td colspan="2">
+ <div class="ansibleOptionAnchor" id="parameter-"></div>
+ <b>state</b>
+ <a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
+ <div style="font-size: small">
+ <span style="color: purple">string</span>
+ </div>
+ </td>
+ <td>
+ <ul style="margin: 0; padding: 0"><b>Choices:</b>
+ <li><div style="color: blue"><b>present</b>&nbsp;&larr;</div></li>
+ <li>absent</li>
+ <li>gathered</li>
+ </ul>
+ </td>
+ <td>
+ <div><code>present</code> creates or updates VLANs.</div>
+ <div><code>absent</code> removes VLANs.</div>
+ <div><code>gathered</code> returns current VLAN configuration.</div>
+ </td>
+ </tr>
+ </table>
+ <br/>
+
+
+Notes
+-----
+
+.. note::
+ - Requires ``ansible_connection=httpapi`` with the VyOS httpapi plugin.
+ - ``ansible_network_os`` must be set to ``vyos.rest.vyos``.
+
+
+
+Examples
+--------
+
+.. code-block:: yaml
+
+ - name: Configure VLANs
+ vyos.rest.vyos_vlan:
+ config:
+ - vlan_id: 10
+ description: VLAN10
+ address: 192.168.10.1/24
+ interfaces:
+ - eth1
+ - vlan_id: 20
+ description: VLAN20
+ interfaces:
+ - eth1
+ - eth2
+ state: present
+
+ - name: Remove a VLAN
+ vyos.rest.vyos_vlan:
+ config:
+ - vlan_id: 10
+ interfaces:
+ - eth1
+ state: absent
+
+ - name: Gather VLAN configuration
+ vyos.rest.vyos_vlan:
+ state: gathered
+
+
+
+Return Values
+-------------
+Common return values are documented `here <https://docs.ansible.com/ansible/latest/reference_appendices/common_return_values.html#common-return-values>`_, the following are the fields unique to this module:
+
+.. raw:: html
+
+ <table border=0 cellpadding=0 class="documentation-table">
+ <tr>
+ <th colspan="1">Key</th>
+ <th>Returned</th>
+ <th width="100%">Description</th>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="return-"></div>
+ <b>after</b>
+ <a class="ansibleOptionLink" href="#return-" title="Permalink to this return value"></a>
+ <div style="font-size: small">
+ <span style="color: purple">list</span>
+ </div>
+ </td>
+ <td>when changed</td>
+ <td>
+ <div>VLAN configuration after this module ran.</div>
+ <br/>
+ </td>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="return-"></div>
+ <b>before</b>
+ <a class="ansibleOptionLink" href="#return-" title="Permalink to this return value"></a>
+ <div style="font-size: small">
+ <span style="color: purple">list</span>
+ </div>
+ </td>
+ <td>always</td>
+ <td>
+ <div>VLAN configuration before this module ran.</div>
+ <br/>
+ </td>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="return-"></div>
+ <b>commands</b>
+ <a class="ansibleOptionLink" href="#return-" title="Permalink to this return value"></a>
+ <div style="font-size: small">
+ <span style="color: purple">list</span>
+ </div>
+ </td>
+ <td>always</td>
+ <td>
+ <div>List of API command tuples sent to the device.</div>
+ <br/>
+ </td>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="return-"></div>
+ <b>gathered</b>
+ <a class="ansibleOptionLink" href="#return-" title="Permalink to this return value"></a>
+ <div style="font-size: small">
+ <span style="color: purple">list</span>
+ </div>
+ </td>
+ <td>when state is gathered</td>
+ <td>
+ <div>Current VLAN configuration as structured data.</div>
+ <br/>
+ </td>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="return-"></div>
+ <b>response</b>
+ <a class="ansibleOptionLink" href="#return-" title="Permalink to this return value"></a>
+ <div style="font-size: small">
+ <span style="color: purple">dictionary</span>
+ </div>
+ </td>
+ <td>always</td>
+ <td>
+ <div>Raw API response.</div>
+ <br/>
+ </td>
+ </tr>
+ <tr>
+ <td colspan="1">
+ <div class="ansibleOptionAnchor" id="return-"></div>
+ <b>saved</b>
+ <a class="ansibleOptionLink" href="#return-" title="Permalink to this return value"></a>
+ <div style="font-size: small">
+ <span style="color: purple">boolean</span>
+ </div>
+ </td>
+ <td>when changed</td>
+ <td>
+ <div>Whether the config was saved after changes.</div>
+ <br/>
+ </td>
+ </tr>
+ </table>
+ <br/><br/>
+
+
+Status
+------
+
+
+Authors
+~~~~~~~
+
+- VyOS Community (@vyos)
diff --git a/plugins/modules/vyos_bgp_address_family.py b/plugins/modules/vyos_bgp_address_family.py
index 65f5810..b745bd5 100644
--- a/plugins/modules/vyos_bgp_address_family.py
+++ b/plugins/modules/vyos_bgp_address_family.py
@@ -234,24 +234,101 @@ gathered:
type: dict
saved:
description: Whether the config was saved after changes.
- returned: when changes are applied
+ returned: when changed
type: bool
response:
description: Raw API response.
- returned: when changes are applied
+ returned: always
type: dict
"""
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,
+ normalize_have,
+)
_BASE = ["protocols", "bgp"]
_AFI_MAP = {"ipv4": "ipv4-unicast", "ipv6": "ipv6-unicast"}
-_AFI_RMAP = {"ipv4-unicast": "ipv4", "ipv6-unicast": "ipv6"}
+_AFI_RMAP = {v: k for k, v in _AFI_MAP.items()}
+
+# Tag-node keys whose value dict_op must always see as a dict, never a
+# bare string/list -- VyOS's REST API collapses a single-child tag node
+# to a plain string (or a list for multiple), exactly like it does for
+# ordinary list leaves (see dict_op's own str->list coercion for that
+# case). Only genuine tag nodes with no other structure need this.
+_AF_TAG_KEYS = {"network", "redistribute"}
+
+# The only neighbor-AF options whose device shape isn't a direct
+# structural match for their argspec type. Every other key in this
+# level's argspec passes through autoclean()/from_device() untouched.
+_NEIGHBOR_AF_IRREGULAR = {"afi", "allowas_in", "capability", "soft_reconfiguration"}
+
+
+# ---------------------------------------------------------------------------
+# want -> device: structural reshaping only (networks/redistribute keyed
+# by prefix/protocol, AFI abbreviation, the 3 irregular neighbor-AF
+# options). Everything else is autoclean -- no field-name mapping.
+# ---------------------------------------------------------------------------
+
+
+def _global_af_to_device(af_list):
+ result = {}
+ for af in af_list or []:
+ entry = {}
+ networks = af.get("networks") or []
+ if networks:
+ entry["network"] = {
+ n["prefix"]: autoclean({k: v for k, v in n.items() if k != "prefix"})
+ for n in networks
+ }
+ redistribute = af.get("redistribute") or []
+ if redistribute:
+ entry["redistribute"] = {
+ r["protocol"]: autoclean({k: v for k, v in r.items() if k != "protocol"})
+ for r in redistribute
+ }
+ result[_AFI_MAP[af["afi"]]] = entry
+ return result
+
+
+def _neighbor_af_to_device(af_list):
+ result = {}
+ for af in af_list or []:
+ entry = autoclean({k: v for k, v in af.items() if k not in _NEIGHBOR_AF_IRREGULAR})
+
+ # allowas-in is a container node ({"number": N}), not a bare scalar.
+ if af.get("allowas_in") is not None:
+ entry["allowas_in"] = {"number": af["allowas_in"]}
+
+ # capability.orf: the chosen value becomes a dict KEY, not a leaf
+ # value (confirmed against vyos-1x: afi-capability-orf.xml.i).
+ orf = (af.get("capability") or {}).get("orf")
+ if orf:
+ entry["capability"] = {"orf": {"prefix-list": {orf: {}}}}
+
+ # soft_reconfiguration is a two-level presence node, not a flat one.
+ if af.get("soft_reconfiguration"):
+ entry["soft_reconfiguration"] = {"inbound": {}}
+
+ result[_AFI_MAP[af["afi"]]] = entry
+ return result
+
+
+# ---------------------------------------------------------------------------
+# device -> argspec (public have/gathered output)
+# ---------------------------------------------------------------------------
+_GLOBAL_AF_OPTIONS = None # populated after ARGUMENT_SPEC is defined below
+_NEIGHBOR_AF_OPTIONS = None
-def _parse_global_af(raw_afs):
+
+def _global_af_from_device(raw_afs):
if not raw_afs or not isinstance(raw_afs, dict):
return []
result = []
@@ -259,32 +336,28 @@ def _parse_global_af(raw_afs):
afi = _AFI_RMAP.get(af_key)
if not afi:
continue
- af_data = af_data or {}
- entry = {"afi": afi}
-
- nets = af_data.get("network", {})
- if nets and isinstance(nets, dict):
- entry["networks"] = [{"prefix": p} for p in sorted(nets.keys())]
-
- redist = af_data.get("redistribute", {})
- if redist and isinstance(redist, dict):
- redist_list = []
- for proto, rdata in sorted(redist.items()):
- r = {"protocol": proto}
- rdata = rdata or {}
- if "metric" in rdata:
- r["metric"] = int(rdata["metric"])
- if "route-map" in rdata:
- r["route_map"] = rdata["route-map"]
- redist_list.append(r)
- if redist_list:
- entry["redistribute"] = redist_list
-
+ af_data = dict(af_data or {})
+ networks_raw = af_data.pop("network", None) or {}
+ redistribute_raw = af_data.pop("redistribute", None) or {}
+
+ entry = {"afi": afi, **from_device(af_data)}
+ if networks_raw:
+ entry["networks"] = [
+ {"prefix": prefix, **from_device(data or {})}
+ for prefix, data in sorted(networks_raw.items())
+ ]
+ if redistribute_raw:
+ entry["redistribute"] = [
+ {"protocol": proto, **from_device(data or {})}
+ for proto, data in sorted(redistribute_raw.items())
+ ]
+
+ cast_by_spec(entry, _GLOBAL_AF_OPTIONS)
result.append(entry)
return result
-def _parse_neighbor_af(raw_afs):
+def _neighbor_af_from_device(raw_afs):
if not raw_afs or not isinstance(raw_afs, dict):
return []
result = []
@@ -292,71 +365,45 @@ def _parse_neighbor_af(raw_afs):
afi = _AFI_RMAP.get(af_key)
if not afi:
continue
- af_data = af_data or {}
- entry = {"afi": afi}
-
- if "nexthop-self" in af_data:
- entry["nexthop_self"] = True
- if "route-reflector-client" in af_data:
- entry["route_reflector_client"] = True
- if "route-server-client" in af_data:
- entry["route_server_client"] = True
- if "default-originate" in af_data:
- entry["default_originate"] = True
- if "maximum-prefix" in af_data:
- entry["maximum_prefix"] = int(af_data["maximum-prefix"])
- if "weight" in af_data:
- entry["weight"] = int(af_data["weight"])
- if "unsuppress-map" in af_data:
- entry["unsuppress_map"] = af_data["unsuppress-map"]
- if "allowas-in" in af_data:
- ai = af_data["allowas-in"]
- if isinstance(ai, dict) and "number" in ai:
- entry["allowas_in"] = int(ai["number"])
- else:
- entry["allowas_in"] = 1
-
- sc = af_data.get("soft-reconfiguration", {})
- if sc and "inbound" in sc:
- entry["soft_reconfiguration"] = True
+ af_data = dict(af_data or {})
+ allowas = af_data.pop("allowas-in", None)
+ orf = ((af_data.pop("capability", None) or {}).get("orf") or {}).get("prefix-list") or {}
+ soft = af_data.pop("soft-reconfiguration", None)
- rm = af_data.get("route-map", {})
- if rm:
- entry["route_map"] = {}
- if "import" in rm:
- entry["route_map"]["import"] = rm["import"]
- if "export" in rm:
- entry["route_map"]["export"] = rm["export"]
-
- pl = af_data.get("prefix-list", {})
- if pl:
- entry["prefix_list"] = {}
- if "import" in pl:
- entry["prefix_list"]["import"] = pl["import"]
- if "export" in pl:
- entry["prefix_list"]["export"] = pl["export"]
+ entry = {"afi": afi, **from_device(af_data)}
+ cast_by_spec(entry, _NEIGHBOR_AF_OPTIONS)
+
+ if isinstance(allowas, dict) and "number" in allowas:
+ entry["allowas_in"] = int(allowas["number"])
+ elif allowas is not None:
+ entry["allowas_in"] = 1
+
+ if "receive" in orf:
+ entry["capability"] = {"orf": "receive"}
+ elif "send" in orf:
+ entry["capability"] = {"orf": "send"}
+
+ if isinstance(soft, dict) and "inbound" in soft:
+ entry["soft_reconfiguration"] = True
result.append(entry)
return result
-def get_running_config(vyos):
- raw = vyos.get_config(_BASE)
+def _device_to_argspec(raw):
if not raw or not isinstance(raw, dict):
return {}
result = {}
-
if "system-as" in raw:
result["as_number"] = int(raw["system-as"])
- global_afs = _parse_global_af(raw.get("address-family"))
+ global_afs = _global_af_from_device(raw.get("address-family"))
if global_afs:
result["address_family"] = global_afs
neighbors = []
for nb_id, nb_data in sorted((raw.get("neighbor") or {}).items()):
- nb_data = nb_data or {}
- nb_afs = _parse_neighbor_af(nb_data.get("address-family"))
+ nb_afs = _neighbor_af_from_device((nb_data or {}).get("address-family"))
if nb_afs:
neighbors.append({"neighbor_address": nb_id, "address_family": nb_afs})
if neighbors:
@@ -365,119 +412,60 @@ def get_running_config(vyos):
return result
-def _global_af_cmds(af, have_af):
- cmds = []
- afi = af["afi"]
- af_key = _AFI_MAP[afi]
- abase = _BASE + ["address-family", af_key]
- have_af = have_af or {}
-
- want_nets = {n["prefix"]: n for n in (af.get("networks") or [])}
- have_nets = {n["prefix"]: n for n in (have_af.get("networks") or [])}
- for prefix in want_nets:
- if prefix not in have_nets:
- cmds.append(("set", abase + ["network", prefix]))
-
- want_redist = {r["protocol"]: r for r in (af.get("redistribute") or [])}
- have_redist = {r["protocol"]: r for r in (have_af.get("redistribute") or [])}
- for proto, entry in want_redist.items():
- have_entry = have_redist.get(proto, {})
- rbase = abase + ["redistribute", proto]
- if proto not in have_redist:
- 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("route_map") and entry["route_map"] != have_entry.get("route_map"):
- cmds.append(("set", rbase + ["route-map", entry["route_map"]]))
-
- return cmds
-
-
-def _neighbor_af_cmds(nb_addr, af, have_af):
- cmds = []
- afi = af["afi"]
- af_key = _AFI_MAP[afi]
- nbase = _BASE + ["neighbor", nb_addr, "address-family", af_key]
- have_af = have_af or {}
-
- if af.get("soft_reconfiguration") and not have_af.get("soft_reconfiguration"):
- cmds.append(("set", nbase + ["soft-reconfiguration", "inbound"]))
- if af.get("nexthop_self") and not have_af.get("nexthop_self"):
- cmds.append(("set", nbase + ["nexthop-self"]))
- if af.get("route_reflector_client") and not have_af.get("route_reflector_client"):
- cmds.append(("set", nbase + ["route-reflector-client"]))
- if af.get("route_server_client") and not have_af.get("route_server_client"):
- cmds.append(("set", nbase + ["route-server-client"]))
- if af.get("default_originate") and not have_af.get("default_originate"):
- cmds.append(("set", nbase + ["default-originate"]))
- if af.get("maximum_prefix") and af["maximum_prefix"] != have_af.get("maximum_prefix"):
- cmds.append(("set", nbase + ["maximum-prefix", str(af["maximum_prefix"])]))
- if af.get("weight") and af["weight"] != have_af.get("weight"):
- cmds.append(("set", nbase + ["weight", str(af["weight"])]))
- if af.get("allowas_in") and af["allowas_in"] != have_af.get("allowas_in"):
- cmds.append(("set", nbase + ["allowas-in", "number", str(af["allowas_in"])]))
- if af.get("unsuppress_map") and af["unsuppress_map"] != have_af.get("unsuppress_map"):
- cmds.append(("set", nbase + ["unsuppress-map", af["unsuppress_map"]]))
-
- want_rm = af.get("route_map") or {}
- have_rm = have_af.get("route_map") or {}
- if want_rm.get("import") and want_rm["import"] != have_rm.get("import"):
- cmds.append(("set", nbase + ["route-map", "import", want_rm["import"]]))
- if want_rm.get("export") and want_rm["export"] != have_rm.get("export"):
- cmds.append(("set", nbase + ["route-map", "export", want_rm["export"]]))
-
- want_pl = af.get("prefix_list") or {}
- have_pl = have_af.get("prefix_list") or {}
- if want_pl.get("import") and want_pl["import"] != have_pl.get("import"):
- cmds.append(("set", nbase + ["prefix-list", "import", want_pl["import"]]))
- if want_pl.get("export") and want_pl["export"] != have_pl.get("export"):
- cmds.append(("set", nbase + ["prefix-list", "export", want_pl["export"]]))
-
- return cmds
-
-
-def build_commands(config, have, state):
- cmds = []
+def get_running_config(vyos):
+ return vyos.get_config(_BASE) or {}
+
+
+# ---------------------------------------------------------------------------
+# Command building — dict_op scoped per owned subtree.
+#
+# "protocols bgp" is a shared root owned jointly with vyos_bgp_global, so
+# every dict_op call here is scoped to a subtree this module fully owns
+# (global address-family, or one neighbor's address-family) — never the
+# shared root, and never a whole "neighbor.<addr>" entry (which also
+# holds remote-as/timers/password etc. that belong to other modules).
+# ---------------------------------------------------------------------------
+
+
+def build_commands(config, raw_have, state):
config = config or {}
+ raw_have = raw_have or {}
+ commands = []
- if state == "deleted":
- if have.get("address_family"):
- cmds.append(("delete", _BASE + ["address-family"]))
- for nb in have.get("neighbors") or []:
- path = _BASE + ["neighbor", nb["neighbor_address"], "address-family"]
- cmds.append(("delete", path))
- return cmds
+ global_af_base = _BASE + ["address-family"]
+ raw_global_af = raw_have.get("address-family") or {}
+ raw_neighbors = raw_have.get("neighbor") or {}
- if state == "replaced":
- would_set = build_commands(config, {}, "merged")
- have_set = build_commands(have, {}, "merged")
- if would_set == have_set:
- return []
- if have.get("address_family"):
- cmds.append(("delete", _BASE + ["address-family"]))
- for nb in have.get("neighbors") or []:
- path = _BASE + ["neighbor", nb["neighbor_address"], "address-family"]
- cmds.append(("delete", path))
- have = {}
-
- # global address-family
- have_global_af_map = {af["afi"]: af for af in (have.get("address_family") or [])}
- for af in config.get("address_family") or []:
- cmds += _global_af_cmds(af, have_global_af_map.get(af["afi"]))
-
- # per-neighbor address-family
- have_nb_map = {
- n["neighbor_address"]: {af["afi"]: af for af in n.get("address_family", [])}
- for n in (have.get("neighbors") or [])
+ want_global_af = _global_af_to_device(config.get("address_family") or [])
+ want_neighbors = {
+ nb["neighbor_address"]: _neighbor_af_to_device(nb.get("address_family") or [])
+ for nb in (config.get("neighbors") or [])
}
- for nb in config.get("neighbors") or []:
- nb_addr = nb["neighbor_address"]
- have_nb_afs = have_nb_map.get(nb_addr, {})
- for af in nb.get("address_family") or []:
- cmds += _neighbor_af_cmds(nb_addr, af, have_nb_afs.get(af["afi"]))
+ if state == "deleted":
+ if raw_global_af:
+ commands.append(("delete", global_af_base))
+ for nb_addr, nb_data in sorted(raw_neighbors.items()):
+ if (nb_data or {}).get("address-family"):
+ commands.append(("delete", _BASE + ["neighbor", nb_addr, "address-family"]))
+ return commands
+
+ norm_global_af = normalize_have(raw_global_af, _AF_TAG_KEYS)
+ if state == "replaced":
+ commands += dict_op(want_global_af, norm_global_af, global_af_base, op="purge")
+ commands += dict_op(want_global_af, norm_global_af, global_af_base, op="set")
+
+ for nb_addr in sorted(set(want_neighbors) | set(raw_neighbors)):
+ nb_base = _BASE + ["neighbor", nb_addr, "address-family"]
+ raw_nb_af = (raw_neighbors.get(nb_addr) or {}).get("address-family") or {}
+ norm_nb_af = normalize_have(raw_nb_af, _AF_TAG_KEYS)
+ want_nb_af = want_neighbors.get(nb_addr, {})
- return cmds
+ if state == "replaced":
+ commands += dict_op(want_nb_af, norm_nb_af, nb_base, op="purge")
+ commands += dict_op(want_nb_af, norm_nb_af, nb_base, op="set")
+
+ return commands
ARGUMENT_SPEC = dict(
@@ -594,6 +582,14 @@ ARGUMENT_SPEC = dict(
),
)
+# Populated post-definition to avoid forward-reference ordering issues;
+# these back cast_by_spec so have-side int leaves are derived from the
+# spec itself rather than a hand-maintained field list.
+_GLOBAL_AF_OPTIONS = ARGUMENT_SPEC["config"]["options"]["address_family"]["options"]
+_NEIGHBOR_AF_OPTIONS = ARGUMENT_SPEC["config"]["options"]["neighbors"]["options"]["address_family"][
+ "options"
+]
+
def main():
module = AnsibleModule(ARGUMENT_SPEC, supports_check_mode=True)
@@ -602,12 +598,13 @@ 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)
if state == "gathered":
- module.exit_json(changed=False, gathered=have)
+ module.exit_json(changed=False, gathered=have, commands=[])
- 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)
@@ -618,7 +615,7 @@ def main():
module.exit_json(
changed=True,
before=have,
- after=get_running_config(vyos),
+ after=_device_to_argspec(get_running_config(vyos)),
commands=commands,
saved=saved,
response=response,
diff --git a/plugins/modules/vyos_bgp_global.py b/plugins/modules/vyos_bgp_global.py
index a05e3b0..50d7a87 100644
--- a/plugins/modules/vyos_bgp_global.py
+++ b/plugins/modules/vyos_bgp_global.py
@@ -205,254 +205,197 @@ gathered:
type: dict
saved:
description: Whether the config was saved after changes.
- returned: when changes are applied
+ returned: when changed
type: bool
response:
description: Raw API response.
- returned: when changes are applied
+ returned: always
type: dict
"""
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,
+ normalize_have,
+ scope_to_spec,
+)
_BASE = ["protocols", "bgp"]
+# "neighbor" and "peer-group" are genuine tag nodes (like network/
+# redistribute in vyos_bgp_address_family) that VyOS's REST API can
+# collapse to a bare string for a single entry with no other config.
+_TAG_KEYS = {"neighbor", "peer-group"}
-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 "log-neighbor-changes" in raw:
- result["log_neighbor_changes"] = True
- if "no-ipv4-unicast" in raw:
- result["no_ipv4_unicast"] = True
- if "graceful-restart" in raw:
- result["graceful_restart"] = True
- bp = raw.get("bestpath", {}) or {}
- if bp:
- bestpath = {}
- if "as-path" in bp:
- bestpath["as_path"] = bp["as-path"]
- if bestpath:
- result["bestpath"] = bestpath
- conf = raw.get("confederation", {}) or {}
- if conf:
- confederation = {}
- if "identifier" in conf:
- confederation["identifier"] = int(conf["identifier"])
- if "peers" in conf:
- peers = conf["peers"]
- if isinstance(peers, list):
- confederation["peers"] = [int(p) for p in peers]
- else:
- confederation["peers"] = [int(peers)]
- if confederation:
- result["confederation"] = confederation
+
+# ---------------------------------------------------------------------------
+# want -> device / device -> argspec
+#
+# Every leaf here is a direct structural match between argspec and device
+# shape (unlike vyos_bgp_address_family, this module has zero device-shape
+# exceptions) -- only the two tag-node reshapes (neighbors keyed by
+# address, peer_groups keyed by name) are unavoidable structural work.
+# ---------------------------------------------------------------------------
+
+
+def _neighbors_to_device(neighbors):
+ return {
+ nb["neighbor_address"]: autoclean(
+ {k: v for k, v in nb.items() if k != "neighbor_address"},
+ )
+ for nb in neighbors or []
+ }
+
+
+def _neighbors_from_device(raw):
+ result = []
+ for addr, data in sorted((raw or {}).items()):
+ scoped = scope_to_spec(data or {}, _NEIGHBOR_OPTIONS, exclude={"neighbor_address"})
+ entry = {"neighbor_address": addr, **from_device(scoped)}
+ cast_by_spec(entry, _NEIGHBOR_OPTIONS)
+ result.append(entry)
+ return result
+
+
+def _peer_groups_to_device(peer_groups):
+ return {
+ pg["peer_group"]: autoclean({k: v for k, v in pg.items() if k != "peer_group"})
+ for pg in peer_groups or []
+ }
+
+
+def _peer_groups_from_device(raw):
+ result = []
+ for name, data in sorted((raw or {}).items()):
+ scoped = scope_to_spec(data or {}, _PEER_GROUP_OPTIONS, exclude={"peer_group"})
+ entry = {"peer_group": name, **from_device(scoped)}
+ cast_by_spec(entry, _PEER_GROUP_OPTIONS)
+ result.append(entry)
return result
-def _parse_neighbor(nb_id, data):
- nb = {"neighbor_address": nb_id}
- data = data or {}
- if "remote-as" in data:
- nb["remote_as"] = int(data["remote-as"])
- if "description" in data:
- nb["description"] = data["description"]
- if "ebgp-multihop" in data:
- nb["ebgp_multihop"] = int(data["ebgp-multihop"])
- if "local-as" in data:
- nb["local_as"] = int(data["local-as"])
- if "password" in data:
- nb["password"] = data["password"]
- if "peer-group" in data:
- nb["peer_group"] = data["peer-group"]
- if "shutdown" in data:
- nb["shutdown"] = True
- if "update-source" in data:
- nb["update_source"] = data["update-source"]
- if "disable-connected-check" in data:
- nb["disable_connected_check"] = True
- timers = data.get("timers", {}) or {}
- if timers:
- t = {}
- if "holdtime" in timers:
- t["holdtime"] = int(timers["holdtime"])
- if "keepalive" in timers:
- t["keepalive"] = int(timers["keepalive"])
- if t:
- nb["timers"] = t
- return nb
-
-
-def _parse_peer_group(pg_name, data):
- pg = {"peer_group": pg_name}
- data = data or {}
- if "remote-as" in data:
- pg["remote_as"] = int(data["remote-as"])
- if "description" in data:
- pg["description"] = data["description"]
- if "ebgp-multihop" in data:
- pg["ebgp_multihop"] = int(data["ebgp-multihop"])
- if "password" in data:
- pg["password"] = data["password"]
- if "update-source" in data:
- pg["update_source"] = data["update-source"]
- timers = data.get("timers", {}) or {}
- if timers:
- t = {}
- if "holdtime" in timers:
- t["holdtime"] = int(timers["holdtime"])
- if "keepalive" in timers:
- t["keepalive"] = int(timers["keepalive"])
- if t:
- pg["timers"] = t
- return pg
+def _want_to_device(config):
+ config = config or {}
+ result = {}
+ if config.get("as_number") is not None:
+ result["system_as"] = config["as_number"]
+ if config.get("parameters"):
+ result["parameters"] = autoclean(config["parameters"])
+ if config.get("neighbors"):
+ result["neighbor"] = _neighbors_to_device(config["neighbors"])
+ if config.get("peer_groups"):
+ result["peer_group"] = _peer_groups_to_device(config["peer_groups"])
+ return result
def get_running_config(vyos):
- raw = vyos.get_config(_BASE)
+ return vyos.get_config(_BASE) or {}
+
+
+def _device_to_argspec(raw):
if not raw or not isinstance(raw, dict):
return {}
result = {}
-
if "system-as" in raw:
result["as_number"] = int(raw["system-as"])
-
- params = _parse_parameters(raw.get("parameters"))
- if params:
+ if raw.get("parameters"):
+ params = from_device(raw["parameters"])
+ cast_by_spec(params, _PARAMETERS_OPTIONS)
result["parameters"] = params
-
- neighbors = []
- for nb_id, data in sorted((raw.get("neighbor") or {}).items()):
- neighbors.append(_parse_neighbor(nb_id, data))
+ neighbors = _neighbors_from_device(raw.get("neighbor"))
if neighbors:
result["neighbors"] = neighbors
-
- peer_groups = []
- for pg_name, data in sorted((raw.get("peer-group") or {}).items()):
- peer_groups.append(_parse_peer_group(pg_name, data))
+ peer_groups = _peer_groups_from_device(raw.get("peer-group"))
if peer_groups:
result["peer_groups"] = peer_groups
-
return result
-def _neighbor_cmds(nb, have_nb):
- cmds = []
- nb_addr = nb["neighbor_address"]
- nbase = _BASE + ["neighbor", nb_addr]
- have_nb = have_nb or {}
-
- if nb.get("remote_as") and nb["remote_as"] != have_nb.get("remote_as"):
- cmds.append(("set", nbase + ["remote-as", str(nb["remote_as"])]))
- if nb.get("description") and nb["description"] != have_nb.get("description"):
- cmds.append(("set", nbase + ["description", nb["description"]]))
- if nb.get("ebgp_multihop") and nb["ebgp_multihop"] != have_nb.get("ebgp_multihop"):
- cmds.append(("set", nbase + ["ebgp-multihop", str(nb["ebgp_multihop"])]))
- if nb.get("local_as") and nb["local_as"] != have_nb.get("local_as"):
- cmds.append(("set", nbase + ["local-as", str(nb["local_as"])]))
- if nb.get("password") and nb["password"] != have_nb.get("password"):
- cmds.append(("set", nbase + ["password", nb["password"]]))
- if nb.get("peer_group") and nb["peer_group"] != have_nb.get("peer_group"):
- cmds.append(("set", nbase + ["peer-group", nb["peer_group"]]))
- if nb.get("update_source") and nb["update_source"] != have_nb.get("update_source"):
- cmds.append(("set", nbase + ["update-source", nb["update_source"]]))
- if nb.get("shutdown") and not have_nb.get("shutdown"):
- cmds.append(("set", nbase + ["shutdown"]))
- if nb.get("disable_connected_check") and not have_nb.get("disable_connected_check"):
- cmds.append(("set", nbase + ["disable-connected-check"]))
-
- want_t = nb.get("timers") or {}
- have_t = have_nb.get("timers") or {}
- if want_t.get("holdtime") and want_t["holdtime"] != have_t.get("holdtime"):
- cmds.append(("set", nbase + ["timers", "holdtime", str(want_t["holdtime"])]))
- if want_t.get("keepalive") and want_t["keepalive"] != have_t.get("keepalive"):
- cmds.append(("set", nbase + ["timers", "keepalive", str(want_t["keepalive"])]))
-
- return cmds
-
-
-def _peer_group_cmds(pg, have_pg):
- cmds = []
- pg_name = pg["peer_group"]
- pbase = _BASE + ["peer-group", pg_name]
- have_pg = have_pg or {}
-
- if pg.get("remote_as") and pg["remote_as"] != have_pg.get("remote_as"):
- cmds.append(("set", pbase + ["remote-as", str(pg["remote_as"])]))
- if pg.get("description") and pg["description"] != have_pg.get("description"):
- cmds.append(("set", pbase + ["description", pg["description"]]))
- if pg.get("ebgp_multihop") and pg["ebgp_multihop"] != have_pg.get("ebgp_multihop"):
- cmds.append(("set", pbase + ["ebgp-multihop", str(pg["ebgp_multihop"])]))
- if pg.get("password") and pg["password"] != have_pg.get("password"):
- cmds.append(("set", pbase + ["password", pg["password"]]))
- if pg.get("update_source") and pg["update_source"] != have_pg.get("update_source"):
- cmds.append(("set", pbase + ["update-source", pg["update_source"]]))
-
- want_t = pg.get("timers") or {}
- have_t = have_pg.get("timers") or {}
- if want_t.get("holdtime") and want_t["holdtime"] != have_t.get("holdtime"):
- cmds.append(("set", pbase + ["timers", "holdtime", str(want_t["holdtime"])]))
- if want_t.get("keepalive") and want_t["keepalive"] != have_t.get("keepalive"):
- cmds.append(("set", pbase + ["timers", "keepalive", str(want_t["keepalive"])]))
-
- return cmds
-
-
-def build_commands(config, have, state):
- cmds = []
-
- if state == "deleted":
- if have:
- cmds.append(("delete", _BASE))
- return cmds
-
- 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 {}
+# ---------------------------------------------------------------------------
+# Command building — dict_op scoped per owned subtree, with one exception.
+#
+# "protocols bgp" is a shared root with vyos_bgp_address_family, and each
+# neighbor entry mixes fields owned by *both* modules (this module owns
+# remote-as/timers/etc.; the sibling module owns the nested address-family
+# subtree). Every dict_op call for a neighbor or peer-group here first
+# goes through scope_to_spec() against this module's own ARGUMENT_SPEC, so
+# a foreign subtree like address-family is never visible to purge/set —
+# without hardcoding its name, since this module's argspec simply never
+# declared it.
+#
+# The one exception: removing system-as. VyOS rejects any commit that
+# leaves "protocols bgp" non-empty without an AS number defined, so that
+# specific transition can't be done with scoped/incremental commands --
+# see the short-circuit at the top of build_commands().
+# ---------------------------------------------------------------------------
+
+
+def build_commands(config, raw_have, state):
+ raw_have = raw_have or {}
+ # "deleted" is "replaced" with an empty desired state -- same scoped
+ # purge mechanics, no separate blanket-delete-the-whole-root logic
+ # (which would have wiped the sibling module's config too)...
+ want = _want_to_device({} if state == "deleted" else config)
+ effective_state = "replaced" if state == "deleted" else state
+
+ # ...EXCEPT for one case: VyOS requires system-as to be defined
+ # whenever "protocols bgp" has any content at all, and rejects the
+ # commit otherwise. So if system-as is being removed (present in
+ # have, absent from want) under replaced/deleted -- the only states
+ # that purge at all -- the only valid action is to delete the entire
+ # tree in one atomic commit, including address-family, which cannot
+ # validly exist without an AS number anyway. This is a real
+ # device-model cascade, not cross-module scope creep. It must never
+ # fire for "merged": an omitted config/as_number there is a no-op by
+ # definition, and merged's set-only dict_op flow below already
+ # leaves system-as untouched correctly on its own.
+ if effective_state == "replaced" and "system-as" in raw_have and "system_as" not in want:
+ return [("delete", _BASE)]
+
+ commands = []
+
+ norm_have = normalize_have(raw_have, _TAG_KEYS)
+
+ top_have = {k: v for k, v in raw_have.items() if k in ("system-as", "parameters")}
+ top_want = {k: v for k, v in want.items() if k in ("system_as", "parameters")}
+ if effective_state == "replaced":
+ commands += dict_op(top_want, top_have, _BASE, op="purge")
+ commands += dict_op(top_want, top_have, _BASE, op="set")
+
+ raw_neighbors = norm_have.get("neighbor") or {}
+ want_neighbors = want.get("neighbor", {})
+ for addr in sorted(set(want_neighbors) | set(raw_neighbors)):
+ nbase = _BASE + ["neighbor", addr]
+ have_scoped = scope_to_spec(
+ raw_neighbors.get(addr) or {},
+ _NEIGHBOR_OPTIONS,
+ exclude={"neighbor_address"},
+ )
+ want_entry = want_neighbors.get(addr, {})
+ if effective_state == "replaced":
+ commands += dict_op(want_entry, have_scoped, nbase, op="purge")
+ commands += dict_op(want_entry, have_scoped, nbase, op="set")
+
+ raw_peer_groups = norm_have.get("peer-group") or {}
+ want_peer_groups = want.get("peer_group", {})
+ for name in sorted(set(want_peer_groups) | set(raw_peer_groups)):
+ pbase = _BASE + ["peer-group", name]
+ have_scoped = scope_to_spec(
+ raw_peer_groups.get(name) or {},
+ _PEER_GROUP_OPTIONS,
+ exclude={"peer_group"},
+ )
+ want_entry = want_peer_groups.get(name, {})
+ if effective_state == "replaced":
+ commands += dict_op(want_entry, have_scoped, pbase, op="purge")
+ commands += dict_op(want_entry, have_scoped, pbase, op="set")
- # system-as — must be first
- if config.get("as_number") and config["as_number"] != have.get("as_number"):
- cmds.append(("set", _BASE + ["system-as", str(config["as_number"])]))
-
- # parameters
- params = config.get("parameters") or {}
- have_params = have.get("parameters") or {}
- if params.get("router_id") and params["router_id"] != have_params.get("router_id"):
- cmds.append(("set", _BASE + ["parameters", "router-id", params["router_id"]]))
- if params.get("log_neighbor_changes") and not have_params.get("log_neighbor_changes"):
- cmds.append(("set", _BASE + ["parameters", "log-neighbor-changes"]))
- if params.get("no_ipv4_unicast") and not have_params.get("no_ipv4_unicast"):
- cmds.append(("set", _BASE + ["parameters", "no-ipv4-unicast"]))
- if params.get("graceful_restart") and not have_params.get("graceful_restart"):
- cmds.append(("set", _BASE + ["parameters", "graceful-restart"]))
- bp = params.get("bestpath") or {}
- have_bp = have_params.get("bestpath") or {}
- if bp.get("as_path") and bp["as_path"] != have_bp.get("as_path"):
- cmds.append(("set", _BASE + ["parameters", "bestpath", "as-path", bp["as_path"]]))
-
- # neighbors
- have_nb_map = {n["neighbor_address"]: n for n in (have.get("neighbors") or [])}
- for nb in config.get("neighbors") or []:
- cmds += _neighbor_cmds(nb, have_nb_map.get(nb["neighbor_address"]))
-
- # peer_groups
- have_pg_map = {p["peer_group"]: p for p in (have.get("peer_groups") or [])}
- for pg in config.get("peer_groups") or []:
- cmds += _peer_group_cmds(pg, have_pg_map.get(pg["peer_group"]))
-
- return cmds
+ return commands
ARGUMENT_SPEC = dict(
@@ -535,6 +478,13 @@ ARGUMENT_SPEC = dict(
),
)
+# Populated post-definition (avoids forward-reference ordering); backs
+# cast_by_spec/scope_to_spec so have-side casting and cross-module
+# protection are both derived from the spec itself.
+_PARAMETERS_OPTIONS = ARGUMENT_SPEC["config"]["options"]["parameters"]["options"]
+_NEIGHBOR_OPTIONS = ARGUMENT_SPEC["config"]["options"]["neighbors"]["options"]
+_PEER_GROUP_OPTIONS = ARGUMENT_SPEC["config"]["options"]["peer_groups"]["options"]
+
def main():
module = AnsibleModule(ARGUMENT_SPEC, supports_check_mode=True)
@@ -543,12 +493,13 @@ 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)
if state == "gathered":
- module.exit_json(changed=False, gathered=have)
+ module.exit_json(changed=False, gathered=have, commands=[])
- 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)
@@ -559,7 +510,7 @@ def main():
module.exit_json(
changed=True,
before=have,
- after=get_running_config(vyos),
+ after=_device_to_argspec(get_running_config(vyos)),
commands=commands,
saved=saved,
response=response,
diff --git a/plugins/modules/vyos_command.py b/plugins/modules/vyos_command.py
new file mode 100644
index 0000000..d688251
--- /dev/null
+++ b/plugins/modules/vyos_command.py
@@ -0,0 +1,228 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+# GNU General Public License v3.0+
+from __future__ import absolute_import, division, print_function
+
+
+__metaclass__ = type
+
+DOCUMENTATION = r"""
+---
+module: vyos_command
+short_description: Run show commands on VyOS devices using REST API
+description:
+ - Sends show commands to VyOS devices via the REST API C(/show) endpoint
+ and returns the output.
+ - Equivalent to C(vyos_command) in the CLI collection but uses the REST API.
+ - Uses REST API (C(connection=httpapi)) instead of CLI.
+version_added: "1.0.0"
+author:
+ - VyOS Community (@vyos)
+options:
+ commands:
+ description:
+ - List of show commands to run on the device.
+ - Each command is a list of path elements passed to the C(/show) endpoint.
+ - Commands may be specified as a string (space-separated) or a list.
+ type: list
+ elements: raw
+ required: true
+ wait_for:
+ description:
+ - Specifies what to evaluate from the output of the command and what
+ conditionals to apply. This argument will cause the task to wait for
+ a particular conditional to be true before moving forward.
+ type: list
+ elements: str
+ aliases: [waitfor]
+ match:
+ description:
+ - The C(match) argument is used in conjunction with the C(wait_for)
+ argument to specify the match policy.
+ type: str
+ choices: [any, all]
+ default: all
+ retries:
+ description:
+ - Specifies the number of retries a command should be run before it
+ is considered failed.
+ type: int
+ default: 10
+ interval:
+ description:
+ - Configures the interval in seconds to wait between retries of the
+ command.
+ type: int
+ default: 1
+notes:
+ - Requires C(ansible_connection=httpapi) with the VyOS httpapi plugin.
+ - C(ansible_network_os) must be set to C(vyos.rest.vyos).
+ - Only C(show) commands are supported via the REST API.
+ - Commands are passed as path lists to the C(/show) endpoint.
+"""
+
+EXAMPLES = r"""
+- name: Run show version
+ vyos.rest.vyos_command:
+ commands:
+ - - version
+ register: result
+
+- name: Run multiple show commands
+ vyos.rest.vyos_command:
+ commands:
+ - - interfaces
+ - - ip
+ - route
+ - - system
+ - uptime
+ register: result
+
+- name: Run show commands as strings
+ vyos.rest.vyos_command:
+ commands:
+ - "interfaces"
+ - "ip route"
+ - "version"
+ register: result
+
+- name: Wait for BGP to establish
+ vyos.rest.vyos_command:
+ commands:
+ - - ip
+ - bgp
+ - summary
+ wait_for:
+ - result[0] contains Established
+ retries: 10
+ interval: 5
+"""
+
+RETURN = r"""
+stdout:
+ description: List of output from each command.
+ returned: always
+ type: list
+ sample: ["VyOS 1.5.0\n...", "Interface IP Address\n..."]
+stdout_lines:
+ description: List of output split into lines for each command.
+ returned: always
+ type: list
+failed_conditions:
+ description: List of conditions that failed.
+ returned: failed
+ type: list
+"""
+
+import time
+
+from ansible.module_utils.basic import AnsibleModule
+from ansible_collections.vyos.rest.plugins.module_utils.vyos import VyOSModule
+
+
+def parse_command(cmd):
+ """Convert a command to a path list."""
+ if isinstance(cmd, list):
+ return cmd
+ elif isinstance(cmd, str):
+ return cmd.split()
+ return list(cmd)
+
+
+def run_commands(vyos, commands):
+ """Run show commands and return stdout list."""
+ stdout = []
+ for cmd in commands:
+ path = parse_command(cmd)
+ try:
+ result = vyos.show(path)
+ stdout.append(result if result else "")
+ except Exception as e:
+ stdout.append("ERROR: %s" % str(e))
+ return stdout
+
+
+def evaluate_conditions(stdout, wait_for, match):
+ """Evaluate wait_for conditions against stdout."""
+ failed = []
+ results = []
+
+ for condition in wait_for:
+ # Parse simple conditions: "result[N] contains STRING"
+ if " contains " in condition:
+ parts = condition.split(" contains ", 1)
+ ref = parts[0].strip()
+ value = parts[1].strip()
+ # Extract index from result[N]
+ if ref.startswith("result[") and ref.endswith("]"):
+ try:
+ idx = int(ref[7:-1])
+ matched = value in stdout[idx]
+ results.append(matched)
+ if not matched:
+ failed.append(condition)
+ except (ValueError, IndexError):
+ failed.append(condition)
+ else:
+ failed.append(condition)
+ else:
+ # Unsupported condition format
+ failed.append(condition)
+
+ if match == "any":
+ return not any(results), failed
+ return bool(failed), failed
+
+
+def main():
+ module = AnsibleModule(
+ argument_spec=dict(
+ commands=dict(type="list", elements="raw", required=True),
+ wait_for=dict(type="list", elements="str", aliases=["waitfor"]),
+ match=dict(type="str", default="all", choices=["any", "all"]),
+ retries=dict(type="int", default=10),
+ interval=dict(type="int", default=1),
+ ),
+ supports_check_mode=True,
+ )
+
+ vyos = VyOSModule(module)
+ commands = module.params["commands"]
+ wait_for = module.params["wait_for"] or []
+ match = module.params["match"]
+ retries = module.params["retries"]
+ interval = module.params["interval"]
+
+ stdout = []
+ failed_conditions = []
+
+ for attempt in range(retries):
+ stdout = run_commands(vyos, commands)
+
+ if not wait_for:
+ break
+
+ failed_check, failed_conditions = evaluate_conditions(stdout, wait_for, match)
+ if not failed_check:
+ break
+
+ if attempt < retries - 1:
+ time.sleep(interval)
+ else:
+ if failed_conditions:
+ module.fail_json(
+ msg="One or more conditional statements have not been satisfied",
+ failed_conditions=failed_conditions,
+ )
+
+ stdout_lines = [out.splitlines() for out in stdout]
+
+ module.exit_json(
+ changed=False,
+ stdout=stdout,
+ stdout_lines=stdout_lines,
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/plugins/modules/vyos_config.py b/plugins/modules/vyos_config.py
new file mode 100644
index 0000000..55d5fdc
--- /dev/null
+++ b/plugins/modules/vyos_config.py
@@ -0,0 +1,238 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+# GNU General Public License v3.0+
+from __future__ import absolute_import, division, print_function
+
+
+__metaclass__ = type
+
+DOCUMENTATION = r"""
+---
+module: vyos_config
+short_description: Manage VyOS configuration using REST API
+description:
+ - Manages VyOS device configuration via the REST API.
+ - Accepts configuration commands in CLI C(set)/C(delete) string format
+ and applies them via the REST C(/configure) endpoint.
+ - Uses REST API (C(connection=httpapi)) instead of CLI.
+version_added: "1.0.0"
+author:
+ - VyOS Community (@vyos)
+options:
+ lines:
+ description:
+ - Ordered list of C(set) or C(delete) commands to apply.
+ - Commands should be in standard VyOS CLI format, e.g.
+ C(set system host-name router1) or C(delete protocols bgp).
+ type: list
+ elements: str
+ src:
+ description:
+ - Path to a file containing C(set)/C(delete) commands, one per line.
+ - Blank lines and lines starting with C(#) are ignored.
+ - Mutually exclusive with C(lines).
+ type: path
+ match:
+ description:
+ - Controls how commands are matched against the running configuration.
+ - C(line) checks each command against the running config and only
+ applies commands that would change the configuration.
+ - C(none) applies all commands without checking the running config.
+ type: str
+ default: line
+ choices: [line, none]
+ save:
+ description:
+ - Save the configuration to disk after applying changes.
+ type: bool
+ default: false
+notes:
+ - Requires C(ansible_connection=httpapi) with the VyOS httpapi plugin.
+ - C(ansible_network_os) must be set to C(vyos.rest.vyos).
+ - Unlike the CLI collection's C(vyos_config), this module does not support
+ C(backup), C(confirm), or C(comment) options as these are CLI-specific.
+ - Commands are parsed from CLI string format into REST API path arrays.
+"""
+
+EXAMPLES = r"""
+- name: Apply configuration lines
+ vyos.rest.vyos_config:
+ lines:
+ - set system host-name router1
+ - set system domain-name example.com
+ - set interfaces ethernet eth0 description "WAN"
+ save: true
+
+- name: Delete configuration
+ vyos.rest.vyos_config:
+ lines:
+ - delete protocols bgp
+ save: true
+
+- name: Apply config from file
+ vyos.rest.vyos_config:
+ src: /tmp/vyos_config.txt
+ match: none
+ save: true
+
+- name: Always apply without matching
+ vyos.rest.vyos_config:
+ lines:
+ - set system host-name router1
+ match: none
+"""
+
+RETURN = r"""
+commands:
+ description: List of commands applied to the device.
+ returned: always
+ type: list
+saved:
+ description: Whether the configuration was saved to disk.
+ returned: when save is true and changes were made
+ type: bool
+response:
+ description: Raw API response from the device.
+ returned: always
+ type: dict
+"""
+
+from ansible.module_utils.basic import AnsibleModule
+from ansible_collections.vyos.rest.plugins.module_utils.vyos import VyOSModule
+
+
+def parse_line(line):
+ """Parse a CLI set/delete line into (op, path) tuple.
+
+ Args:
+ line (str): CLI command, e.g. "set system host-name router1"
+
+ Returns:
+ tuple: (op, path) where op is "set" or "delete" and path is a list,
+ or None if the line is not a valid command.
+ """
+ import shlex
+
+ line = line.strip()
+ if not line or line.startswith("#"):
+ return None
+ try:
+ tokens = shlex.split(line)
+ except ValueError:
+ tokens = line.split()
+ if len(tokens) < 2:
+ return None
+ op = tokens[0].lower()
+ if op not in ("set", "delete"):
+ return None
+ path = tokens[1:]
+ return (op, path)
+
+
+def load_lines(module):
+ """Load lines from either lines param or src file."""
+ if module.params["lines"]:
+ return module.params["lines"]
+ src = module.params["src"]
+ if src:
+ try:
+ with open(src) as f:
+ return f.readlines()
+ except IOError as e:
+ module.fail_json(msg="Unable to read src file: %s" % str(e))
+ return []
+
+
+def parse_commands(lines):
+ """Parse a list of CLI lines into (op, path) tuples."""
+ commands = []
+ for line in lines:
+ parsed = parse_line(line)
+ if parsed:
+ commands.append(parsed)
+ return commands
+
+
+def filter_commands(commands, vyos):
+ """Filter commands that would not change the running config.
+
+ For set commands, check if the path already has the desired value.
+ For delete commands, check if the path exists.
+ """
+ filtered = []
+ for op, path in commands:
+ if op == "set":
+ if len(path) >= 2:
+ # For leaf: path[-1] is the value, path[:-1] is the config path
+ # e.g. ["system","host-name","vyos150"] -> get ["system","host-name"]
+ # returns {"host-name": "vyos150"} -> unwrap -> "vyos150"
+ parent_path = path[:-1]
+ value = path[-1]
+ parent = vyos.get_config(parent_path)
+ if isinstance(parent, dict):
+ # unwrap single-key dict (API wraps leaf values)
+ if len(parent) == 1:
+ actual = list(parent.values())[0]
+ else:
+ actual = parent.get(parent_path[-1])
+ if actual == value:
+ continue
+ # value may be a key in the dict (tag node)
+ if value in parent:
+ continue
+ elif isinstance(parent, str) and parent == value:
+ continue
+ filtered.append((op, path))
+ elif op == "delete":
+ current = vyos.get_config(path)
+ if current is not None and current != {}:
+ filtered.append((op, path))
+ return filtered
+
+
+def main():
+ module = AnsibleModule(
+ argument_spec=dict(
+ lines=dict(type="list", elements="str"),
+ src=dict(type="path"),
+ match=dict(type="str", default="line", choices=["line", "none"]),
+ save=dict(type="bool", default=False),
+ ),
+ mutually_exclusive=[["lines", "src"]],
+ supports_check_mode=True,
+ )
+
+ vyos = VyOSModule(module)
+
+ lines = load_lines(module)
+ commands = parse_commands(lines)
+
+ if not commands:
+ module.exit_json(changed=False, commands=[])
+
+ match = module.params["match"]
+ if match == "line":
+ commands = filter_commands(commands, vyos)
+
+ if not commands:
+ module.exit_json(changed=False, commands=[])
+
+ if module.check_mode:
+ module.exit_json(changed=True, commands=commands)
+
+ response = vyos.apply_commands(commands)
+
+ saved = False
+ if module.params["save"]:
+ saved = vyos.save_config()
+
+ module.exit_json(
+ changed=True,
+ commands=commands,
+ saved=saved,
+ response=response,
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/plugins/modules/vyos_firewall_global.py b/plugins/modules/vyos_firewall_global.py
index 66cdabc..337f364 100644
--- a/plugins/modules/vyos_firewall_global.py
+++ b/plugins/modules/vyos_firewall_global.py
@@ -181,21 +181,30 @@ gathered:
type: dict
saved:
description: Whether the config was saved after changes.
- returned: when changes are applied
+ returned: when changed
type: bool
response:
description: Raw API response.
- returned: when changes are applied
+ returned: always
type: dict
"""
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,
+ dict_op,
+ from_device,
+ normalize_have,
+)
_BASE = ["firewall", "group"]
-# Map argspec key -> API key, value key
+# argspec_key -> (device_key, member_key). A genuinely minimal, unavoidable
+# mapping: VyOS's 5 group types have different kebab-case device names and
+# different member-list field names (address/network/port/interface), none
+# of which is a mechanical snake<->kebab transform of the other.
_GROUP_TYPES = {
"address_group": ("address-group", "address"),
"network_group": ("network-group", "network"),
@@ -204,103 +213,81 @@ _GROUP_TYPES = {
"ipv6_network_group": ("ipv6-network-group", "network"),
}
+# Each of the 5 device_keys above is itself a tag node (keyed by group
+# name) that can collapse to a bare string for a single group with no
+# other config. The member fields (address/network/port/interface) are
+# NOT tag nodes -- confirmed against vyos-1x (leafNode with <multi/>) --
+# they're plain multi-value leaves, so dict_op's own native list handling
+# applies to them directly; no reshaping needed.
+_TAG_KEYS = {device_key for device_key, _member_key in _GROUP_TYPES.values()}
-def _parse_group_type(raw, val_key):
- """Parse a group dict from API raw data."""
- if not raw or not isinstance(raw, dict):
- return []
- result = []
- for name, data in sorted(raw.items()):
- entry = {"name": name}
- data = data or {}
- if data.get("description"):
- entry["description"] = data["description"]
- val = data.get(val_key)
- if val is not None:
- if isinstance(val, list):
- entry[val_key.replace("-", "_")] = val
- elif isinstance(val, str):
- entry[val_key.replace("-", "_")] = [val]
- elif isinstance(val, dict):
- entry[val_key.replace("-", "_")] = list(val.keys())
- result.append(entry)
- return result
+def _group_to_device(g, member_key):
+ entry = autoclean({k: v for k, v in g.items() if k not in ("name", member_key)})
+ members = g.get(member_key)
+ if members:
+ entry[member_key] = [str(m) for m in members]
+ return entry
-def get_running_config(vyos):
- raw = vyos.get_config(_BASE)
- if not raw or not isinstance(raw, dict):
- return {}
- result = {"group": {}}
- for arg_key, (api_key, val_key) in _GROUP_TYPES.items():
- groups = _parse_group_type(raw.get(api_key), val_key)
- if groups:
- result["group"][arg_key] = groups
+def _groups_to_device(groups, member_key):
+ return {g["name"]: _group_to_device(g, member_key) for g in groups or []}
- if not result["group"]:
- return {}
- return result
+def _want_to_device(config):
+ group = (config or {}).get("group") or {}
+ want = {}
+ for arg_key, (device_key, member_key) in _GROUP_TYPES.items():
+ groups = group.get(arg_key) or []
+ if groups:
+ want[device_key] = _groups_to_device(groups, member_key)
+ return want
-def _group_cmds(arg_key, groups, have_groups, state):
- cmds = []
- api_key, val_key = _GROUP_TYPES[arg_key]
- have_map = {g["name"]: g for g in (have_groups or [])}
- want_map = {g["name"]: g for g in (groups or [])}
- if state == "replaced":
- for name in set(have_map) - set(want_map):
- cmds.append(("delete", _BASE + [api_key, name]))
+def _group_from_device(name, data, member_key):
+ data = dict(data or {})
+ members = data.pop(member_key, None)
+ entry = {"name": name, **from_device(data)}
+ if members is not None:
+ member_list = [members] if isinstance(members, str) else members
+ entry[member_key] = sorted(str(m) for m in member_list)
+ return entry
- for name, group in want_map.items():
- have_group = have_map.get(name, {})
- gbase = _BASE + [api_key, name]
- if group.get("description") and group["description"] != have_group.get("description"):
- cmds.append(("set", gbase + ["description", group["description"]]))
+def _groups_from_device(raw_groups, member_key):
+ if not raw_groups or not isinstance(raw_groups, dict):
+ return []
+ return [_group_from_device(name, data, member_key) for name, data in sorted(raw_groups.items())]
- # normalize val_key for argspec (underscores)
- arg_val_key = val_key.replace("-", "_")
- want_vals = set(group.get(arg_val_key) or [])
- have_vals = set(have_group.get(arg_val_key) or [])
- for val in want_vals - have_vals:
- cmds.append(("set", gbase + [val_key, val]))
+def get_running_config(vyos):
+ return vyos.get_config(_BASE) or {}
- if state == "replaced":
- for val in have_vals - want_vals:
- cmds.append(("delete", gbase + [val_key, val]))
- return cmds
+def _device_to_argspec(raw):
+ if not raw or not isinstance(raw, dict):
+ return {}
+ group = {}
+ for arg_key, (device_key, member_key) in _GROUP_TYPES.items():
+ groups = _groups_from_device(raw.get(device_key), member_key)
+ if groups:
+ group[arg_key] = groups
+ return {"group": group} if group else {}
-def build_commands(config, have, state):
- cmds = []
+def build_commands(config, raw_have, state):
+ raw_have = raw_have or {}
+ want = _want_to_device(config)
+ norm_have = normalize_have(raw_have, _TAG_KEYS)
if state == "deleted":
- if have:
- cmds.append(("delete", _BASE))
- return cmds
+ return [("delete", _BASE)] if raw_have else []
+ commands = []
if state == "replaced":
- # Check if anything differs
- would_set = build_commands(config, {}, "merged")
- have_set = build_commands(have, {}, "merged")
- if would_set == have_set:
- return []
-
- config = config or {}
- want_group = config.get("group") or {}
- have_group = have.get("group") or {}
-
- for arg_key in _GROUP_TYPES:
- want_groups = want_group.get(arg_key) or []
- have_groups = have_group.get(arg_key) or []
- if want_groups or (state == "replaced" and have_groups):
- cmds += _group_cmds(arg_key, want_groups, have_groups, state)
-
- 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(
@@ -373,12 +360,13 @@ 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)
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)
@@ -389,7 +377,7 @@ def main():
module.exit_json(
changed=True,
before=have,
- after=get_running_config(vyos),
+ after=_device_to_argspec(get_running_config(vyos)),
commands=commands,
saved=saved,
response=response,
diff --git a/plugins/modules/vyos_firewall_interfaces.py b/plugins/modules/vyos_firewall_interfaces.py
index 8769f54..4b4b7eb 100644
--- a/plugins/modules/vyos_firewall_interfaces.py
+++ b/plugins/modules/vyos_firewall_interfaces.py
@@ -168,197 +168,166 @@ gathered:
type: list
saved:
description: Whether the config was saved after changes.
- returned: when changes are applied
+ returned: when changed
type: bool
response:
description: Raw API response.
- returned: when changes are applied
+ returned: always
type: dict
"""
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,
+ dict_op,
+ from_device,
+ normalize_have,
+)
_BASE = ["firewall"]
-_AFIS = ["ipv4", "ipv6"]
-_HOOKS = ["input", "output", "forward"]
-
-
-def _parse_rule(rule_num, data):
- rule = {"number": int(rule_num)}
- data = data or {}
- if "action" in data:
- rule["action"] = data["action"]
- if "description" in data:
- rule["description"] = data["description"]
- if "disable" in data:
- rule["disable"] = True
- if "protocol" in data:
- rule["protocol"] = data["protocol"]
- if "state" in data:
- rule["state"] = data["state"]
- if "log" in data:
- rule["log"] = True
-
- for endpoint in ["source", "destination"]:
- ep = data.get(endpoint, {}) or {}
- if ep:
- rule[endpoint] = {}
- if "address" in ep:
- rule[endpoint]["address"] = ep["address"]
- if "port" in ep:
- rule[endpoint]["port"] = ep["port"]
-
- return rule
-
-
-def _parse_hook_filter(hook, data):
- entry = {"hook": hook}
- data = data or {}
- filter_data = data.get("filter", {}) or {}
- if "default-action" in filter_data:
- entry["default_action"] = filter_data["default-action"]
- if "description" in filter_data:
- entry["description"] = filter_data["description"]
- rules_raw = filter_data.get("rule", {}) or {}
- if rules_raw and isinstance(rules_raw, dict):
- rules = [
- _parse_rule(num, rdata)
- for num, rdata in sorted(
- rules_raw.items(),
- key=lambda x: int(x[0]),
- )
- ]
- if rules:
- entry["rules"] = rules
- return entry
+# The only hook filter keys this module owns under firewall.<afi>. Sibling
+# top-level keys under the same afi (e.g. firewall.<afi>.name, owned by
+# vyos_firewall_rules) are never enumerated or touched -- this module
+# only ever builds paths as _BASE + [afi, hook, "filter", ...] for hook
+# drawn from this fixed set, never a blanket op at _BASE + [afi] itself.
+_HOOKS = ("input", "output", "forward")
+_AFIS = ("ipv4", "ipv6")
-def get_running_config(vyos):
+# "rule" is a genuine tag node (keyed by rule number) that VyOS's REST API
+# can collapse to a bare value for a single rule with no other config.
+_TAG_KEYS = {"rule"}
+
+
+# ---------------------------------------------------------------------------
+# want -> device / device -> argspec
+#
+# Every leaf here is a direct structural match between argspec and device
+# shape (protocol, description, disable, state, log, source/destination
+# both flowing through autoclean/from_device generically). The only
+# unavoidable structural work: the "rule" tag-node reshape (keyed by
+# number) and inserting the literal "filter" wrapper key that VyOS
+# requires one level under each hook but the argspec omits (hook_entry's
+# fields live directly on it, not nested under a "filter" key).
+# ---------------------------------------------------------------------------
+
+
+def _rules_to_device(rules):
+ return {
+ str(r["number"]): autoclean({k: v for k, v in r.items() if k != "number"})
+ for r in rules or []
+ }
+
+
+def _rules_from_device(raw):
result = []
- for afi in _AFIS:
- raw = vyos.get_config(_BASE + [afi])
- if not raw or not isinstance(raw, dict):
- continue
- hooks = []
- for hook in _HOOKS:
- if hook in raw:
- parsed = _parse_hook_filter(hook, raw[hook])
- if len(parsed) > 1: # more than just hook key
- hooks.append(parsed)
- if hooks:
- result.append({"afi": afi, "hooks": hooks})
+ for num, data in sorted((raw or {}).items(), key=lambda kv: int(kv[0])):
+ entry = {"number": int(num), **from_device(data or {})}
+ result.append(entry)
return result
-def _rule_cmds(afi, hook, rule, have_rule):
- cmds = []
- rbase = _BASE + [afi, hook, "filter", "rule", str(rule["number"])]
- have_rule = have_rule or {}
+def _hook_filter_to_device(hook_entry):
+ entry = autoclean({k: v for k, v in hook_entry.items() if k not in ("hook", "rules")})
+ if hook_entry.get("rules"):
+ entry["rule"] = _rules_to_device(hook_entry["rules"])
+ return entry
- if rule.get("action") and rule["action"] != have_rule.get("action"):
- cmds.append(("set", rbase + ["action", rule["action"]]))
- if rule.get("description") and rule["description"] != have_rule.get("description"):
- cmds.append(("set", rbase + ["description", rule["description"]]))
- if rule.get("disable") and not have_rule.get("disable"):
- cmds.append(("set", rbase + ["disable"]))
- if rule.get("protocol") and rule["protocol"] != have_rule.get("protocol"):
- cmds.append(("set", rbase + ["protocol", rule["protocol"]]))
- if rule.get("state") and rule["state"] != have_rule.get("state"):
- cmds.append(("set", rbase + ["state", rule["state"]]))
- if rule.get("log") and not have_rule.get("log"):
- cmds.append(("set", rbase + ["log"]))
- for endpoint in ["source", "destination"]:
- want_ep = rule.get(endpoint) or {}
- have_ep = have_rule.get(endpoint) or {}
- if want_ep.get("address") and want_ep["address"] != have_ep.get("address"):
- cmds.append(("set", rbase + [endpoint, "address", want_ep["address"]]))
- if want_ep.get("port") and want_ep["port"] != have_ep.get("port"):
- cmds.append(("set", rbase + [endpoint, "port", str(want_ep["port"])]))
+def _hook_filter_from_device(hook, filter_data):
+ filter_data = dict(filter_data or {})
+ rules_raw = filter_data.pop("rule", None) or {}
+ entry = {"hook": hook, **from_device(filter_data)}
+ if rules_raw:
+ entry["rules"] = _rules_from_device(rules_raw)
+ return entry
- return cmds
+def _want_to_device(config):
+ result = {}
+ for entry in config or []:
+ afi = entry["afi"]
+ hooks = entry.get("hooks") or []
+ if not hooks:
+ continue
+ result[afi] = {h["hook"]: {"filter": _hook_filter_to_device(h)} for h in hooks}
+ return result
-def _hook_cmds(afi, hook_entry, have_hook, state):
- cmds = []
- hook = hook_entry["hook"]
- hbase = _BASE + [afi, hook, "filter"]
- have_hook = have_hook or {}
- if hook_entry.get("default_action") and hook_entry["default_action"] != have_hook.get(
- "default_action",
- ):
- cmds.append(("set", hbase + ["default-action", hook_entry["default_action"]]))
- if hook_entry.get("description") and hook_entry["description"] != have_hook.get("description"):
- cmds.append(("set", hbase + ["description", hook_entry["description"]]))
+def get_running_config(vyos):
+ return vyos.get_config(_BASE) or {}
- have_rules = {r["number"]: r for r in (have_hook.get("rules") or [])}
- want_rules = {r["number"]: r for r in (hook_entry.get("rules") or [])}
- if state == "replaced":
- for num in set(have_rules) - set(want_rules):
- cmds.append(("delete", hbase + ["rule", str(num)]))
+def _device_to_argspec(raw):
+ raw = raw or {}
+ result = []
+ for afi in _AFIS:
+ afi_raw = raw.get(afi) or {}
+ hooks = []
+ for hook in _HOOKS:
+ filter_data = (afi_raw.get(hook) or {}).get("filter")
+ if filter_data:
+ hooks.append(_hook_filter_from_device(hook, filter_data))
+ if hooks:
+ result.append({"afi": afi, "hooks": hooks})
+ return result
- for num, rule in want_rules.items():
- cmds += _rule_cmds(afi, hook, rule, have_rules.get(num))
- return cmds
+# ---------------------------------------------------------------------------
+# Command building — dict_op scoped to _BASE + [afi, hook, "filter"] only,
+# per hook, never a blanket op at _BASE + [afi] or _BASE itself (which
+# would risk vyos_firewall_rules's firewall.<afi>.name subtree, even
+# though today the keys happen to differ -- staying scoped to the exact
+# owned path is the same discipline established for the BGP modules).
+# ---------------------------------------------------------------------------
-def build_commands(config, have_list, state):
- cmds = []
+def build_commands(config, raw_have, state):
+ raw_have = raw_have or {}
+ config = config or []
+ norm_have = normalize_have(raw_have, _TAG_KEYS)
if state == "deleted":
- if not config:
- if have_list:
- for entry in have_list:
- afi = entry["afi"]
- for hook_entry in entry.get("hooks", []):
- cmds.append(("delete", _BASE + [afi, hook_entry["hook"], "filter"]))
- else:
- have_map = {(e["afi"], h["hook"]): h for e in have_list for h in e.get("hooks", [])}
- for entry in config:
- afi = entry["afi"]
- for hook_entry in entry.get("hooks") or []:
- if (afi, hook_entry["hook"]) in have_map:
- cmds.append(("delete", _BASE + [afi, hook_entry["hook"], "filter"]))
- return cmds
-
- have_map = {e["afi"]: {h["hook"]: h for h in e.get("hooks", [])} for e in have_list}
-
- if state == "overridden":
- want_keys = {(e["afi"], h["hook"]) for e in (config or []) for h in e.get("hooks", [])}
- for e in have_list:
- for h in e.get("hooks", []):
- if (e["afi"], h["hook"]) not in want_keys:
- cmds.append(("delete", _BASE + [e["afi"], h["hook"], "filter"]))
+ commands = []
+ # No config given -> delete every hook filter currently present.
+ # Config given -> delete only the (afi, hook) pairs it names.
+ targets = (
+ [(afi, hook) for afi in _AFIS for hook in _HOOKS]
+ if not config
+ else [(e["afi"], h["hook"]) for e in config for h in (e.get("hooks") or [])]
+ )
+ for afi, hook in targets:
+ if ((raw_have.get(afi) or {}).get(hook) or {}).get("filter"):
+ commands.append(("delete", _BASE + [afi, hook, "filter"]))
+ return commands
- for entry in config or []:
- afi = entry["afi"]
- have_afi = have_map.get(afi, {})
+ want = _want_to_device(config)
+ commands = []
- for hook_entry in entry.get("hooks") or []:
- hook = hook_entry["hook"]
- have_hook = have_afi.get(hook)
+ if state == "overridden":
+ want_pairs = {(afi, hook) for afi, hooks in want.items() for hook in hooks}
+ for afi in _AFIS:
+ for hook in _HOOKS:
+ if (afi, hook) not in want_pairs and (
+ (raw_have.get(afi) or {}).get(hook) or {}
+ ).get(
+ "filter",
+ ):
+ commands.append(("delete", _BASE + [afi, hook, "filter"]))
- if state == "replaced" and have_hook:
- want_cmds = _hook_cmds(afi, hook_entry, {}, "merged")
- have_hook_entry = {
- "hook": hook,
- "default_action": have_hook.get("default_action"),
- "rules": have_hook.get("rules", []),
- }
- have_cmds = _hook_cmds(afi, have_hook_entry, {}, "merged")
- if want_cmds != have_cmds:
- cmds.append(("delete", _BASE + [afi, hook, "filter"]))
- have_hook = None
+ for afi, hooks in want.items():
+ for hook, want_hook in hooks.items():
+ hbase = _BASE + [afi, hook, "filter"]
+ have_filter = ((norm_have.get(afi) or {}).get(hook) or {}).get("filter") or {}
+ want_filter = want_hook.get("filter", {})
- effective_state = state if state not in ("replaced", "overridden") else "merged"
- cmds += _hook_cmds(afi, hook_entry, have_hook, effective_state)
+ if state in ("replaced", "overridden"):
+ commands += dict_op(want_filter, have_filter, hbase, op="purge")
+ commands += dict_op(want_filter, have_filter, hbase, op="set")
- return cmds
+ return commands
ARGUMENT_SPEC = dict(
@@ -444,12 +413,13 @@ 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)
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)
@@ -460,7 +430,7 @@ def main():
module.exit_json(
changed=True,
before=have,
- after=get_running_config(vyos),
+ after=_device_to_argspec(get_running_config(vyos)),
commands=commands,
saved=saved,
response=response,
diff --git a/plugins/modules/vyos_firewall_rules.py b/plugins/modules/vyos_firewall_rules.py
index a1c7c01..daf3915 100644
--- a/plugins/modules/vyos_firewall_rules.py
+++ b/plugins/modules/vyos_firewall_rules.py
@@ -127,6 +127,10 @@ notes:
- C(ansible_network_os) must be set to C(vyos.rest.vyos).
- Rule sets are identified by AFI and name. Deleting a rule set removes
all its rules.
+ - The C(group) suboption can only reference an address-group. VyOS also
+ supports network-group/port-group/domain-group references, which this
+ module can read back (via C(gathered)) if already configured by other
+ means, but cannot create -- the argspec has no group-type discriminator.
"""
EXAMPLES = r"""
@@ -185,248 +189,196 @@ gathered:
type: list
saved:
description: Whether the config was saved after changes.
- returned: when changes are applied
+ returned: when changed
type: bool
response:
description: Raw API response.
- returned: when changes are applied
+ returned: always
type: dict
"""
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,
+ dict_op,
+ from_device,
+ normalize_have,
+)
_BASE = ["firewall"]
-_AFIS = ["ipv4", "ipv6"]
-
-
-def _parse_rule(rule_num, data):
- rule = {"number": int(rule_num)}
- data = data or {}
- if "action" in data:
- rule["action"] = data["action"]
- if "description" in data:
- rule["description"] = data["description"]
- if "disable" in data:
- rule["disable"] = True
- if "protocol" in data:
- rule["protocol"] = data["protocol"]
- if "state" in data:
- rule["state"] = data["state"]
- if "log" in data:
- rule["log"] = True
-
- src = data.get("source", {}) or {}
- if src:
- rule["source"] = {}
- if "address" in src:
- rule["source"]["address"] = src["address"]
- if "group" in src:
- grp = src["group"]
- if isinstance(grp, dict):
- rule["source"]["group"] = list(grp.values())[0] if grp else None
- else:
- rule["source"]["group"] = grp
- if "port" in src:
- rule["source"]["port"] = src["port"]
-
- dst = data.get("destination", {}) or {}
- if dst:
- rule["destination"] = {}
- if "address" in dst:
- rule["destination"]["address"] = dst["address"]
- if "group" in dst:
- grp = dst["group"]
- if isinstance(grp, dict):
- rule["destination"]["group"] = list(grp.values())[0] if grp else None
- else:
- rule["destination"]["group"] = grp
- if "port" in dst:
- rule["destination"]["port"] = dst["port"]
-
- icmp = data.get("icmp", {}) or {}
- if icmp:
- rule["icmp"] = {}
- if "type" in icmp:
- rule["icmp"]["type"] = int(icmp["type"])
- if "code" in icmp:
- rule["icmp"]["code"] = int(icmp["code"])
-
- return rule
-
-
-def _parse_rule_set(rs_name, data):
- rs = {"name": rs_name}
- data = data or {}
- if "default-action" in data:
- rs["default_action"] = data["default-action"]
- if "description" in data:
- rs["description"] = data["description"]
- rules_raw = data.get("rule", {}) or {}
- if rules_raw and isinstance(rules_raw, dict):
- rules = [
- _parse_rule(num, rdata)
- for num, rdata in sorted(
- rules_raw.items(),
- key=lambda x: int(x[0]),
- )
- ]
- if rules:
- rs["rules"] = rules
- return rs
+_AFIS = ("ipv4", "ipv6")
+
+# Tag nodes VyOS's REST API can collapse to a bare string/list for a
+# single entry with no other config -- "name" (rule sets, keyed by name)
+# and "rule" (rules, keyed by number).
+_TAG_KEYS = {"name", "rule"}
+
+
+# ---------------------------------------------------------------------------
+# want -> device / device -> argspec
+#
+# Every leaf here matches the device shape directly (action, description,
+# disable, protocol, state, log, icmp.type/code) except one: "group".
+# VyOS wraps a group reference under a literal group-kind key
+# (address-group/network-group/...), not a flat value -- see the module
+# note above on why this module can only ever *write* address-group.
+# Rule-set/rule tag-node reshaping (keyed by name/number) is the other
+# unavoidable structural work.
+# ---------------------------------------------------------------------------
+
+
+def _endpoint_to_device(ep):
+ entry = autoclean({k: v for k, v in ep.items() if k != "group"})
+ if ep.get("group"):
+ entry["group"] = {"address-group": ep["group"]}
+ return entry
+
+
+def _endpoint_from_device(data):
+ data = dict(data or {})
+ group = data.pop("group", None)
+ entry = from_device(data)
+ if isinstance(group, dict) and group:
+ entry["group"] = list(group.values())[0]
+ elif isinstance(group, str):
+ entry["group"] = group
+ return entry
+
+
+def _rules_to_device(rules):
+ result = {}
+ for r in rules or []:
+ entry = autoclean(
+ {k: v for k, v in r.items() if k not in ("number", "source", "destination")},
+ )
+ for endpoint in ("source", "destination"):
+ if r.get(endpoint):
+ entry[endpoint] = _endpoint_to_device(r[endpoint])
+ result[str(r["number"])] = entry
+ return result
-def get_running_config(vyos):
+def _rules_from_device(raw):
result = []
- for afi in _AFIS:
- raw = vyos.get_config(_BASE + [afi, "name"])
- if not raw or not isinstance(raw, dict):
- continue
- # unwrap "name" key if present
- raw = raw.get("name", raw)
- if not raw or not isinstance(raw, dict):
- continue
- rule_sets = [_parse_rule_set(name, data) for name, data in sorted(raw.items())]
- if rule_sets:
- result.append({"afi": afi, "rule_sets": rule_sets})
+ for num, data in sorted((raw or {}).items(), key=lambda kv: int(kv[0])):
+ data = dict(data or {})
+ src = data.pop("source", None)
+ dst = data.pop("destination", None)
+ entry = {"number": int(num), **from_device(data)}
+ if src:
+ entry["source"] = _endpoint_from_device(src)
+ if dst:
+ entry["destination"] = _endpoint_from_device(dst)
+ result.append(entry)
return result
-def _rule_cmds(rs_name, afi, rule, have_rule):
- cmds = []
- rbase = _BASE + [afi, "name", rs_name, "rule", str(rule["number"])]
- have_rule = have_rule or {}
-
- if rule.get("action") and rule["action"] != have_rule.get("action"):
- cmds.append(("set", rbase + ["action", rule["action"]]))
- if rule.get("description") and rule["description"] != have_rule.get("description"):
- cmds.append(("set", rbase + ["description", rule["description"]]))
- if rule.get("disable") and not have_rule.get("disable"):
- cmds.append(("set", rbase + ["disable"]))
- if rule.get("protocol") and rule["protocol"] != have_rule.get("protocol"):
- cmds.append(("set", rbase + ["protocol", rule["protocol"]]))
- if rule.get("state") and rule["state"] != have_rule.get("state"):
- cmds.append(("set", rbase + ["state", rule["state"]]))
- if rule.get("log") and not have_rule.get("log"):
- cmds.append(("set", rbase + ["log"]))
-
- for endpoint in ["source", "destination"]:
- want_ep = rule.get(endpoint) or {}
- have_ep = have_rule.get(endpoint) or {}
- if want_ep.get("address") and want_ep["address"] != have_ep.get("address"):
- cmds.append(("set", rbase + [endpoint, "address", want_ep["address"]]))
- if want_ep.get("port") and want_ep["port"] != have_ep.get("port"):
- cmds.append(("set", rbase + [endpoint, "port", str(want_ep["port"])]))
- if want_ep.get("group") and want_ep["group"] != have_ep.get("group"):
- cmds.append(
- (
- "set",
- rbase
- + [
- endpoint,
- "group",
- "address-group",
- want_ep["group"],
- ],
- ),
- )
+def _rule_set_to_device(rs):
+ entry = autoclean({k: v for k, v in rs.items() if k not in ("name", "rules")})
+ if rs.get("rules"):
+ entry["rule"] = _rules_to_device(rs["rules"])
+ return entry
- icmp = rule.get("icmp") or {}
- have_icmp = have_rule.get("icmp") or {}
- if icmp.get("type") and icmp["type"] != have_icmp.get("type"):
- cmds.append(("set", rbase + ["icmp", "type", str(icmp["type"])]))
- if icmp.get("code") and icmp["code"] != have_icmp.get("code"):
- cmds.append(("set", rbase + ["icmp", "code", str(icmp["code"])]))
- return cmds
+def _rule_set_from_device(name, data):
+ data = dict(data or {})
+ rules_raw = data.pop("rule", None) or {}
+ entry = {"name": name, **from_device(data)}
+ if rules_raw:
+ entry["rules"] = _rules_from_device(rules_raw)
+ return entry
-def _rule_set_cmds(afi, rs, have_rs, state):
- cmds = []
- rs_name = rs["name"]
- rsbase = _BASE + [afi, "name", rs_name]
- have_rs = have_rs or {}
+def _want_to_device(config):
+ result = {}
+ for entry in config or []:
+ afi = entry["afi"]
+ rule_sets = entry.get("rule_sets") or []
+ if not rule_sets:
+ continue
+ result[afi] = {rs["name"]: _rule_set_to_device(rs) for rs in rule_sets}
+ return result
- if rs.get("default_action") and rs["default_action"] != have_rs.get("default_action"):
- cmds.append(("set", rsbase + ["default-action", rs["default_action"]]))
- if rs.get("description") and rs["description"] != have_rs.get("description"):
- cmds.append(("set", rsbase + ["description", rs["description"]]))
- have_rules = {r["number"]: r for r in (have_rs.get("rules") or [])}
- want_rules = {r["number"]: r for r in (rs.get("rules") or [])}
+def get_running_config(vyos):
+ """Fetch each AFI's rule-set subtree directly at firewall.<afi>.name --
+ the most targeted path available, deliberately not a broader fetch at
+ firewall.<afi> or firewall itself (which would pull in the hook-filter
+ and group subtrees owned by sibling modules for no benefit here).
+ """
+ result = {}
+ for afi in _AFIS:
+ raw = vyos.get_config(_BASE + [afi, "name"])
+ if raw and isinstance(raw, dict):
+ # Some VyOS REST responses wrap the result in an extra "name"
+ # key even when fetched at a path already ending in "name";
+ # unwrap defensively either way.
+ raw = raw.get("name", raw)
+ if raw and isinstance(raw, dict):
+ result[afi] = raw
+ return result
- if state == "replaced":
- for num in set(have_rules) - set(want_rules):
- cmds.append(("delete", rsbase + ["rule", str(num)]))
- for num, rule in want_rules.items():
- cmds += _rule_cmds(rs_name, afi, rule, have_rules.get(num))
+def _device_to_argspec(raw):
+ raw = raw or {}
+ result = []
+ for afi in _AFIS:
+ afi_raw = raw.get(afi) or {}
+ rule_sets = [_rule_set_from_device(name, data) for name, data in sorted(afi_raw.items())]
+ if rule_sets:
+ result.append({"afi": afi, "rule_sets": rule_sets})
+ return result
- return cmds
+# ---------------------------------------------------------------------------
+# Command building — dict_op scoped to _BASE + [afi, "name", rs_name] per
+# rule set, never a blanket op at _BASE + [afi] or _BASE itself (which
+# would risk vyos_firewall_interfaces's hook-filter subtree and
+# vyos_firewall_global's group subtree under the same "firewall" root).
+# ---------------------------------------------------------------------------
-def build_commands(config, have_list, state):
- cmds = []
+
+def build_commands(config, raw_have, state):
+ raw_have = raw_have or {}
+ config = config or []
+ norm_have = {afi: normalize_have(data, _TAG_KEYS) for afi, data in raw_have.items()}
if state == "deleted":
+ commands = []
if not config:
- if have_list:
- cmds.append(("delete", _BASE))
+ for afi, rule_sets in raw_have.items():
+ for name in rule_sets:
+ commands.append(("delete", _BASE + [afi, "name", name]))
else:
- have_map = {
- (e["afi"], rs["name"]): rs for e in have_list for rs in e.get("rule_sets", [])
- }
for entry in config:
afi = entry["afi"]
for rs in entry.get("rule_sets") or []:
- if (afi, rs["name"]) in have_map:
- cmds.append(("delete", _BASE + [afi, "name", rs["name"]]))
- return cmds
+ if rs["name"] in (raw_have.get(afi) or {}):
+ commands.append(("delete", _BASE + [afi, "name", rs["name"]]))
+ return commands
- have_map = {e["afi"]: {rs["name"]: rs for rs in e.get("rule_sets", [])} for e in have_list}
+ want = _want_to_device(config)
+ commands = []
if state == "overridden":
- want_keys = {
- (e["afi"], rs["name"]) for e in (config or []) for rs in e.get("rule_sets", [])
- }
- for e in have_list:
- for rs in e.get("rule_sets", []):
- if (e["afi"], rs["name"]) not in want_keys:
- cmds.append(("delete", _BASE + [e["afi"], "name", rs["name"]]))
+ want_keys = {(afi, name) for afi, rule_sets in want.items() for name in rule_sets}
+ for afi, rule_sets in raw_have.items():
+ for name in rule_sets:
+ if (afi, name) not in want_keys:
+ commands.append(("delete", _BASE + [afi, "name", name]))
- for entry in config or []:
- afi = entry["afi"]
- have_afi = have_map.get(afi, {})
-
- for rs in entry.get("rule_sets") or []:
- have_rs = have_afi.get(rs["name"])
-
- if state == "replaced" and have_rs:
- # delete and rebuild if different
- want_cmds = _rule_set_cmds(afi, rs, {}, "merged")
- have_cmds = _rule_set_cmds(
- afi,
- {
- "name": rs["name"],
- "default_action": have_rs.get("default_action"),
- "rules": have_rs.get("rules", []),
- },
- {},
- "merged",
- )
- if want_cmds != have_cmds:
- cmds.append(("delete", _BASE + [afi, "name", rs["name"]]))
- have_rs = None
-
- cmds += _rule_set_cmds(
- afi,
- rs,
- have_rs,
- state if state not in ("replaced", "overridden") else "merged",
- )
-
- return cmds
+ for afi, rule_sets in want.items():
+ for name, want_rs in rule_sets.items():
+ rsbase = _BASE + [afi, "name", name]
+ have_rs = (norm_have.get(afi) or {}).get(name) or {}
+
+ if state in ("replaced", "overridden"):
+ commands += dict_op(want_rs, have_rs, rsbase, op="purge")
+ commands += dict_op(want_rs, have_rs, rsbase, op="set")
+
+ return commands
ARGUMENT_SPEC = dict(
@@ -517,12 +469,13 @@ 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)
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)
@@ -533,7 +486,7 @@ def main():
module.exit_json(
changed=True,
before=have,
- after=get_running_config(vyos),
+ after=_device_to_argspec(get_running_config(vyos)),
commands=commands,
saved=saved,
response=response,
diff --git a/plugins/modules/vyos_ha.py b/plugins/modules/vyos_ha.py
new file mode 100644
index 0000000..ff1705f
--- /dev/null
+++ b/plugins/modules/vyos_ha.py
@@ -0,0 +1,783 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+# GNU General Public License v3.0+
+from __future__ import absolute_import, division, print_function
+
+
+__metaclass__ = type
+
+DOCUMENTATION = r"""
+module: vyos_ha
+short_description: Manage VRRP and load balancer configuration on VyOS via REST API
+description:
+- Manages VRRP groups, global VRRP parameters, sync-groups, virtual servers, and LVS
+ real servers on VyOS devices via the REST API.
+- Uses REST API (C(connection=httpapi)) instead of CLI.
+- Targets VyOS 1.4+.
+version_added: 1.0.0
+author:
+- Evgeny Molotkov (@omnom62)
+options:
+ config:
+ description: High-availability configuration.
+ type: dict
+ suboptions:
+ disable:
+ description: Disable all high-availability configuration.
+ type: bool
+ default: false
+ virtual_servers:
+ description: List of load balancer virtual server definitions.
+ type: list
+ elements: dict
+ suboptions:
+ name:
+ type: str
+ required: true
+ description: Name.
+ address:
+ type: str
+ description: Address.
+ algorithm:
+ type: str
+ description: Algorithm.
+ delay_loop:
+ type: int
+ description: Delay loop.
+ forward_method:
+ type: str
+ choices:
+ - direct
+ - nat
+ description: Forward method.
+ fwmark:
+ type: int
+ description: Fwmark.
+ persistence_timeout:
+ type: int
+ description: Persistence timeout.
+ port:
+ type: int
+ description: Port.
+ protocol:
+ type: str
+ choices:
+ - tcp
+ - udp
+ description: Protocol.
+ real_server:
+ type: list
+ elements: dict
+ suboptions:
+ address:
+ type: str
+ required: true
+ description: Address.
+ port:
+ type: int
+ description: Port.
+ connection_timeout:
+ type: int
+ description: Connection timeout.
+ health_check_script:
+ type: str
+ description: Health check script.
+ description: Real server.
+ vrrp:
+ description: VRRP configuration.
+ type: dict
+ suboptions:
+ global_parameters:
+ type: dict
+ suboptions:
+ garp:
+ type: dict
+ suboptions:
+ interval:
+ type: int
+ description: Interval.
+ master_delay:
+ type: int
+ description: Master delay.
+ master_refresh:
+ type: int
+ description: Master refresh.
+ master_refresh_repeat:
+ type: int
+ description: Master refresh repeat.
+ master_repeat:
+ type: int
+ description: Master repeat.
+ description: Garp.
+ startup_delay:
+ type: int
+ description: Startup delay.
+ version:
+ type: str
+ description: Version.
+ description: Global parameters.
+ groups:
+ type: list
+ elements: dict
+ suboptions:
+ name:
+ type: str
+ required: true
+ description: Name.
+ address:
+ type: list
+ elements: str
+ description: Address.
+ advertise_interval:
+ type: int
+ description: Advertise interval.
+ authentication:
+ type: dict
+ suboptions:
+ password:
+ type: str
+ description: Password.
+ type:
+ type: str
+ description: Type.
+ description: Authentication.
+ description:
+ type: str
+ description: Description.
+ disable:
+ type: bool
+ default: false
+ description: Disable.
+ excluded_address:
+ type: list
+ elements: str
+ description: Excluded address.
+ garp:
+ type: dict
+ suboptions:
+ interval:
+ type: int
+ description: Interval.
+ master_delay:
+ type: int
+ description: Master delay.
+ master_refresh:
+ type: int
+ description: Master refresh.
+ master_refresh_repeat:
+ type: int
+ description: Master refresh repeat.
+ master_repeat:
+ type: int
+ description: Master repeat.
+ description: Garp.
+ health_check:
+ type: dict
+ suboptions:
+ failure_count:
+ type: int
+ description: Failure count.
+ interval:
+ type: int
+ description: Interval.
+ ping:
+ type: str
+ description: Ping.
+ script:
+ type: str
+ description: Script.
+ description: Health check.
+ hello_source_address:
+ type: str
+ description: Hello source address.
+ interface:
+ type: str
+ description: Interface.
+ no_preempt:
+ type: bool
+ default: false
+ description: No preempt.
+ peer_address:
+ type: str
+ description: Peer address.
+ preempt_delay:
+ type: int
+ description: Preempt delay.
+ priority:
+ type: int
+ description: Priority.
+ rfc3768_compatibility:
+ type: bool
+ default: false
+ description: Rfc3768 compatibility.
+ track:
+ type: dict
+ suboptions:
+ exclude_vrrp_interface:
+ type: bool
+ description: Exclude vrrp interface.
+ interface:
+ type: list
+ elements: str
+ description: Interface.
+ description: Track.
+ transition_script:
+ type: dict
+ suboptions:
+ backup:
+ type: str
+ description: Backup.
+ fault:
+ type: str
+ description: Fault.
+ master:
+ type: str
+ description: Master.
+ stop:
+ type: str
+ description: Stop.
+ description: Transition script.
+ vrid:
+ type: int
+ description: Vrid.
+ description: Groups.
+ snmp:
+ type: str
+ choices:
+ - enabled
+ - disabled
+ description: Snmp.
+ sync_groups:
+ type: list
+ elements: dict
+ suboptions:
+ name:
+ type: str
+ required: true
+ description: Name.
+ health_check:
+ type: dict
+ suboptions:
+ failure_count:
+ type: int
+ description: Failure count.
+ interval:
+ type: int
+ description: Interval.
+ ping:
+ type: str
+ description: Ping.
+ script:
+ type: str
+ description: Script.
+ description: Health check.
+ member:
+ type: list
+ elements: str
+ description: Member.
+ transition_script:
+ type: dict
+ suboptions:
+ backup:
+ type: str
+ description: Backup.
+ fault:
+ type: str
+ description: Fault.
+ master:
+ type: str
+ description: Master.
+ stop:
+ type: str
+ description: Stop.
+ description: Transition script.
+ description: Sync groups.
+ state:
+ description: Desired end state of the configuration.
+ type: str
+ choices:
+ - merged
+ - replaced
+ - overridden
+ - deleted
+ - gathered
+ default: merged
+
+"""
+
+EXAMPLES = r"""
+- name: Merge VRRP configuration
+ vyos.rest.vyos_ha:
+ config:
+ vrrp:
+ global_parameters:
+ startup_delay: 30
+ groups:
+ - name: g1
+ interface: eth0
+ vrid: 20
+ priority: 100
+ address:
+ - 192.168.1.100/24
+ sync_groups:
+ - name: sg1
+ member: [g1]
+ snmp: enabled
+ state: merged
+
+- name: Delete all HA configuration
+ vyos.rest.vyos_ha:
+ state: deleted
+
+- name: Gather current HA configuration
+ vyos.rest.vyos_ha:
+ state: gathered
+"""
+
+RETURN = r"""
+before:
+ description: HA configuration before this module ran.
+ returned: always
+ type: dict
+after:
+ description: HA configuration after this module ran.
+ returned: when changed
+ type: dict
+commands:
+ description: List of API commands sent to the device.
+ returned: always
+ type: list
+gathered:
+ description: Current HA configuration as structured data.
+ returned: when state is gathered
+ type: dict
+saved:
+ description: Whether the config was saved after changes.
+ returned: when changed
+ type: bool
+"""
+
+from ansible.module_utils.basic import AnsibleModule
+from ansible_collections.vyos.rest.plugins.module_utils.vyos import (
+ VyOSModule,
+ autoclean,
+ cast_by_spec,
+ dict_op,
+ from_device,
+ normalize_have,
+ to_tag_dict,
+)
+
+
+_BASE = ["high-availability"]
+
+ARGUMENT_SPEC = dict(
+ config=dict(
+ type="dict",
+ options=dict(
+ disable=dict(type="bool", default=False),
+ virtual_servers=dict(
+ type="list",
+ elements="dict",
+ options=dict(
+ name=dict(type="str", required=True),
+ address=dict(type="str"),
+ algorithm=dict(type="str"),
+ delay_loop=dict(type="int"),
+ forward_method=dict(type="str", choices=["direct", "nat"]),
+ fwmark=dict(type="int"),
+ persistence_timeout=dict(type="int"),
+ port=dict(type="int"),
+ protocol=dict(type="str", choices=["tcp", "udp"]),
+ real_server=dict(
+ type="list",
+ elements="dict",
+ options=dict(
+ address=dict(type="str", required=True),
+ port=dict(type="int"),
+ connection_timeout=dict(type="int"),
+ health_check_script=dict(type="str"),
+ ),
+ ),
+ ),
+ ),
+ vrrp=dict(
+ type="dict",
+ options=dict(
+ global_parameters=dict(
+ type="dict",
+ options=dict(
+ garp=dict(
+ type="dict",
+ options=dict(
+ interval=dict(type="int"),
+ master_delay=dict(type="int"),
+ master_refresh=dict(type="int"),
+ master_refresh_repeat=dict(type="int"),
+ master_repeat=dict(type="int"),
+ ),
+ ),
+ startup_delay=dict(type="int"),
+ version=dict(type="str"),
+ ),
+ ),
+ groups=dict(
+ type="list",
+ elements="dict",
+ options=dict(
+ name=dict(type="str", required=True),
+ address=dict(type="list", elements="str"),
+ advertise_interval=dict(type="int"),
+ authentication=dict(
+ type="dict",
+ options=dict(
+ password=dict(type="str", no_log=True),
+ type=dict(type="str"),
+ ),
+ ),
+ description=dict(type="str"),
+ disable=dict(type="bool", default=False),
+ excluded_address=dict(type="list", elements="str"),
+ garp=dict(
+ type="dict",
+ options=dict(
+ interval=dict(type="int"),
+ master_delay=dict(type="int"),
+ master_refresh=dict(type="int"),
+ master_refresh_repeat=dict(type="int"),
+ master_repeat=dict(type="int"),
+ ),
+ ),
+ health_check=dict(
+ type="dict",
+ options=dict(
+ failure_count=dict(type="int"),
+ interval=dict(type="int"),
+ ping=dict(type="str"),
+ script=dict(type="str"),
+ ),
+ ),
+ hello_source_address=dict(type="str"),
+ interface=dict(type="str"),
+ no_preempt=dict(type="bool", default=False),
+ peer_address=dict(type="str"),
+ preempt_delay=dict(type="int"),
+ priority=dict(type="int"),
+ rfc3768_compatibility=dict(type="bool", default=False),
+ track=dict(
+ type="dict",
+ options=dict(
+ exclude_vrrp_interface=dict(type="bool"),
+ interface=dict(type="list", elements="str"),
+ ),
+ ),
+ transition_script=dict(
+ type="dict",
+ options=dict(
+ backup=dict(type="str"),
+ fault=dict(type="str"),
+ master=dict(type="str"),
+ stop=dict(type="str"),
+ ),
+ ),
+ vrid=dict(type="int"),
+ ),
+ ),
+ snmp=dict(type="str", choices=["enabled", "disabled"]),
+ sync_groups=dict(
+ type="list",
+ elements="dict",
+ options=dict(
+ name=dict(type="str", required=True),
+ health_check=dict(
+ type="dict",
+ options=dict(
+ failure_count=dict(type="int"),
+ interval=dict(type="int"),
+ ping=dict(type="str"),
+ script=dict(type="str"),
+ ),
+ ),
+ member=dict(type="list", elements="str"),
+ transition_script=dict(
+ type="dict",
+ options=dict(
+ backup=dict(type="str"),
+ fault=dict(type="str"),
+ master=dict(type="str"),
+ stop=dict(type="str"),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ state=dict(
+ default="merged",
+ choices=["merged", "replaced", "overridden", "deleted", "gathered"],
+ ),
+)
+
+_TOP_OPTIONS = ARGUMENT_SPEC["config"]["options"]
+_VS_OPTIONS = _TOP_OPTIONS["virtual_servers"]["options"]
+_RS_OPTIONS = _VS_OPTIONS["real_server"]["options"]
+_VRRP_OPTIONS = _TOP_OPTIONS["vrrp"]["options"]
+_GROUP_OPTIONS = _VRRP_OPTIONS["groups"]["options"]
+_SYNC_GROUP_OPTIONS = _VRRP_OPTIONS["sync_groups"]["options"]
+
+# Tag nodes VyOS's REST API can collapse to a bare string/list for a
+# single entry with no other config. Split by section because "address"
+# means two different things depending on where it appears -- confirmed
+# against vyos-1x: vrrp.group.<name>.address is a genuine tagNode (VRRP
+# virtual IPs, each with real child structure), but virtual-server.
+# <name>.address is a flat scalar string (the load-balancer's own bind
+# address). A single blanket key-name-based coercion across the whole
+# raw tree would wrongly reshape the latter into a tag-node dict --
+# exactly the class of bug this split avoids.
+_VS_TAG_KEYS = {"virtual-server", "real-server"}
+_VRRP_TAG_KEYS = {"group", "sync-group", "address", "excluded-address"}
+
+# track.interface and sync-group.member are NOT included above --
+# confirmed <leafNode><multi/>, i.e. plain multi-value leaves, not tag
+# nodes; dict_op's own native list handling (which already corrects for
+# the same single-value-collapse quirk) applies to them directly, no
+# reshaping needed.
+
+
+# ---------------------------------------------------------------------------
+# Structural adapters — the only genuine exceptions, confirmed against
+# vyos-1x schema, not assumed:
+# 1. Named-object lists (virtual_servers, real_server, groups,
+# sync_groups): argspec uses [{name: "x", ...}], device uses
+# {"x": {...}}.
+# 2. address / excluded_address (VRRP group virtual IPs): genuine
+# tagNodes (each has real child structure) -> {"a": {}, "b": {}}.
+# 3. snmp: argspec "enabled"/"disabled" string <-> device presence node
+# (present) / absent. "disabled" has no device-side representation at
+# all -- see the explicit delete in build_commands().
+# 4. health_check_script: argspec flat field <-> device nested under
+# health-check.script.
+#
+# Everything else -- including track.interface and sync_group.member,
+# both plain multi-value leaves despite superficially looking like the
+# same shape as address/excluded_address -- flows through autoclean/
+# from_device untouched.
+# ---------------------------------------------------------------------------
+
+
+def _real_server_to_device(rs):
+ entry = autoclean(
+ {k: v for k, v in rs.items() if k not in ("address", "health_check_script")},
+ )
+ if rs.get("health_check_script"):
+ entry["health-check"] = {"script": rs["health_check_script"]}
+ return entry
+
+
+def _real_server_from_device(addr, data):
+ data = dict(data or {})
+ hc = data.pop("health-check", None) or {}
+ entry = {"address": addr, **from_device(data)}
+ if hc.get("script"):
+ entry["health_check_script"] = hc["script"]
+ cast_by_spec(entry, _RS_OPTIONS)
+ return entry
+
+
+def _virtual_server_to_device(vs):
+ entry = autoclean({k: v for k, v in vs.items() if k not in ("name", "real_server")})
+ if vs.get("real_server"):
+ entry["real-server"] = {
+ rs["address"]: _real_server_to_device(rs) for rs in vs["real_server"]
+ }
+ return entry
+
+
+def _virtual_server_from_device(name, data):
+ data = dict(data or {})
+ rs_raw = data.pop("real-server", None) or {}
+ entry = {"name": name, **from_device(data)}
+ cast_by_spec(entry, _VS_OPTIONS)
+ if rs_raw:
+ entry["real_server"] = [
+ _real_server_from_device(addr, rdata) for addr, rdata in sorted(rs_raw.items())
+ ]
+ return entry
+
+
+def _group_to_device(grp):
+ entry = autoclean(
+ {k: v for k, v in grp.items() if k not in ("name", "address", "excluded_address")},
+ )
+ if grp.get("address"):
+ entry["address"] = {a: {} for a in grp["address"]}
+ if grp.get("excluded_address"):
+ entry["excluded-address"] = {a: {} for a in grp["excluded_address"]}
+ return entry
+
+
+def _group_from_device(name, data):
+ data = dict(data or {})
+ addr_raw = data.pop("address", None)
+ excl_raw = data.pop("excluded-address", None)
+ entry = {"name": name, **from_device(data)}
+ cast_by_spec(entry, _GROUP_OPTIONS)
+ if addr_raw:
+ entry["address"] = sorted(to_tag_dict(addr_raw).keys())
+ if excl_raw:
+ entry["excluded_address"] = sorted(to_tag_dict(excl_raw).keys())
+ return entry
+
+
+def _sync_group_from_device(name, data):
+ entry = {"name": name, **from_device(data or {})}
+ cast_by_spec(entry, _SYNC_GROUP_OPTIONS)
+ return entry
+
+
+def _want_to_device(config):
+ if not config:
+ return {}
+ want = autoclean({k: v for k, v in config.items() if k not in ("virtual_servers", "vrrp")})
+
+ if config.get("virtual_servers"):
+ want["virtual-server"] = {
+ vs["name"]: _virtual_server_to_device(vs) for vs in config["virtual_servers"]
+ }
+
+ vrrp = config.get("vrrp") or {}
+ if vrrp:
+ vrrp_dev = autoclean(
+ {k: v for k, v in vrrp.items() if k not in ("groups", "sync_groups", "snmp")},
+ )
+ # snmp: "enabled" -> presence node; "disabled" has no device-side
+ # form at all (handled via an explicit delete in build_commands).
+ if vrrp.get("snmp") == "enabled":
+ vrrp_dev["snmp"] = {}
+ if vrrp.get("groups"):
+ vrrp_dev["group"] = {g["name"]: _group_to_device(g) for g in vrrp["groups"]}
+ if vrrp.get("sync_groups"):
+ vrrp_dev["sync-group"] = {
+ sg["name"]: autoclean({k: v for k, v in sg.items() if k != "name"})
+ for sg in vrrp["sync_groups"]
+ }
+ if vrrp_dev:
+ want["vrrp"] = vrrp_dev
+
+ return want
+
+
+def get_running_config(vyos):
+ return vyos.get_config(_BASE) or {}
+
+
+def _device_to_argspec(raw):
+ if not raw:
+ return {}
+ result = from_device({k: v for k, v in raw.items() if k not in ("virtual-server", "vrrp")})
+ cast_by_spec(result, _TOP_OPTIONS)
+
+ vs_raw = raw.get("virtual-server") or {}
+ if vs_raw:
+ result["virtual_servers"] = [
+ _virtual_server_from_device(name, data) for name, data in sorted(vs_raw.items())
+ ]
+
+ vrrp_raw = raw.get("vrrp") or {}
+ if vrrp_raw:
+ vrrp_arg = from_device(
+ {k: v for k, v in vrrp_raw.items() if k not in ("group", "sync-group", "snmp")},
+ )
+ cast_by_spec(vrrp_arg, _VRRP_OPTIONS)
+ if "snmp" in vrrp_raw:
+ vrrp_arg["snmp"] = "enabled"
+
+ grp_raw = vrrp_raw.get("group") or {}
+ if grp_raw:
+ vrrp_arg["groups"] = [
+ _group_from_device(name, data) for name, data in sorted(grp_raw.items())
+ ]
+
+ sg_raw = vrrp_raw.get("sync-group") or {}
+ if sg_raw:
+ vrrp_arg["sync_groups"] = [
+ _sync_group_from_device(name, data) for name, data in sorted(sg_raw.items())
+ ]
+
+ if vrrp_arg:
+ result["vrrp"] = vrrp_arg
+
+ return result
+
+
+def build_commands(config, raw_have, state):
+ raw_have = raw_have or {}
+ config = config or {}
+
+ if state == "deleted":
+ return [("delete", _BASE)] if raw_have else []
+
+ want = _want_to_device(config)
+ norm_have = {k: v for k, v in raw_have.items() if k not in ("virtual-server", "vrrp")}
+ if raw_have.get("virtual-server"):
+ norm_have["virtual-server"] = normalize_have(raw_have, _VS_TAG_KEYS)["virtual-server"]
+ if raw_have.get("vrrp"):
+ norm_have["vrrp"] = normalize_have(raw_have, _VRRP_TAG_KEYS)["vrrp"]
+
+ commands = []
+ if state == "overridden":
+ commands += dict_op(want, norm_have, _BASE, op="purge")
+ elif state == "replaced":
+ for section, section_want in want.items():
+ section_have = norm_have.get(section, {})
+ commands += dict_op(section_want, section_have, _BASE + [section], op="purge")
+ commands += dict_op(want, norm_have, _BASE, op="set")
+
+ # snmp "disabled" has no device-side value to compare against --
+ # it's the absence of the presence node, which dict_op's set/purge
+ # logic can't express as a "delete" on its own. Handled explicitly.
+ if (config.get("vrrp") or {}).get("snmp") == "disabled":
+ if "snmp" in (raw_have.get("vrrp") or {}):
+ commands.append(("delete", _BASE + ["vrrp", "snmp"]))
+
+ return commands
+
+
+def main():
+ module = AnsibleModule(ARGUMENT_SPEC, supports_check_mode=True)
+ vyos = VyOSModule(module)
+
+ state = module.params["state"]
+ config = module.params.get("config") or {}
+
+ raw_have = get_running_config(vyos)
+ have = _device_to_argspec(raw_have)
+
+ if state == "gathered":
+ module.exit_json(changed=False, gathered=have)
+
+ commands = build_commands(config, raw_have, state)
+
+ if module.check_mode:
+ module.exit_json(changed=bool(commands), commands=commands, before=have)
+
+ if commands:
+ response = vyos.apply_commands(commands)
+ saved = vyos.save_config()
+ after = _device_to_argspec(get_running_config(vyos))
+ module.exit_json(
+ changed=True,
+ before=have,
+ after=after,
+ commands=commands,
+ saved=saved,
+ response=response,
+ )
+
+ module.exit_json(changed=False, before=have, after=have, commands=[])
+
+
+if __name__ == "__main__":
+ main()
diff --git a/plugins/modules/vyos_logging_global.py b/plugins/modules/vyos_logging_global.py
index f18ba3f..4a89a12 100644
--- a/plugins/modules/vyos_logging_global.py
+++ b/plugins/modules/vyos_logging_global.py
@@ -1,7 +1,6 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
-# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
-
+# GNU General Public License v3.0+
from __future__ import absolute_import, division, print_function
@@ -13,8 +12,7 @@ module: vyos_logging_global
short_description: Manage syslog configuration on VyOS devices using REST API
description:
- Manages syslog (logging) configuration on VyOS devices via the REST API.
- - Supports console, file, host, user, and global logging targets with
- per-target facility and severity configuration.
+ - Targets VyOS 1.5+ syslog schema under C(system syslog).
- Uses REST API (C(connection=httpapi)) instead of CLI.
version_added: "1.0.0"
author:
@@ -40,51 +38,12 @@ options:
severity:
description: Minimum severity level to log (e.g. err, debug, all).
type: str
- files:
- description: Logging to local files.
- type: list
- elements: dict
- suboptions:
- path:
- description: Path to the log file on the device.
- type: str
- archive:
- description: Log file archive/rotation settings.
- type: dict
- suboptions:
- file_num:
- description: Number of archived log files to keep.
- type: int
- size:
- description: Maximum size of log file in kilobytes before rotation.
- type: int
- facilities:
- description: List of syslog facilities to log to this file.
- type: list
- elements: dict
- suboptions:
- facility:
- description: Syslog facility name.
- type: str
- severity:
- description: Minimum severity level to log.
- type: str
global_params:
- description: Global syslog parameters (maps to C(system syslog global)).
+ description: Global syslog parameters (maps to C(system syslog local) on device).
type: dict
suboptions:
- archive:
- description: Global log archive/rotation settings.
- type: dict
- suboptions:
- file_num:
- description: Number of archived log files to keep.
- type: int
- size:
- description: Maximum size of log file in kilobytes before rotation.
- type: int
facilities:
- description: List of syslog facilities for global logging.
+ description: List of syslog facilities for local logging.
type: list
elements: dict
suboptions:
@@ -101,7 +60,7 @@ options:
description: Use the fully qualified domain name in syslog messages.
type: bool
hosts:
- description: Logging to remote syslog hosts.
+ description: Logging to remote syslog hosts (maps to C(system syslog remote)).
type: list
elements: dict
suboptions:
@@ -148,9 +107,6 @@ options:
description: Minimum severity level to send.
type: str
- running_config:
- description: Used only with state C(parsed).
- type: str
state:
description:
@@ -163,8 +119,6 @@ options:
- overridden
- deleted
- gathered
- - rendered
- - parsed
"""
EXAMPLES = r"""
@@ -175,30 +129,18 @@ EXAMPLES = r"""
facilities:
- facility: local7
severity: err
- files:
- - path: logFile
- archive:
- file_num: 2
- facilities:
- - facility: local6
- severity: emerg
hosts:
- hostname: 172.16.0.1
- port: 223
+ port: 514
facilities:
- facility: local7
severity: all
- - facility: all
- protocol: udp
users:
- username: vyos
facilities:
- facility: local7
severity: debug
global_params:
- archive:
- file_num: 2
- size: 111
facilities:
- facility: cron
severity: debug
@@ -234,223 +176,184 @@ gathered:
type: dict
saved:
description: Result of save_config after applying changes.
- returned: when changes are applied
+ returned: when changed
type: dict
"""
from ansible.module_utils.basic import AnsibleModule
-from ansible_collections.vyos.rest.plugins.module_utils.vyos import VyOSModule
-
-
-# ------------------------------------------------------------
-# Normalization
-# ------------------------------------------------------------
-
-
-def normalize_config(cfg):
- result = {
- "console": {"facilities": {}},
- "global": {"facilities": {}},
- "hosts": {},
- "files": {},
- "users": {},
- }
-
- for f in cfg.get("console", {}).get("facilities", []):
- result["console"]["facilities"][f["facility"]] = f.get("severity")
-
- gp = cfg.get("global_params", {})
- for f in gp.get("facilities", []):
- result["global"]["facilities"][f["facility"]] = f.get("severity")
- if gp.get("archive"):
- result["global"]["archive"] = gp["archive"]
- if gp.get("marker_interval"):
- result["global"]["marker_interval"] = gp["marker_interval"]
- if gp.get("preserve_fqdn"):
- result["global"]["preserve_fqdn"] = True
-
- for h in cfg.get("hosts", []):
- host = {"port": h.get("port"), "facilities": {}}
- for f in h.get("facilities", []):
- host["facilities"][f["facility"]] = {k: v for k, v in f.items() if k != "facility"}
- result["hosts"][h["hostname"]] = host
-
- for f in cfg.get("files", []):
- facilities = {x["facility"]: x.get("severity") for x in f.get("facilities", [])}
- result["files"][f["path"]] = {
- "archive": f.get("archive"),
- "facilities": facilities,
- }
-
- for u in cfg.get("users", []):
- result["users"][u["username"]] = {
- "facilities": {f["facility"]: f.get("severity") for f in u.get("facilities", [])},
- }
-
- return result
-
-
-def normalize_running(raw):
- result = {
- "console": {"facilities": {}},
- "global": {"facilities": {}},
- "hosts": {},
- "files": {},
- "users": {},
- }
-
- if not raw:
- return result
-
- for f, data in raw.get("console", {}).get("facility", {}).items():
- result["console"]["facilities"][f] = data.get("level")
-
- g = raw.get("local", {})
- for f, data in g.get("facility", {}).items():
- result["global"]["facilities"][f] = data.get("level")
- if "archive" in g:
- result["global"]["archive"] = g["archive"]
- if "marker" in g and "interval" in g["marker"]:
- result["global"]["marker_interval"] = g["marker"]["interval"]
- if "preserve-fqdn" in g:
- result["global"]["preserve_fqdn"] = True
-
- for host, data in raw.get("remote", {}).items():
- h = {"port": data.get("port"), "facilities": {}}
- for f, fd in data.get("facility", {}).items():
- h["facilities"][f] = {
- "severity": fd.get("level"),
- "protocol": fd.get("protocol"),
- }
- result["hosts"][host] = h
-
- for path, data in raw.get("file", {}).items():
- facilities = {}
- for f, fd in data.get("facility", {}).items():
- facilities[f] = fd.get("level")
- result["files"][path] = {
- "archive": data.get("archive"),
- "facilities": facilities,
- }
-
- for user, data in raw.get("user", {}).items():
- facilities = {}
- for f, fd in data.get("facility", {}).items():
- facilities[f] = fd.get("level")
- result["users"][user] = {"facilities": facilities}
-
- return result
+from ansible_collections.vyos.rest.plugins.module_utils.vyos import (
+ VyOSModule,
+ dict_op,
+)
-# ------------------------------------------------------------
-# Diff helpers
-# ------------------------------------------------------------
+_BASE = ["system", "syslog"]
-def diff_facilities(base, want, have, state):
- cmds = []
- want_keys = set(want)
- have_keys = set(have)
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
- for f in want_keys:
- if f not in have_keys or want[f] != have[f]:
- path = base + ["facility", f]
- if want[f]:
- path += ["level", want[f]]
- cmds.append(("set", path))
- if state in ["replaced", "deleted"]:
- for f in have_keys - want_keys:
- cmds.append(("delete", base + ["facility", f]))
-
- return cmds
-
-
-def diff_map(base, want, have, state):
- cmds = []
- w = set(want)
- h = set(have)
-
- if state in ["merged", "replaced"]:
- for k in w - h:
- cmds.append(("set", base + [k]))
-
- if state in ["replaced", "deleted"]:
- for k in h - w:
- cmds.append(("delete", base + [k]))
-
- return cmds
-
-
-# ------------------------------------------------------------
-# Build commands
-# ------------------------------------------------------------
-
-
-def build_commands(want, have, state):
- cmds = []
-
- if state == "overridden":
- cmds.append(("delete", ["system", "syslog"]))
- state = "merged"
-
- cmds += diff_facilities(
- ["system", "syslog", "console"],
- want["console"]["facilities"],
- have["console"]["facilities"],
- state,
- )
-
- cmds += diff_facilities(
- ["system", "syslog", "local"],
- want["global"]["facilities"],
- have["global"]["facilities"],
- state,
- )
-
- cmds += diff_map(
- ["system", "syslog", "file"],
- want["files"],
- have["files"],
- state,
- )
-
- cmds += diff_map(
- ["system", "syslog", "remote"],
- want["hosts"],
- have["hosts"],
- state,
- )
-
- cmds += diff_map(
- ["system", "syslog", "user"],
- want["users"],
- have["users"],
- state,
- )
+def _fac_list_to_device(facilities):
+ """Convert [{facility, severity, protocol}] -> {"name": {"level": s, ...}}"""
+ result = {}
+ for fac in facilities or []:
+ name = fac["facility"]
+ entry = {}
+ if fac.get("severity"):
+ entry["level"] = fac["severity"]
+ if fac.get("protocol"):
+ entry["protocol"] = fac["protocol"]
+ result[name] = entry
+ return result
- return cmds
+def _fac_device_to_list(raw_fac):
+ """Convert {"name": {"level": s}} -> [{facility, severity}]"""
+ if not raw_fac or not isinstance(raw_fac, dict):
+ return []
+ result = []
+ for name, data in sorted(raw_fac.items()):
+ entry = {"facility": name}
+ if isinstance(data, dict):
+ if data.get("level"):
+ entry["severity"] = data["level"]
+ if data.get("protocol"):
+ entry["protocol"] = data["protocol"]
+ result.append(entry)
+ return result
-# ------------------------------------------------------------
-# Running config
-# ------------------------------------------------------------
+# ---------------------------------------------------------------------------
+# Shape adapters
+# ---------------------------------------------------------------------------
+
+
+def _want_to_device(config):
+ """Convert argspec config to device shape for dict_op.
+
+ VyOS 1.5 syslog schema:
+ system syslog console facility <f> level <s>
+ system syslog local facility <f> level <s> (was: global)
+ system syslog remote <host> facility <f> ... (was: host)
+ system syslog user <u> facility <f> ...
+ system syslog marker interval <n> (was: global marker)
+ system syslog preserve-fqdn (was: global preserve-fqdn)
+ NOTE: file and archive are removed in VyOS 1.5
+ """
+ if not config:
+ return {}
+ want = {}
+
+ # console
+ console = config.get("console") or {}
+ if console.get("facilities"):
+ want["console"] = {"facility": _fac_list_to_device(console["facilities"])}
+
+ # global_params -> local + top-level marker/preserve-fqdn
+ gp = config.get("global_params") or {}
+ if gp:
+ if gp.get("facilities"):
+ want["local"] = {"facility": _fac_list_to_device(gp["facilities"])}
+ if gp.get("marker_interval") is not None:
+ want["marker"] = {"interval": gp["marker_interval"]}
+ if gp.get("preserve_fqdn"):
+ want["preserve-fqdn"] = {}
+
+ # hosts -> remote (keyed by hostname)
+ for h in config.get("hosts") or []:
+ hd = {}
+ if h.get("port") is not None:
+ hd["port"] = h["port"]
+ if h.get("protocol"):
+ hd["protocol"] = h["protocol"]
+ if h.get("facilities"):
+ hd["facility"] = _fac_list_to_device(h["facilities"])
+ want.setdefault("remote", {})[h["hostname"]] = hd
+
+ # users -> user (keyed by username)
+ for u in config.get("users") or []:
+ ud = {}
+ if u.get("facilities"):
+ ud["facility"] = _fac_list_to_device(u["facilities"])
+ want.setdefault("user", {})[u["username"]] = ud
+
+ return want
+
+
+def _device_to_argspec(raw):
+ """Convert raw device response to argspec shape for before/after/gathered."""
+ if not raw:
+ return {}
+ result = {}
+
+ # console
+ console = raw.get("console") or {}
+ if console:
+ facs = _fac_device_to_list(console.get("facility"))
+ if facs:
+ result["console"] = {"facilities": facs}
+
+ # local -> global_params
+ local = raw.get("local") or {}
+ marker = raw.get("marker") or {}
+ preserve_fqdn = "preserve-fqdn" in raw
+ if local or marker or preserve_fqdn:
+ gp = {}
+ facs = _fac_device_to_list(local.get("facility") if isinstance(local, dict) else {})
+ if facs:
+ gp["facilities"] = facs
+ if isinstance(marker, dict) and "interval" in marker:
+ gp["marker_interval"] = marker["interval"]
+ if preserve_fqdn:
+ gp["preserve_fqdn"] = True
+ if gp:
+ result["global_params"] = gp
+
+ # remote -> hosts
+ remote_raw = raw.get("remote") or {}
+ if remote_raw and isinstance(remote_raw, dict):
+ hosts = []
+ for hostname, data in sorted(remote_raw.items()):
+ h = {"hostname": hostname}
+ if isinstance(data, dict):
+ if data.get("port") is not None:
+ h["port"] = data["port"]
+ if data.get("protocol"):
+ h["protocol"] = data["protocol"]
+ facs = _fac_device_to_list(data.get("facility"))
+ if facs:
+ h["facilities"] = facs
+ hosts.append(h)
+ if hosts:
+ result["hosts"] = hosts
+
+ # user -> users
+ user_raw = raw.get("user") or {}
+ if user_raw and isinstance(user_raw, dict):
+ users = []
+ for username, data in sorted(user_raw.items()):
+ u = {"username": username}
+ if isinstance(data, dict):
+ facs = _fac_device_to_list(data.get("facility"))
+ if facs:
+ u["facilities"] = facs
+ users.append(u)
+ if users:
+ result["users"] = users
-def get_running_config(vyos):
- raw = vyos.get_config(["system", "syslog"])
- return normalize_running(raw)
+ return result
-# ------------------------------------------------------------
+# ---------------------------------------------------------------------------
# Main
-# ------------------------------------------------------------
+# ---------------------------------------------------------------------------
def main():
argument_spec = dict(
config=dict(type="dict"),
- running_config=dict(type="str"),
state=dict(
default="merged",
choices=[
@@ -459,8 +362,6 @@ def main():
"overridden",
"deleted",
"gathered",
- "rendered",
- "parsed",
],
),
)
@@ -471,22 +372,34 @@ def main():
state = module.params["state"]
config = module.params.get("config") or {}
+ raw_have = vyos.get_config(_BASE)
+ have = _device_to_argspec(raw_have)
+
if state == "gathered":
- module.exit_json(gathered=get_running_config(vyos))
+ module.exit_json(changed=False, gathered=have)
- want = normalize_config(config)
- have = get_running_config(vyos)
+ want_device = _want_to_device(config)
if state == "deleted":
- want = {
- "console": {"facilities": {}},
- "global": {"facilities": {}},
- "hosts": {},
- "files": {},
- "users": {},
- }
-
- commands = build_commands(want, have, state)
+ commands = [("delete", _BASE)] if raw_have else []
+ elif state == "overridden":
+ commands = []
+ for section in list(raw_have.keys()):
+ if section not in want_device:
+ commands.append(("delete", _BASE + [section]))
+ else:
+ commands += dict_op(
+ want_device[section],
+ raw_have[section],
+ _BASE + [section],
+ op="purge",
+ )
+ commands += dict_op(want_device, raw_have, _BASE, op="set")
+ else:
+ commands = []
+ if state == "replaced":
+ commands += dict_op(want_device, raw_have, _BASE, op="purge")
+ commands += dict_op(want_device, raw_have, _BASE, op="set")
if module.check_mode:
module.exit_json(changed=bool(commands), commands=commands, before=have)
@@ -494,10 +407,11 @@ def main():
if commands:
response = vyos.apply_commands(commands)
saved = vyos.save_config()
+ after = {} if state == "deleted" else _device_to_argspec(vyos.get_config(_BASE))
module.exit_json(
changed=True,
before=have,
- after=want,
+ after=after,
commands=commands,
saved=saved,
response=response,
diff --git a/plugins/modules/vyos_nat.py b/plugins/modules/vyos_nat.py
new file mode 100644
index 0000000..ae8fea0
--- /dev/null
+++ b/plugins/modules/vyos_nat.py
@@ -0,0 +1,492 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+# GNU General Public License v3.0+
+from __future__ import absolute_import, division, print_function
+
+
+__metaclass__ = type
+
+DOCUMENTATION = r"""
+---
+module: vyos_nat
+short_description: Manage NAT configuration on VyOS devices using REST API
+description:
+ - Manages NAT configuration on VyOS devices via the REST API.
+ - Supports source, destination, static, CGNAT, NAT64, and NAT66.
+ - Uses REST API (C(connection=httpapi)) instead of CLI.
+ - Targets VyOS 1.5+.
+version_added: "1.0.0"
+author:
+ - Evgeny Molotkov (@omnom62)
+options:
+ config:
+ description: NAT configuration.
+ type: dict
+ state:
+ description:
+ - The desired state of the NAT configuration.
+ type: str
+ default: merged
+ choices: [merged, replaced, overridden, deleted, gathered]
+"""
+
+EXAMPLES = r"""
+- name: Merge source NAT rule
+ vyos.rest.vyos_nat:
+ config:
+ nat:
+ source:
+ rule:
+ - id: 100
+ outbound_interface:
+ name: eth0
+ translation:
+ address: masquerade
+ state: merged
+
+- name: Delete all NAT
+ vyos.rest.vyos_nat:
+ state: deleted
+
+- name: Gather NAT configuration
+ vyos.rest.vyos_nat:
+ state: gathered
+"""
+
+RETURN = r"""
+before:
+ description: NAT configuration before this module ran.
+ returned: always
+ type: dict
+after:
+ description: NAT configuration after this module ran.
+ returned: when changed
+ type: dict
+commands:
+ description: List of API commands sent to the device.
+ returned: always
+ type: list
+gathered:
+ description: Current NAT configuration as structured data.
+ returned: when state is gathered
+ type: dict
+saved:
+ description: Whether the config was saved after changes.
+ returned: when changed
+ type: bool
+"""
+
+from ansible.module_utils.basic import AnsibleModule
+from ansible_collections.vyos.rest.plugins.module_utils.vyos import (
+ VyOSModule,
+ autoclean,
+ dict_op,
+ from_device,
+ normalize_have,
+ to_tag_dict,
+)
+
+
+_NAT_TYPES = ("nat", "nat64", "nat66")
+
+# "rule" (any NAT rule set) and "backend" (load-balance) are genuine tag
+# nodes everywhere they appear -- unambiguous. "range" is NOT included
+# here: confirmed against vyos-1x that it means two different things
+# depending on parent -- cgnat.pool.external.<name>.range is a tagNode
+# (has a nested "seq" leaf), but cgnat.pool.internal.<name>.range is a
+# plain multi-value leafNode (<multi/>). Handling that context-sensitive
+# case generically by key name alone would silently corrupt one or the
+# other, so it's handled explicitly in _normalize_cgnat_have() instead.
+_TAG_KEYS = {"rule", "backend"}
+
+
+# ---------------------------------------------------------------------------
+# load_balance.backend / nat64 translation.pool — the only two genuine
+# structural exceptions in this module (confirmed tagNodes with nested
+# substructure). Every other field (destination/source/translation/
+# match, inbound/outbound-interface, exclude, disable, description,
+# protocol, packet_type, load_balance.hash) is a direct structural match
+# and flows through autoclean/from_device untouched. hash in particular
+# stays a plain list -- it's a multi-value leafNode, not a tag node, so
+# dict_op's own native list handling applies to it directly.
+# ---------------------------------------------------------------------------
+
+
+def _backend_to_device(backends):
+ return {b["ip"]: autoclean({k: v for k, v in b.items() if k != "ip"}) for b in backends or []}
+
+
+def _backend_from_device(raw):
+ result = []
+ for ip, data in sorted((raw or {}).items()):
+ entry = {"ip": ip, **from_device(data or {})}
+ if "weight" in entry:
+ entry["weight"] = int(entry["weight"])
+ result.append(entry)
+ return result
+
+
+def _pool_to_device(pools):
+ return {str(p["id"]): autoclean({k: v for k, v in p.items() if k != "id"}) for p in pools or []}
+
+
+def _pool_from_device(raw):
+ return [
+ {"id": int(pid), **from_device(data or {})}
+ for pid, data in sorted((raw or {}).items(), key=lambda kv: int(kv[0]))
+ ]
+
+
+def _rule_to_device(rule):
+ entry = autoclean(
+ {k: v for k, v in rule.items() if k not in ("id", "load_balance", "translation")},
+ )
+
+ lb = rule.get("load_balance")
+ if lb:
+ lb_entry = autoclean({k: v for k, v in lb.items() if k != "backend"})
+ if lb.get("backend"):
+ lb_entry["backend"] = _backend_to_device(lb["backend"])
+ entry["load_balance"] = lb_entry
+
+ translation = rule.get("translation")
+ if translation:
+ t_entry = autoclean({k: v for k, v in translation.items() if k != "pool"})
+ if translation.get("pool"):
+ t_entry["pool"] = _pool_to_device(translation["pool"])
+ entry["translation"] = t_entry
+
+ return entry
+
+
+def _rule_from_device(raw):
+ raw = raw or {}
+ entry = from_device({k: v for k, v in raw.items() if k not in ("load-balance", "translation")})
+
+ lb_raw = raw.get("load-balance")
+ if lb_raw:
+ lb_entry = from_device({k: v for k, v in lb_raw.items() if k != "backend"})
+ # "hash" is a multi-value leafNode; the device can collapse a
+ # single value to a bare string. from_device() only does
+ # kebab->snake translation, not type coercion, so fix that up
+ # explicitly here (there's no ARGUMENT_SPEC for cast_by_spec to
+ # derive this from -- config is a bare type=dict in this module).
+ if isinstance(lb_entry.get("hash"), str):
+ lb_entry["hash"] = [lb_entry["hash"]]
+ if lb_raw.get("backend"):
+ lb_entry["backend"] = _backend_from_device(lb_raw["backend"])
+ entry["load_balance"] = lb_entry
+
+ t_raw = raw.get("translation")
+ if t_raw:
+ t_entry = from_device({k: v for k, v in t_raw.items() if k != "pool"})
+ if t_raw.get("pool"):
+ t_entry["pool"] = _pool_from_device(t_raw["pool"])
+ entry["translation"] = t_entry
+
+ return entry
+
+
+def _rules_to_device(rules):
+ return {str(r["id"]): _rule_to_device(r) for r in rules or []}
+
+
+def _rules_from_device(raw):
+ return [
+ {"id": int(rid), **_rule_from_device(data or {})}
+ for rid, data in sorted((raw or {}).items(), key=lambda kv: int(kv[0]))
+ ]
+
+
+# ---------------------------------------------------------------------------
+# CGNAT — cgnat.pool.external.<name>.range is a genuine tag node
+# (confirmed: nested "seq" leaf); cgnat.pool.internal.<name>.range is a
+# plain multi-value leafNode (confirmed <multi/>). Fixing the real bug
+# here: the previous implementation only checked isinstance(str)/
+# isinstance(dict) for internal range and silently dropped it whenever
+# the device returned the actual real shape -- a plain list.
+# ---------------------------------------------------------------------------
+
+
+def _cgnat_pool_external_to_device(pools):
+ result = {}
+ for p in pools or []:
+ entry = autoclean({k: v for k, v in p.items() if k not in ("name", "range")})
+ if p.get("range"):
+ entry["range"] = {
+ r["value"]: ({"seq": r["seq"]} if r.get("seq") is not None else {})
+ for r in p["range"]
+ }
+ result[p["name"]] = entry
+ return result
+
+
+def _cgnat_pool_external_from_device(raw):
+ result = []
+ for name, data in sorted((raw or {}).items()):
+ data = data or {}
+ p = {"name": name, **from_device({k: v for k, v in data.items() if k != "range"})}
+ rng = data.get("range")
+ if rng:
+ rng_dict = to_tag_dict(rng)
+ p["range"] = [
+ (
+ {"value": v, "seq": int(d["seq"])}
+ if isinstance(d, dict) and d.get("seq")
+ else {"value": v}
+ )
+ for v, d in sorted(rng_dict.items())
+ ]
+ result.append(p)
+ return result
+
+
+def _cgnat_pool_internal_to_device(pools):
+ return {p["name"]: {"range": list(p["range"])} for p in pools or [] if p.get("range")}
+
+
+def _cgnat_pool_internal_from_device(raw):
+ result = []
+ for name, data in sorted((raw or {}).items()):
+ rng = (data or {}).get("range")
+ p = {"name": name}
+ if rng:
+ # Confirmed real bug in the previous implementation: it only
+ # checked isinstance(str)/isinstance(dict) here, silently
+ # dropping "range" entirely whenever the device returned the
+ # actual real shape for >1 value -- a plain list.
+ p["range"] = [rng] if isinstance(rng, str) else list(rng)
+ result.append(p)
+ return result
+
+
+# The two known CGNAT pool kinds and their handlers, declared once. Both
+# are unavoidable exceptions -- "external" pool range is a tag node
+# (confirmed: nested "seq" leaf), "internal" pool range is a plain
+# multi-value leaf (confirmed <multi/>), same key name, genuinely
+# different device shape, not discoverable by walking the JSON alone.
+# What's NOT necessary is repeating "if pool.get(kind)" per kind inline
+# -- one table declares the exception, both directions read it.
+_CGNAT_POOL_KINDS = {
+ "external": (_cgnat_pool_external_to_device, _cgnat_pool_external_from_device),
+ "internal": (_cgnat_pool_internal_to_device, _cgnat_pool_internal_from_device),
+}
+
+
+def _cgnat_pool_to_device(pool):
+ return {
+ kind: to_fn(pool[kind])
+ for kind, (to_fn, _from_fn) in _CGNAT_POOL_KINDS.items()
+ if pool.get(kind)
+ }
+
+
+def _cgnat_pool_from_device(pool_raw):
+ return {
+ kind: from_fn(pool_raw[kind])
+ for kind, (_to_fn, from_fn) in _CGNAT_POOL_KINDS.items()
+ if pool_raw.get(kind)
+ }
+
+
+def _normalize_cgnat_have(cgnat_raw):
+ """Like normalize_have(), but external/internal pool "range" needs
+ different treatment despite sharing a key name -- see _TAG_KEYS.
+ """
+ if not cgnat_raw or not isinstance(cgnat_raw, dict):
+ return {}
+ result = normalize_have(cgnat_raw, _TAG_KEYS)
+ ext_raw = (cgnat_raw.get("pool") or {}).get("external")
+ if ext_raw:
+ ext_norm = {}
+ for name, data in ext_raw.items():
+ data = dict(data or {})
+ if "range" in data:
+ data["range"] = to_tag_dict(data["range"])
+ ext_norm[name] = data
+ result.setdefault("pool", {})["external"] = ext_norm
+ return result
+
+
+def _cgnat_to_device(cgnat):
+ if not cgnat:
+ return {}
+ entry = autoclean({k: v for k, v in cgnat.items() if k not in ("pool", "rule")})
+ pool = cgnat.get("pool") or {}
+ pool_entry = _cgnat_pool_to_device(pool)
+ if pool_entry:
+ entry["pool"] = pool_entry
+ if cgnat.get("rule"):
+ entry["rule"] = _rules_to_device(cgnat["rule"])
+ return entry
+
+
+def _cgnat_from_device(raw):
+ raw = raw or {}
+ entry = from_device({k: v for k, v in raw.items() if k not in ("pool", "rule")})
+ pool_entry = _cgnat_pool_from_device(raw.get("pool") or {})
+ if pool_entry:
+ entry["pool"] = pool_entry
+ if raw.get("rule"):
+ entry["rule"] = _rules_from_device(raw["rule"])
+ return entry
+
+
+# ---------------------------------------------------------------------------
+# want -> device / device -> argspec (top level)
+# ---------------------------------------------------------------------------
+
+
+# Which sections are valid under each NAT type, and whether it has a
+# cgnat subtree (only "nat" does) -- declared once so _want_to_device and
+# _device_to_argspec each need a single loop instead of three near-
+# identical hand-written blocks per NAT type.
+_NAT_TYPE_SECTIONS = {
+ "nat": ("destination", "source", "static"),
+ "nat64": ("source",),
+ "nat66": ("destination", "source"),
+}
+
+
+def _want_to_device(config):
+ if not config:
+ return {}
+ result = {}
+ for nat_type, sections in _NAT_TYPE_SECTIONS.items():
+ nat = config.get(nat_type) or {}
+ if not nat:
+ continue
+ nat_dev = {}
+ if nat_type == "nat" and nat.get("cgnat"):
+ nat_dev["cgnat"] = _cgnat_to_device(nat["cgnat"])
+ for section in sections:
+ rules = (nat.get(section) or {}).get("rule")
+ if rules:
+ nat_dev[section] = {"rule": _rules_to_device(rules)}
+ if nat_dev:
+ result[nat_type] = nat_dev
+ return result
+
+
+def _device_to_argspec(raw_all):
+ if not raw_all:
+ return {}
+ result = {}
+ for nat_type, sections in _NAT_TYPE_SECTIONS.items():
+ nat = raw_all.get(nat_type) or {}
+ if not nat:
+ continue
+ nat_arg = {}
+ if nat_type == "nat" and nat.get("cgnat"):
+ nat_arg["cgnat"] = _cgnat_from_device(nat["cgnat"])
+ for section in sections:
+ rules = (nat.get(section) or {}).get("rule")
+ if rules:
+ nat_arg[section] = {"rule": _rules_from_device(rules)}
+ if nat_arg:
+ result[nat_type] = nat_arg
+ return result
+
+
+# ---------------------------------------------------------------------------
+# Main
+# ---------------------------------------------------------------------------
+
+
+def _get_raw(vyos):
+ """Retrieve all NAT config from device."""
+ result = {}
+ for nat_type in _NAT_TYPES:
+ raw = vyos.get_config([nat_type])
+ if raw:
+ result[nat_type] = raw
+ return result
+
+
+def _normalize_nat_have(raw_have, nat_type):
+ """normalize_have() for a given NAT type's have data, with "nat"'s
+ cgnat section handled by the range-context-aware
+ _normalize_cgnat_have() instead of the generic pass (which would
+ mishandle internal-pool range -- see _TAG_KEYS).
+ """
+ nat_raw = raw_have.get(nat_type, {})
+ result = normalize_have(nat_raw, _TAG_KEYS)
+ if nat_type == "nat" and nat_raw.get("cgnat"):
+ result["cgnat"] = _normalize_cgnat_have(nat_raw["cgnat"])
+ return result
+
+
+def main():
+ argument_spec = dict(
+ config=dict(type="dict"),
+ state=dict(
+ default="merged",
+ choices=["merged", "replaced", "overridden", "deleted", "gathered"],
+ ),
+ )
+
+ module = AnsibleModule(argument_spec, supports_check_mode=True)
+ vyos = VyOSModule(module)
+
+ state = module.params["state"]
+ config = module.params.get("config") or {}
+
+ raw_have = _get_raw(vyos)
+ have = _device_to_argspec(raw_have)
+
+ if state == "gathered":
+ module.exit_json(changed=False, gathered=have)
+
+ want_device = _want_to_device(config)
+
+ if state == "deleted":
+ commands = []
+ if not config:
+ for nat_type in _NAT_TYPES:
+ if raw_have.get(nat_type):
+ commands.append(("delete", [nat_type]))
+ else:
+ for nat_type in _NAT_TYPES:
+ if config.get(nat_type) and raw_have.get(nat_type):
+ commands.append(("delete", [nat_type]))
+ elif state == "overridden":
+ commands = []
+ for nat_type in _NAT_TYPES:
+ nat_want = want_device.get(nat_type, {})
+ nat_have_norm = _normalize_nat_have(raw_have, nat_type)
+ base = [nat_type]
+ commands += dict_op(nat_want, nat_have_norm, base, op="purge")
+ commands += dict_op(nat_want, nat_have_norm, base, op="set")
+ else:
+ commands = []
+ for nat_type in _NAT_TYPES:
+ nat_want = want_device.get(nat_type, {})
+ nat_have_norm = _normalize_nat_have(raw_have, nat_type)
+ base = [nat_type]
+ if state == "replaced":
+ for section, section_want in nat_want.items():
+ section_have = nat_have_norm.get(section, {})
+ commands += dict_op(section_want, section_have, base + [section], op="purge")
+ commands += dict_op(nat_want, nat_have_norm, base, op="set")
+
+ if module.check_mode:
+ module.exit_json(changed=bool(commands), commands=commands, before=have)
+
+ if commands:
+ response = vyos.apply_commands(commands)
+ saved = vyos.save_config()
+ after = _device_to_argspec(_get_raw(vyos))
+ module.exit_json(
+ changed=True,
+ before=have,
+ after=after,
+ commands=commands,
+ saved=saved,
+ response=response,
+ )
+
+ module.exit_json(changed=False, before=have, after=have, commands=[])
+
+
+if __name__ == "__main__":
+ main()
diff --git a/plugins/modules/vyos_ntp_global.py b/plugins/modules/vyos_ntp_global.py
index dd5fe4e..e308035 100644
--- a/plugins/modules/vyos_ntp_global.py
+++ b/plugins/modules/vyos_ntp_global.py
@@ -63,12 +63,6 @@ options:
- ptp
- interleave
- running_config:
- description:
- - Used only with state C(parsed).
- - Provide the output of C(show configuration commands | grep ntp).
- type: str
-
state:
description:
- The desired state of the NTP configuration.
@@ -80,8 +74,6 @@ options:
- overridden
- deleted
- gathered
- - rendered
- - parsed
"""
EXAMPLES = r"""
@@ -137,194 +129,139 @@ gathered:
description: Current NTP configuration as structured data.
returned: when state is gathered
type: dict
-rendered:
- description: CLI commands generated for the provided config (offline).
- returned: when state is rendered
- type: list
-parsed:
- description: Structured data parsed from running_config.
- returned: when state is parsed
- type: dict
saved:
description: Whether the config was saved after changes.
- returned: when changes are applied
+ returned: when changed
type: bool
"""
from ansible.module_utils.basic import AnsibleModule
-from ansible_collections.vyos.rest.plugins.module_utils.utils import normalize_to_list
-from ansible_collections.vyos.rest.plugins.module_utils.vyos import VyOSModule
-
-
-def normalize_config(config):
- result = {
- "allow_clients": sorted(config.get("allow_clients") or []),
- "listen_addresses": sorted(config.get("listen_addresses") or []),
- "servers": {},
- }
- for s in config.get("servers") or []:
- name = s["server"]
- result["servers"][name] = sorted(s.get("options") or [])
+from ansible_collections.vyos.rest.plugins.module_utils.vyos import (
+ VyOSModule,
+ dict_op,
+ normalize_have,
+ to_tag_dict,
+)
+
+
+_BASE = ["service", "ntp"]
+
+# "server" is a genuine tag node (keyed by server address) that VyOS's
+# REST API can collapse to a bare value for a single server with no
+# options set.
+_TAG_KEYS = {"server"}
+
+
+def _servers_to_device(servers):
+ """server[].options is the one genuine structural exception here:
+ the argspec wraps per-server options in a named "options" list
+ field, but confirmed against vyos-1x (service_ntp.xml.in) each
+ option (noselect/nts/pool/prefer/ptp/interleave) is a direct
+ valueless leafNode sibling under the server tagNode itself -- there
+ is no "options" wrapper node on the device side at all.
+ """
+ return {s["server"]: {opt: {} for opt in (s.get("options") or [])} for s in servers or []}
+
+
+def _servers_from_device(raw):
+ result = []
+ for name, data in sorted((raw or {}).items()):
+ entry = {"server": name}
+ if data:
+ entry["options"] = sorted(to_tag_dict(data).keys())
+ result.append(entry)
return result
-def normalize_servers(value):
- result = {}
- if isinstance(value, dict):
- for server, data in value.items():
- if isinstance(data, dict):
- result[server] = sorted(list(data.keys()))
- elif isinstance(data, list):
- result[server] = sorted(data)
- elif isinstance(data, str):
- result[server] = [data]
- else:
- result[server] = []
- elif isinstance(value, list):
- for server in value:
- result[server] = []
- elif isinstance(value, str):
- result[value] = []
- return result
+def _want_to_device(config):
+ want = {}
+ if config.get("allow_clients"):
+ # allow_clients is a flat argspec list, but confirmed against
+ # vyos-1x (allow-client.xml.i) the device nests the multi-value
+ # leaf one level deeper, under a literal "address" child --
+ # allow-client itself is a plain grouping node, not the leaf.
+ want["allow-client"] = {"address": list(config["allow_clients"])}
+ if config.get("listen_addresses"):
+ want["listen-address"] = list(config["listen_addresses"])
+ if config.get("servers"):
+ want["server"] = _servers_to_device(config["servers"])
+ return want
def get_running_config(vyos):
- raw = vyos.get_config(["service", "ntp"])
- result = {
- "allow_clients": [],
- "listen_addresses": [],
- "servers": {},
- }
- if not raw:
- return result
-
- # allow-client: handle both VyOS schemas
- # 1.4: {"allow-client": {"address": {"10.x.x.x/y": {}}}}
- # 1.5+: {"allow-client": {"10.x.x.x/y": {}}} (no address subnode)
- allow_raw_outer = raw.get("allow-client", {})
- if "address" in allow_raw_outer:
- allow_raw = allow_raw_outer.get("address", [])
+ return vyos.get_config(_BASE) or {}
+
+
+def _device_to_argspec(raw):
+ raw = raw or {}
+ result = {"allow_clients": [], "listen_addresses": [], "servers": []}
+
+ # allow-client: handle both VyOS schema variants (this module
+ # targets VyOS 1.4+) --
+ # 1.4: {"allow-client": {"address": {...}}}
+ # 1.5+: {"allow-client": {...}} (no "address" subnode observed
+ # on some REST responses)
+ # Confirmed current vyos-1x schema always declares the "address"
+ # child, but this stays defensive for older devices/REST variants.
+ allow_outer = raw.get("allow-client") or {}
+ if isinstance(allow_outer, dict) and "address" in allow_outer:
+ allow_raw = allow_outer["address"]
else:
- allow_raw = allow_raw_outer
- result["allow_clients"] = sorted(normalize_to_list(allow_raw))
+ allow_raw = allow_outer
+ if allow_raw:
+ result["allow_clients"] = sorted(to_tag_dict(allow_raw).keys())
- result["listen_addresses"] = sorted(
- normalize_to_list(raw.get("listen-address", [])),
- )
- result["servers"] = normalize_servers(raw.get("server", {}))
+ listen_raw = raw.get("listen-address")
+ if listen_raw:
+ result["listen_addresses"] = sorted(to_tag_dict(listen_raw).keys())
+
+ result["servers"] = _servers_from_device(raw.get("server"))
return result
-def build_commands(desired, existing, state):
- cmds = []
+def _normalize_allow_client(raw_have):
+ """Ensure allow-client always presents the shape _want_to_device
+ emits and dict_op compares against -- a dict with a plain LIST
+ under "address" -- regardless of which VyOS schema/REST variant the
+ device actually returned (a missing "address" wrapper, or the
+ address values themselves collapsed to a dict-of-presence or a bare
+ string instead of a plain array). This keeps dict_op only ever
+ comparing list-vs-list for this field, the same well-exercised path
+ used throughout the rest of this collection, rather than needing
+ any change to the shared engine for a dict-vs-list case.
+ """
+ allow_outer = raw_have.get("allow-client")
+ if not allow_outer:
+ return raw_have
+
+ if isinstance(allow_outer, dict) and "address" in allow_outer:
+ address_raw = allow_outer["address"]
+ else:
+ address_raw = allow_outer
+
+ raw_have = dict(raw_have)
+ raw_have["allow-client"] = {"address": sorted(to_tag_dict(address_raw).keys())}
+ return raw_have
+
+
+def build_commands(config, raw_have, state):
+ raw_have = _normalize_allow_client(raw_have or {})
+ config = config or {}
if state == "overridden":
state = "replaced"
if state == "deleted":
- if existing["servers"] or existing["allow_clients"] or existing["listen_addresses"]:
- cmds.append(("delete", ["service", "ntp"]))
- return cmds
-
- cmds += diff_list(
- "allow-client",
- "address",
- desired["allow_clients"],
- existing["allow_clients"],
- state,
- )
- cmds += diff_list(
- "listen-address",
- None,
- desired["listen_addresses"],
- existing["listen_addresses"],
- state,
- )
- cmds += diff_servers(desired["servers"], existing["servers"], state)
- return cmds
-
-
-def diff_list(node, subnode, desired, existing, state):
- cmds = []
- desired = set(desired)
- existing = set(existing)
-
- if state in ("merged", "replaced"):
- for v in desired - existing:
- path = ["service", "ntp", node]
- if subnode:
- path += [subnode, v]
- else:
- path += [v]
- cmds.append(("set", path))
-
- if state in ("replaced", "deleted"):
- for v in existing - desired:
- path = ["service", "ntp", node]
- if subnode:
- path += [subnode, v]
- else:
- path += [v]
- cmds.append(("delete", path))
-
- return cmds
-
-
-def diff_servers(desired, existing, state):
- cmds = []
- desired_set = set(desired.keys())
- existing_set = set(existing.keys())
-
- if state in ("merged", "replaced"):
- for server in desired_set:
- desired_opts = set(desired[server])
- existing_opts = set(existing.get(server, []))
- if server not in existing_set:
- cmds.append(("set", ["service", "ntp", "server", server]))
- for opt in desired_opts - existing_opts:
- cmds.append(("set", ["service", "ntp", "server", server, opt]))
- if state == "replaced":
- for opt in existing_opts - desired_opts:
- cmds.append(("delete", ["service", "ntp", "server", server, opt]))
-
- if state in ("replaced", "deleted"):
- for server in existing_set - desired_set:
- cmds.append(("delete", ["service", "ntp", "server", server]))
-
- return cmds
-
-
-def parse_running_config(text):
- result = {"allow_clients": [], "listen_addresses": [], "servers": {}}
- for line in text.splitlines():
- parts = line.strip().split()
- if len(parts) < 4:
- continue
- if parts[3] == "allow-clients":
- result["allow_clients"].append(parts[-1])
- elif parts[3] == "listen-address":
- result["listen_addresses"].append(parts[-1])
- elif parts[3] == "server":
- server = parts[4]
- if server not in result["servers"]:
- result["servers"][server] = []
- if len(parts) > 5:
- result["servers"][server].append(parts[5])
- return result
+ return [("delete", _BASE)] if raw_have else []
+ want = _want_to_device(config)
+ norm_have = normalize_have(raw_have, _TAG_KEYS)
-def render_commands(config):
- cmds = []
- for c in config["allow_clients"]:
- cmds.append("set service ntp allow-client address {c}".format(c=c))
- for la in config["listen_addresses"]:
- cmds.append("set service ntp listen-address {la}".format(la=la))
- for server, opts in config["servers"].items():
- if not opts:
- cmds.append("set service ntp server {s}".format(s=server))
- for opt in opts:
- cmds.append("set service ntp server {s} {o}".format(s=server, o=opt))
- return cmds
+ commands = []
+ if state == "replaced":
+ commands += dict_op(want, norm_have, _BASE, op="purge")
+ commands += dict_op(want, norm_have, _BASE, op="set")
+ return commands
def main():
@@ -357,7 +294,6 @@ def main():
),
),
),
- running_config=dict(type="str"),
state=dict(
default="merged",
choices=[
@@ -366,8 +302,6 @@ def main():
"overridden",
"deleted",
"gathered",
- "rendered",
- "parsed",
],
),
)
@@ -378,40 +312,30 @@ def main():
state = module.params["state"]
config = module.params.get("config") or {}
- if state == "parsed":
- module.exit_json(parsed=parse_running_config(module.params["running_config"]))
-
- desired = normalize_config(config)
-
- if state == "rendered":
- module.exit_json(rendered=render_commands(desired))
-
- existing = get_running_config(vyos)
+ raw_have = get_running_config(vyos)
+ have = _device_to_argspec(raw_have)
if state == "gathered":
- module.exit_json(gathered=existing)
-
- if state == "deleted":
- desired = {"allow_clients": [], "listen_addresses": [], "servers": {}}
+ module.exit_json(gathered=have)
- commands = build_commands(desired, existing, state)
+ commands = build_commands(config, raw_have, state)
if module.check_mode:
- module.exit_json(changed=bool(commands), commands=commands, before=existing)
+ module.exit_json(changed=bool(commands), commands=commands, before=have)
if commands:
response = vyos.apply_commands(commands)
saved = vyos.save_config()
module.exit_json(
changed=True,
- before=existing,
- after=desired,
+ before=have,
+ after=_device_to_argspec(get_running_config(vyos)),
commands=commands,
saved=saved,
response=response,
)
- module.exit_json(changed=False, before=existing, after=existing, commands=[])
+ module.exit_json(changed=False, before=have, after=have, commands=[])
if __name__ == "__main__":
diff --git a/plugins/modules/vyos_route_maps.py b/plugins/modules/vyos_route_maps.py
index 607d1ab..347c58d 100644
--- a/plugins/modules/vyos_route_maps.py
+++ b/plugins/modules/vyos_route_maps.py
@@ -13,6 +13,12 @@ short_description: Manage route-map configuration on VyOS devices using REST API
description:
- Manages route maps on VyOS via the REST API.
- Uses REST API (C(connection=httpapi)) instead of CLI.
+ - >-
+ Covers the commonly used match/set fields (as documented below). VyOS's
+ route-map schema is considerably larger than this (EVPN attributes,
+ extended communities, RPKI matching, on-match goto/next, route-source,
+ source-peer, source-vrf, and more) -- those are not modeled by this
+ module and are a real, documented limitation, not an oversight.
version_added: "1.0.0"
author:
- VyOS Community (@vyos)
@@ -39,6 +45,7 @@ options:
action:
description: Permit or deny.
type: str
+ choices: [permit, deny]
description:
description: Rule description.
type: str
@@ -49,11 +56,166 @@ options:
description: Continue at a different sequence number.
type: int
match:
- description: Match conditions (passed through to VyOS API).
+ description: Match conditions.
type: dict
+ suboptions:
+ interface:
+ description: Interface to match.
+ type: str
+ metric:
+ description: Metric of route to match.
+ type: int
+ origin:
+ description: BGP origin code to match.
+ type: str
+ choices: [egp, igp, incomplete]
+ peer:
+ description: Peer address to match.
+ type: str
+ protocol:
+ description: Match protocol via which the route was learnt.
+ type: str
+ choices:
+ [
+ babel, bgp, connected, isis, kernel, ospf, ospfv3,
+ rip, ripng, static, table, vnc,
+ ]
+ prefix_list:
+ description: IPv4 prefix-list to match.
+ type: str
+ prefix_list6:
+ description: IPv6 prefix-list to match.
+ type: str
+ ip:
+ description: IPv4 next-hop match parameters.
+ type: dict
+ suboptions:
+ nexthop_address:
+ description: IPv4 next-hop address to match.
+ type: str
+ nexthop_prefix_list:
+ description: IPv4 next-hop prefix-list to match.
+ type: str
+ ipv6:
+ description: IPv6 next-hop match parameters.
+ type: dict
+ suboptions:
+ nexthop_address:
+ description: IPv6 next-hop address to match.
+ type: str
set:
- description: Route parameters to set (passed through to VyOS API).
+ description: Route parameters to set.
type: dict
+ suboptions:
+ metric:
+ description: Metric of route.
+ type: int
+ metric_type:
+ description: Metric type.
+ type: str
+ origin:
+ description: BGP origin code to set.
+ type: str
+ choices: [egp, igp, incomplete]
+ originator_id:
+ description: BGP originator ID.
+ type: str
+ src:
+ description: Source address for route.
+ type: str
+ tag:
+ description: Route tag value.
+ type: int
+ weight:
+ description: BGP weight.
+ type: int
+ distance:
+ description: Locally significant administrative distance.
+ type: int
+ table:
+ description: Non-main kernel routing table.
+ type: int
+ local_preference:
+ description: BGP local preference.
+ type: int
+ ip_next_hop:
+ description: IPv4 next-hop address to set.
+ type: str
+ atomic_aggregate:
+ description: Set the BGP atomic aggregate attribute.
+ type: bool
+ as_path_exclude:
+ description: AS number(s) to remove from the as-path attribute.
+ type: str
+ as_path_prepend:
+ description: AS number(s) to prepend to the as-path attribute.
+ type: str
+ as_path_prepend_last_as:
+ description: Number of times to prepend the last AS number in the as-path.
+ type: int
+ aggregator:
+ description: BGP aggregator attribute.
+ type: dict
+ suboptions:
+ as_:
+ description: AS number of an aggregation.
+ type: int
+ aliases: [as]
+ ip:
+ description: IP address of an aggregation.
+ type: str
+ community:
+ description: BGP community attribute.
+ type: dict
+ suboptions:
+ add:
+ description: Communities to add to a prefix.
+ type: list
+ elements: str
+ replace:
+ description: Communities to set for a prefix.
+ type: list
+ elements: str
+ none:
+ description: Completely remove the communities attribute from a prefix.
+ type: bool
+ delete:
+ description: Remove communities defined in a list from a prefix.
+ type: str
+ large_community:
+ description: BGP large community attribute.
+ type: dict
+ suboptions:
+ add:
+ description: Large communities to add to a prefix.
+ type: list
+ elements: str
+ replace:
+ description: Large communities to set for a prefix.
+ type: list
+ elements: str
+ none:
+ description: Completely remove the large-community attribute from a prefix.
+ type: bool
+ delete:
+ description: Remove large communities defined in a list from a prefix.
+ type: str
+ ipv6_next_hop:
+ description: IPv6 next-hop to set.
+ type: dict
+ suboptions:
+ global:
+ description: Nexthop IPv6 global address.
+ type: str
+ local:
+ description: Nexthop IPv6 local address.
+ type: str
+ peer_address:
+ description: Use the peer address (BGP only) as the nexthop.
+ type: bool
+ prefer_global:
+ description: Prefer the global address as the nexthop.
+ type: bool
state:
description:
@@ -70,7 +232,6 @@ options:
notes:
- Requires C(ansible_connection=httpapi) with the VyOS httpapi plugin.
- C(ansible_network_os) must be set to C(vyos.rest.vyos).
- - Input validation is delegated to the VyOS API.
"""
EXAMPLES = r"""
@@ -84,10 +245,10 @@ EXAMPLES = r"""
match:
peer: 192.0.2.32
set:
- metric: "5"
+ metric: 5
as_path_exclude: "111"
aggregator:
- as: 100
+ as_: 100
state: merged
- name: Delete all route maps
@@ -110,320 +271,526 @@ before:
description: Route map configuration before this module ran.
returned: always
type: list
-
after:
description: Route map configuration after this module ran.
returned: when changed
type: list
-
commands:
description: List of API command tuples sent to the device.
returned: always
type: list
-
gathered:
description: Current route map configuration as structured data.
returned: when state is gathered
type: list
-
saved:
description: Whether the config was saved after changes.
- returned: when changes are applied
+ returned: when changed
type: bool
-
response:
description: Raw API response.
- returned: when changes are applied
+ returned: always
type: dict
"""
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 = ["policy", "route-map"]
-_SET_MAP = {
- "as_path_prepend": ["as-path", "prepend"],
- "as_path_exclude": ["as-path", "exclude"],
- "as_path_prepend_last_as": ["as-path", "prepend-last-as"],
- "ip_next_hop": ["ip-next-hop"],
- "local_preference": ["local-preference"],
- "metric": ["metric"],
- "metric_type": ["metric-type"],
- "origin": ["origin"],
- "originator_id": ["originator-id"],
- "src": ["src"],
- "tag": ["tag"],
- "weight": ["weight"],
- "distance": ["distance"],
- "table": ["table"],
-}
-
-_MATCH_MAP = {
- "interface": ["interface"],
- "metric": ["metric"],
- "origin": ["origin"],
- "peer": ["peer"],
- "protocol": ["protocol"],
-}
+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: both named-list sections in this
+ argspec (route maps, rules) already mark exactly one suboption
+ required=True. Deriving it here means the key field is asserted to
+ exist by the argspec itself, not duplicated in a place that could
+ drift out of sync with it.
+ """
+ required = [k for k, spec in options_spec.items() if spec.get("required")]
+ if len(required) != 1:
+ raise ValueError(
+ "expected exactly one required suboption to serve as the key field, "
+ "found: {0}".format(required),
+ )
+ return required[0]
+
+
+def _keyed_list_to_device(items, key_field, entry_transform=None):
+ """A list of dicts, each identified by key_field's value, becomes a
+ device dict keyed by that value -- the one structural mechanic
+ every named-list section in this module needs. entry_transform
+ supplies whatever else is genuinely irreducible for a given section
+ (a nested reshape) -- defaulting to the generic recursive walker.
+ """
+ entry_transform = entry_transform or autoclean
+ 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 get_running_config(vyos):
- raw = vyos.get_config(["policy", "route-map"])
- if not raw or not isinstance(raw, dict):
- return []
- rm_data = raw.get("route-map", raw)
- if not isinstance(rm_data, dict):
- return []
+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())
+ ]
- result = []
- for rm_name, rm_info in sorted(rm_data.items()):
- entry = {"route_map": rm_name, "entries": []}
- rm_info = rm_info or {}
-
- for seq, rule_data in sorted(
- (rm_info.get("rule") or {}).items(),
- key=lambda x: int(x[0]),
- ):
- rule_data = rule_data or {}
- rule = {"sequence": int(seq)}
- if rule_data.get("action"):
- rule["action"] = rule_data["action"]
- if rule_data.get("description"):
- rule["description"] = rule_data["description"]
- if rule_data.get("call"):
- rule["call"] = rule_data["call"]
- if rule_data.get("continue"):
- rule["continue_sequence"] = int(rule_data["continue"])
- if rule_data.get("match"):
- rule["match"] = rule_data["match"]
- if rule_data.get("set"):
- rule["set"] = rule_data["set"]
- entry["entries"].append(rule)
-
- result.append(entry)
- return result
+# ---------------------------------------------------------------------------
+# match.prefix_list / match.prefix_list6 -- confirmed genuine structural
+# insertions: the argspec has these as flat fields, but the device
+# nests them two levels down (ip.address.prefix-list /
+# ipv6.address.prefix-list).
+#
+# match.ip.nexthop_address / nexthop_prefix_list, match.ipv6.
+# nexthop_address -- confirmed the device nests these ONE level
+# deeper than the argspec (ip.nexthop.address / ip.nexthop.prefix-list),
+# under a "nexthop" node the argspec doesn't have (commented out in
+# vyos-1x itself, T3304/T3976, since a plain leaf there would collide
+# with the node).
+# ---------------------------------------------------------------------------
-def _match_cmds(rbase, match):
- cmds = []
+def _match_to_device(match):
if not match:
- return cmds
-
- mbase = rbase + ["match"]
-
- for key, path_suffix in _MATCH_MAP.items():
- if match.get(key) is not None:
- cmds.append(("set", mbase + path_suffix + [str(match[key])]))
+ return {}
+ exclude = {"prefix_list", "prefix_list6", "ip", "ipv6"}
+ device = autoclean({k: v for k, v in match.items() if k not in exclude})
if match.get("prefix_list"):
- cmds.append(("set", mbase + ["ip", "address", "prefix-list", match["prefix_list"]]))
+ device.setdefault("ip", {}).setdefault("address", {})["prefix-list"] = match["prefix_list"]
if match.get("prefix_list6"):
- cmds.append(("set", mbase + ["ipv6", "address", "prefix-list", match["prefix_list6"]]))
+ ipv6_addr = device.setdefault("ipv6", {}).setdefault("address", {})
+ ipv6_addr["prefix-list"] = match["prefix_list6"]
ip = match.get("ip") or {}
if ip.get("nexthop_address"):
- cmds.append(("set", mbase + ["ip", "nexthop", "address", ip["nexthop_address"]]))
+ device.setdefault("ip", {}).setdefault("nexthop", {})["address"] = ip["nexthop_address"]
if ip.get("nexthop_prefix_list"):
- cmds.append(("set", mbase + ["ip", "nexthop", "prefix-list", ip["nexthop_prefix_list"]]))
+ ip_nh = device.setdefault("ip", {}).setdefault("nexthop", {})
+ ip_nh["prefix-list"] = ip["nexthop_prefix_list"]
ipv6 = match.get("ipv6") or {}
if ipv6.get("nexthop_address"):
- cmds.append(("set", mbase + ["ipv6", "nexthop", "address", ipv6["nexthop_address"]]))
+ device.setdefault("ipv6", {}).setdefault("nexthop", {})["address"] = ipv6["nexthop_address"]
- return cmds
+ return device
-def _set_cmds(rbase, setv):
- cmds = []
- if not setv:
- return cmds
+def _match_from_device(data):
+ if not data:
+ return {}
+ ip_raw = data.get("ip") or {}
+ ipv6_raw = data.get("ipv6") or {}
+ exclude = {"ip", "ipv6"}
+ entry = from_device({k: v for k, v in data.items() if k not in exclude})
+
+ prefix_list = (ip_raw.get("address") or {}).get("prefix-list")
+ if prefix_list:
+ entry["prefix_list"] = prefix_list
+ prefix_list6 = (ipv6_raw.get("address") or {}).get("prefix-list")
+ if prefix_list6:
+ entry["prefix_list6"] = prefix_list6
+
+ ip_nexthop = ip_raw.get("nexthop") or {}
+ ip_sub = {}
+ if ip_nexthop.get("address"):
+ ip_sub["nexthop_address"] = ip_nexthop["address"]
+ if ip_nexthop.get("prefix-list"):
+ ip_sub["nexthop_prefix_list"] = ip_nexthop["prefix-list"]
+ if ip_sub:
+ entry["ip"] = ip_sub
+
+ ipv6_nexthop = ipv6_raw.get("nexthop") or {}
+ if ipv6_nexthop.get("address"):
+ entry["ipv6"] = {"nexthop_address": ipv6_nexthop["address"]}
+
+ return entry
+
+
+# ---------------------------------------------------------------------------
+# set.as_path_* -- confirmed structural collapse: three flat argspec
+# keys (as_path_exclude/prepend/prepend_last_as) collapse onto one
+# nested device node (as-path.{exclude,prepend,prepend-last-as}) with
+# different sub-key names -- no mechanical transform gets from
+# "as_path_exclude" to that shape.
+#
+# set.community / set.large_community / set.ipv6_next_hop are fully
+# generic once modeled as real nested dicts (confirmed against schema:
+# community/large-community are add/replace/none/delete nodes;
+# ipv6-next-hop is global/local/peer-address/prefer-global) -- no
+# entry-transform needed for them at all, the top-level community_to_
+# device call handles them via ordinary recursion.
+# ---------------------------------------------------------------------------
+_AS_PATH_FIELDS = {
+ "as_path_exclude": "exclude",
+ "as_path_prepend": "prepend",
+ "as_path_prepend_last_as": "prepend-last-as",
+}
- sbase = rbase + ["set"]
- for key, path_suffix in _SET_MAP.items():
- if setv.get(key) is not None:
- cmds.append(("set", sbase + path_suffix + [str(setv[key])]))
+# Both renames in this module are position-specific -- confirmed
+# against vyos-1x: "as" and "continue" are Python keywords and can't
+# be used as dict() kwargs at all, so "as_"/"continue_sequence" are
+# unavoidable argspec names, renamed to the device's real leaf names
+# "as"/"continue". Neither fits a shared flat rename map: "as_" is
+# nested inside "aggregator" specifically, and "continue_sequence" is
+# a rule-level field, not a set-level one -- each is handled directly
+# at its own point below instead.
- if setv.get("atomic_aggregate"):
- cmds.append(("set", sbase + ["atomic-aggregate"]))
- comm = setv.get("community") or {}
- if comm.get("value"):
- cmds.append(("set", sbase + ["community", comm["value"]]))
+def _set_to_device(setv):
+ if not setv:
+ return {}
+ exclude = set(_AS_PATH_FIELDS) | {"aggregator"}
+ device = autoclean({k: v for k, v in setv.items() if k not in exclude})
+
+ as_path = {
+ device_key: setv[arg_key]
+ for arg_key, device_key in _AS_PATH_FIELDS.items()
+ if setv.get(arg_key) is not None
+ }
+ if as_path:
+ device["as-path"] = as_path
+
+ agg = setv.get("aggregator")
+ if agg:
+ agg_device = autoclean({k: v for k, v in agg.items() if k != "as_"})
+ if agg.get("as_") is not None:
+ agg_device["as"] = agg["as_"]
+ if agg_device:
+ device["aggregator"] = agg_device
+
+ return device
+
+
+def _set_from_device(data):
+ if not data:
+ return {}
+ as_path_raw = data.get("as-path") or {}
+ agg_raw = data.get("aggregator") or {}
+ exclude = {"as-path", "aggregator"}
+ entry = from_device({k: v for k, v in data.items() if k not in exclude})
+
+ for arg_key, device_key in _AS_PATH_FIELDS.items():
+ if as_path_raw.get(device_key) is not None:
+ entry[arg_key] = as_path_raw[device_key]
+ if "as_path_prepend_last_as" in entry:
+ entry["as_path_prepend_last_as"] = int(entry["as_path_prepend_last_as"])
+
+ if agg_raw:
+ agg_entry = from_device({k: v for k, v in agg_raw.items() if k != "as"})
+ if agg_raw.get("as") is not None:
+ agg_entry["as_"] = int(agg_raw["as"])
+ if agg_entry:
+ entry["aggregator"] = agg_entry
+
+ return entry
+
+
+# ---------------------------------------------------------------------------
+# Rules (keyed by sequence) and route maps (keyed by name) -- both are
+# named-list sections like any other in this collection, so they go
+# through the same _keyed_list_to_device/_keyed_list_from_device
+# mechanic as everything else, with key_field derived from ARGSPEC
+# rather than hand-declared, instead of the hand-rolled loops this had
+# before. _ROUTE_MAP_KEY/_RULE_KEY are derived after ARGUMENT_SPEC is
+# built (near the bottom of this file) since they need it to exist.
+# ---------------------------------------------------------------------------
+
+
+def _rule_entry_to_device(rest):
+ exclude = {"match", "set", "continue_sequence"}
+ device = autoclean({k: v for k, v in rest.items() if k not in exclude})
+ if rest.get("continue_sequence") is not None:
+ device["continue"] = rest["continue_sequence"]
+ if rest.get("match"):
+ m = _match_to_device(rest["match"])
+ if m:
+ device["match"] = m
+ if rest.get("set"):
+ s = _set_to_device(rest["set"])
+ if s:
+ device["set"] = s
+ return device
+
+
+def _rule_entry_from_device(data):
+ data = dict(data or {})
+ continue_raw = data.pop("continue", None)
+ match_raw = data.pop("match", None)
+ set_raw = data.pop("set", None)
+ entry = from_device(data)
+ if continue_raw is not None:
+ entry["continue_sequence"] = int(continue_raw)
+ match = _match_from_device(match_raw)
+ if match:
+ entry["match"] = match
+ setv = _set_from_device(set_raw)
+ if setv:
+ entry["set"] = setv
+ return entry
+
+
+def _route_map_entry_to_device(rest):
+ entries = rest.get("entries") or []
+ if not entries:
+ return {}
+ return {"rule": _keyed_list_to_device(entries, _RULE_KEY, _rule_entry_to_device)}
- large_comm = setv.get("large_community")
- if large_comm is not None:
- cmds.append(("set", sbase + ["large-community", str(large_comm)]))
- agg = setv.get("aggregator") or {}
- agg_as = agg.get("as") or agg.get("as_")
- if agg_as and agg.get("ip"):
- cmds.append(("set", sbase + ["aggregator", "as", str(agg_as), "address", agg["ip"]]))
- elif agg_as:
- cmds.append(("set", sbase + ["aggregator", "as", str(agg_as)]))
+def _route_map_entry_from_device(data):
+ rule_raw = (data or {}).get("rule")
+ if not rule_raw:
+ return {"entries": []}
+ entries = _keyed_list_from_device(rule_raw, _RULE_KEY, _rule_entry_from_device, key_cast=int)
+ # _keyed_list_from_device sorts by the raw device key as a string,
+ # which orders sequence numbers wrong across a digit-count boundary
+ # (e.g. "10" < "9" lexicographically) -- re-sort numerically now
+ # that key_cast has already converted each key to a real int.
+ return {"entries": sorted(entries, key=lambda e: e[_RULE_KEY])}
- nh6 = setv.get("ipv6_next_hop") or {}
- if nh6.get("value"):
- ip_type = nh6.get("ip_type") or "global"
- cmds.append(("set", sbase + ["ipv6-next-hop", ip_type, nh6["value"]]))
- return cmds
+def _want_to_device(config):
+ with_entries = [rm for rm in (config or []) if rm.get("entries")]
+ return _keyed_list_to_device(with_entries, _ROUTE_MAP_KEY, _route_map_entry_to_device)
-def _want_to_api_set(setv):
- if not setv:
- return {}
- api = {}
- for key, path in _SET_MAP.items():
- if setv.get(key) is not None:
- d = api
- for p in path[:-1]:
- d = d.setdefault(p, {})
- d[path[-1]] = str(setv[key])
- agg = setv.get("aggregator") or {}
- agg_as = agg.get("as") or agg.get("as_")
- if agg_as:
- api.setdefault("aggregator", {})["as"] = str(agg_as)
- large_comm = setv.get("large_community")
- if large_comm is not None:
- api["large-community"] = {str(large_comm): {}}
- return api
-
-
-def _want_to_api_match(match):
- if not match:
- return {}
- api = {}
- for key in _MATCH_MAP:
- if match.get(key) is not None:
- api[key] = str(match[key])
- if match.get("prefix_list"):
- api.setdefault("ip", {}).setdefault("address", {})["prefix-list"] = match["prefix_list"]
- if match.get("prefix_list6"):
- api.setdefault("ipv6", {}).setdefault("address", {})["prefix-list"] = match["prefix_list6"]
- return api
-
-
-def _rule_cmds(rm_name, rule, have_rule, state="merged"):
- cmds = []
- seq = str(rule["sequence"])
- rbase = _BASE + [rm_name, "rule", seq]
-
- if rule.get("action") and rule["action"] != have_rule.get("action"):
- cmds.append(("set", rbase + ["action", rule["action"]]))
- if rule.get("description") and rule["description"] != have_rule.get("description"):
- cmds.append(("set", rbase + ["description", rule["description"]]))
- if rule.get("call") and rule["call"] != have_rule.get("call"):
- cmds.append(("set", rbase + ["call", rule["call"]]))
- if rule.get("continue_sequence") is not None and rule["continue_sequence"] != have_rule.get(
- "continue_sequence",
- ):
- cmds.append(("set", rbase + ["continue", str(rule["continue_sequence"])]))
-
- want_match_api = _want_to_api_match(rule.get("match"))
- have_match = have_rule.get("match") or {}
- changed_match = {k: v for k, v in want_match_api.items() if have_match.get(k) != v}
- if changed_match:
- match = rule.get("match") or {}
- changed_keys = set(changed_match.keys())
- partial_match = {
- k: v
- for k, v in match.items()
- if k in changed_keys
- or (k == "prefix_list" and "ip" in changed_keys)
- or (k == "prefix_list6" and "ipv6" in changed_keys)
- }
- if not partial_match:
- partial_match = match
- cmds += _match_cmds(rbase, partial_match)
-
- want_set_api = _want_to_api_set(rule.get("set"))
- have_set = have_rule.get("set") or {}
- if state in ("replaced", "overridden"):
- if want_set_api != have_set:
- cmds += _set_cmds(rbase, rule.get("set"))
- else:
- have_subset = {k: have_set[k] for k in want_set_api if k in have_set}
- if want_set_api != have_subset:
- cmds += _set_cmds(rbase, rule.get("set"))
-
- return cmds
-
-
-def build_commands(config, have_raw, state):
- cmds = []
+def get_running_config(vyos):
+ raw = vyos.get_config(_BASE) or {}
+ if isinstance(raw, dict) and "route-map" in raw:
+ return raw["route-map"] or {}
+ return raw
+
+
+def _device_to_argspec(raw):
+ if not raw:
+ return []
+ return _keyed_list_from_device(raw, _ROUTE_MAP_KEY, _route_map_entry_from_device)
+
+
+def _seed_route_map_placeholders(want, have):
+ """dict_op's fallback guesses a translated device key whenever a
+ want key is missing from have entirely (a brand-new route map or
+ rule). That guess is correct for a schema field name but wrong for
+ a route-map name, which is an opaque value that may legitimately
+ contain an underscore (confirmed against vyos-1x: "Name of
+ route-map can only contain alpha-numeric letters, hyphen and
+ underscores") -- confirmed as a real bug via direct reproduction,
+ the same class found in vyos_snmp_server's "admin_user" case:
+ "my_route_map" was silently becoming "my-route-map" in the
+ generated command on first creation.
+
+ Seeds an empty placeholder into have (mutated in place) for every
+ route-map name present in want but not yet in have, using the
+ exact verbatim value -- dict_op's own unmodified exact-match lookup
+ then finds it directly and never reaches its guessing fallback.
+ Also seeds each rule's own tag-node level with None (not {}), since
+ a rule with no other fields set is a presence-only entry -- seeding
+ {} there would make dict_op think it already matches and skip
+ emitting the needed set command (the same mistake caught and fixed
+ once already this session).
+ """
+ for rm_name, rm_val in (want or {}).items():
+ rm_have = have.setdefault(rm_name, {})
+ if not isinstance(rm_have, dict):
+ continue
+ rule_want = (rm_val or {}).get("rule") or {}
+ if rule_want:
+ rule_have = rm_have.setdefault("rule", {})
+ if isinstance(rule_have, dict):
+ for seq in rule_want:
+ if seq not in rule_have:
+ rule_have[seq] = None
+
+
+def build_commands(config, raw_have, state):
+ raw_have = raw_have or {}
+ config = config or []
if state == "deleted":
if not config:
- if have_raw:
- cmds.append(("delete", _BASE))
- else:
- for rm in config:
+ return [("delete", _BASE)] if raw_have else []
+ cmds = []
+ for rm in config:
+ if rm.get("route_map") in raw_have:
cmds.append(("delete", _BASE + [rm["route_map"]]))
return cmds
- have_map = {e["route_map"]: e for e in have_raw}
+ want = _want_to_device(config)
+ norm_have = _want_to_device(_device_to_argspec(raw_have))
+ _seed_route_map_placeholders(want, norm_have)
+ commands = []
if state == "overridden":
- want_names = {rm["route_map"] for rm in config}
- for name in set(have_map) - want_names:
- cmds.append(("delete", _BASE + [name]))
-
- for rm in config:
- rm_name = rm["route_map"]
- have_rm = have_map.get(rm_name, {})
-
- if state == "replaced" and rm_name in have_map:
- # Only delete and rebuild if something actually differs
- have_entries = {str(r["sequence"]): r for r in (have_rm.get("entries") or [])}
- want_seqs = {str(r["sequence"]) for r in (rm.get("entries") or [])}
- extra_seqs = set(have_entries) - want_seqs
- test_cmds = []
- for rule in rm.get("entries") or []:
- have_rule = have_entries.get(str(rule["sequence"]), {})
- test_cmds += _rule_cmds(rm_name, rule, have_rule, state)
- if test_cmds or extra_seqs:
- cmds.append(("delete", _BASE + [rm_name]))
- have_rm = {}
- else:
- continue # already matches — idempotent
-
- have_entries = {str(r["sequence"]): r for r in (have_rm.get("entries") or [])}
-
- for rule in rm.get("entries") or []:
- have_rule = have_entries.get(str(rule["sequence"]), {})
- cmds += _rule_cmds(rm_name, rule, have_rule, state)
-
- return cmds
+ commands += dict_op(want, norm_have, _BASE, op="purge")
+ elif state == "replaced":
+ want_names = {rm.get("route_map") for rm in config if rm.get("route_map")}
+ for name in want_names:
+ section_want = want.get(name, {})
+ section_have = norm_have.get(name, {})
+ commands += dict_op(section_want, section_have, _BASE + [name], op="purge")
+ commands += dict_op(want, norm_have, _BASE, op="set")
+ return commands
ARGUMENT_SPEC = dict(
- config=dict(type="list", elements="dict"),
+ config=dict(
+ type="list",
+ elements="dict",
+ options=dict(
+ route_map=dict(type="str", required=True),
+ entries=dict(
+ type="list",
+ elements="dict",
+ options=dict(
+ sequence=dict(type="int", required=True),
+ action=dict(type="str", choices=["permit", "deny"]),
+ description=dict(type="str"),
+ call=dict(type="str"),
+ continue_sequence=dict(type="int"),
+ match=dict(
+ type="dict",
+ options=dict(
+ interface=dict(type="str"),
+ metric=dict(type="int"),
+ origin=dict(type="str", choices=["egp", "igp", "incomplete"]),
+ peer=dict(type="str"),
+ protocol=dict(
+ type="str",
+ choices=[
+ "babel",
+ "bgp",
+ "connected",
+ "isis",
+ "kernel",
+ "ospf",
+ "ospfv3",
+ "rip",
+ "ripng",
+ "static",
+ "table",
+ "vnc",
+ ],
+ ),
+ prefix_list=dict(type="str"),
+ prefix_list6=dict(type="str"),
+ ip=dict(
+ type="dict",
+ options=dict(
+ nexthop_address=dict(type="str"),
+ nexthop_prefix_list=dict(type="str"),
+ ),
+ ),
+ ipv6=dict(
+ type="dict",
+ options=dict(
+ nexthop_address=dict(type="str"),
+ ),
+ ),
+ ),
+ ),
+ set=dict(
+ type="dict",
+ options=dict(
+ metric=dict(type="int"),
+ metric_type=dict(type="str"),
+ origin=dict(type="str", choices=["egp", "igp", "incomplete"]),
+ originator_id=dict(type="str"),
+ src=dict(type="str"),
+ tag=dict(type="int"),
+ weight=dict(type="int"),
+ distance=dict(type="int"),
+ table=dict(type="int"),
+ local_preference=dict(type="int"),
+ ip_next_hop=dict(type="str"),
+ atomic_aggregate=dict(type="bool"),
+ as_path_exclude=dict(type="str"),
+ as_path_prepend=dict(type="str"),
+ as_path_prepend_last_as=dict(type="int"),
+ aggregator=dict(
+ type="dict",
+ options=dict(
+ as_=dict(type="int", aliases=["as"]),
+ ip=dict(type="str"),
+ ),
+ ),
+ community=dict(
+ type="dict",
+ options=dict(
+ add=dict(type="list", elements="str"),
+ replace=dict(type="list", elements="str"),
+ none=dict(type="bool"),
+ delete=dict(type="str"),
+ ),
+ ),
+ large_community=dict(
+ type="dict",
+ options=dict(
+ add=dict(type="list", elements="str"),
+ replace=dict(type="list", elements="str"),
+ none=dict(type="bool"),
+ delete=dict(type="str"),
+ ),
+ ),
+ ipv6_next_hop=dict(
+ type="dict",
+ options={
+ "global": dict(type="str"),
+ "local": dict(type="str"),
+ "peer_address": dict(type="bool"),
+ "prefer_global": dict(type="bool"),
+ },
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
state=dict(
default="merged",
choices=["merged", "replaced", "overridden", "deleted", "gathered"],
),
)
+_ENTRY_OPTIONS = ARGUMENT_SPEC["config"]["options"]["entries"]["options"]
+_ROUTE_MAP_KEY = _derive_key_field(ARGUMENT_SPEC["config"]["options"])
+_RULE_KEY = _derive_key_field(_ENTRY_OPTIONS)
+
def main():
module = AnsibleModule(ARGUMENT_SPEC, supports_check_mode=True)
-
vyos = VyOSModule(module)
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)
+ for rm in have:
+ for entry in rm.get("entries") or []:
+ cast_by_spec(entry, _ENTRY_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)
@@ -431,10 +798,15 @@ 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)
+ for rm in after:
+ for entry in rm.get("entries") or []:
+ cast_by_spec(entry, _ENTRY_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_snmp_server.py b/plugins/modules/vyos_snmp_server.py
index 1079402..db0a285 100644
--- a/plugins/modules/vyos_snmp_server.py
+++ b/plugins/modules/vyos_snmp_server.py
@@ -54,7 +54,9 @@ options:
description: System location.
type: str
smux_peer:
- description: Register a subtree for SMUX-based processing.
+ description: >-
+ Register a subtree for SMUX-based processing. The device supports
+ multiple values here; this module manages a single value only.
type: str
trap_source:
description: SNMP trap source address.
@@ -72,12 +74,15 @@ options:
description: UDP port (default 161).
type: int
trap_target:
- description: SNMP trap target.
+ description: >-
+ SNMP (v2) trap target. The device supports multiple trap targets;
+ this module manages a single one only.
type: dict
suboptions:
address:
description: IP address of the trap target host.
type: str
+ required: true
community:
description: Community name to use for traps.
type: str
@@ -128,7 +133,9 @@ options:
description: Authentication algorithm.
type: str
encrypted_key:
- description: Encrypted authentication key (stored as encrypted-password on device).
+ description: >-
+ Encrypted authentication key (stored as encrypted-password
+ on device).
type: str
plaintext_key:
description: Plaintext authentication key (device encrypts it).
@@ -164,6 +171,7 @@ options:
address:
description: IP address of the SNMPv3 trap target.
type: str
+ required: true
port:
description: UDP port on the trap target host.
type: int
@@ -202,7 +210,10 @@ options:
description: Plaintext privacy key.
type: str
views:
- description: SNMPv3 view configuration.
+ description: >-
+ SNMPv3 view configuration. The device supports multiple OIDs
+ (each with its own exclude/mask) per view; this module manages
+ a single OID entry per view only.
type: list
elements: dict
suboptions:
@@ -278,7 +289,7 @@ after:
returned: when changed
type: dict
commands:
- description: List of API command dicts sent to the device.
+ description: List of API command tuples sent to the device.
returned: always
type: list
gathered:
@@ -287,463 +298,418 @@ gathered:
type: dict
saved:
description: Whether the config was saved after changes.
- returned: when changes are applied
+ returned: when changed
type: bool
"""
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,
+)
-SNMP_BASE = ["service", "snmp"]
-SCALAR_FIELDS = {
- "contact": "contact",
- "description": "description",
- "location": "location",
- "smux_peer": "smux-peer",
- "trap_source": "trap-source",
+_BASE = ["service", "snmp"]
+
+# ---------------------------------------------------------------------------
+# The one thing a purely structural walk of ARGUMENT_SPEC can never
+# infer: a handful of field names that mean something different on the
+# device than in the argspec, and aren't a mechanical kebab<->snake
+# conversion dict_op could handle itself. Declared once, here, as a
+# flat value map -- not embedded in ARGUMENT_SPEC (keeping that 100%
+# standard Ansible), and not scattered across per-section transform
+# functions. Confirmed against vyos-1x for every entry:
+# - authorization_type/clients/networks: communities' fields don't
+# match their device leaf names at all ("authorization"/"client"/
+# "network").
+# - authentication/encrypted_key/plaintext_key: shared by v3 users
+# and v3 trap-targets (both nest under a device "auth" node with
+# "encrypted-password"/"plaintext-password" leaves). This is also
+# a real bug fix -- the previous implementation used "plaintext-
+# key", which does not exist on the device at all.
+# - engine_id: "engineid" on the device is one word, so there's no
+# hyphen for the mechanical conversion to split on.
+# None of these names are reused elsewhere in this argspec with a
+# different intended device mapping (confirmed by inspection), so one
+# flat map is safe here -- a module with a genuine name collision
+# across nesting levels (this one doesn't have one) would need the map
+# scoped by path instead.
+_DEVICE_RENAMES = {
+ "communities": "community",
+ "listen_addresses": "listen-address",
+ "snmp_v3": "v3",
+ "authorization_type": "authorization",
+ "clients": "client",
+ "networks": "network",
+ "authentication": "auth",
+ "encrypted_key": "encrypted-password",
+ "plaintext_key": "plaintext-password",
+ "engine_id": "engineid",
+ "groups": "group",
+ "users": "user",
+ "views": "view",
+ "trap_targets": "trap-target",
}
-def to_list(value):
- if value is None:
- return []
- if isinstance(value, list):
- return value
- if isinstance(value, str):
- return [value]
- if isinstance(value, dict):
- return list(value.keys())
- return [str(value)]
-
-
-def _cmd(op, path):
- return {"op": op, "path": path}
-
-
-def _set(path):
- return _cmd("set", path)
-
-
-def _delete(path):
- return _cmd("delete", path)
+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 such section in this argspec
+ already marks exactly one suboption required=True (you can't
+ create a community without a name, a user without a username).
+ Deriving it here means the key field is asserted to exist by the
+ argspec itself, not duplicated in a place that could drift out of
+ sync with it.
+ """
+ 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 _parse_communities(raw):
- if not raw or not isinstance(raw, dict):
- return []
- result = []
- for name, data in sorted(raw.items()):
- entry = {"name": name}
- if not isinstance(data, dict):
- result.append(entry)
+def _keyed_list_to_device(items, key_field, entry_transform=None):
+ """A list of dicts, each identified by key_field's value, becomes a
+ device dict keyed by that value -- the one structural mechanic
+ every named-list section in this module needs. entry_transform
+ supplies whatever else is genuinely irreducible for a given section
+ (a nested reshape) -- defaulting to the generic recursive walker.
+ """
+ entry_transform = entry_transform or autoclean
+ result = {}
+ for item in items or []:
+ if not item.get(key_field):
continue
- if "authorization" in data:
- entry["authorization_type"] = data["authorization"]
- if "client" in data:
- entry["clients"] = sorted(to_list(data["client"]))
- if "network" in data:
- entry["networks"] = sorted(to_list(data["network"]))
- result.append(entry)
+ rest = {k: v for k, v in item.items() if k != key_field}
+ result[item[key_field]] = entry_transform(rest)
return result
-def _parse_listen_addresses(raw):
- if not raw or not isinstance(raw, dict):
- return []
- result = []
- for addr, data in sorted(raw.items()):
- entry = {"address": addr}
- if isinstance(data, dict) and "port" in data:
- entry["port"] = int(data["port"])
- result.append(entry)
- 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())
+ ]
-def _parse_trap_target(raw):
- if not raw:
- return None
- if isinstance(raw, str):
- return {"address": raw}
+def _single_to_device(obj, key_field):
+ """trap_target (v2): confirmed a genuine tagNode keyed by address,
+ but the argspec models only a single object (documented
+ limitation, preserved as-is: the device supports multiple trap
+ targets, this module manages one). Reuses the same keyed-list
+ mechanic above as "a list capped to one entry" rather than a
+ bespoke pair of functions.
+ """
+ if not obj or not obj.get(key_field):
+ return {}
+ return _keyed_list_to_device([obj], key_field)
+
+
+def _single_from_device(raw, key_field):
+ entries = _keyed_list_from_device(raw, key_field)
+ return entries[0] if entries else None
+
+
+# ---------------------------------------------------------------------------
+# v3 views — the confirmed structural bug fix. "oid" is a genuine tag
+# node (keyed by the OID value itself) with its own "exclude"/"mask"
+# children -- the previous implementation treated "oid" as a flat leaf
+# and read exclude/mask from the wrong nesting level entirely (directly
+# under the view, when they actually live under view.oid.<value>). This
+# is a genuine arity change (three sibling scalar fields collapse into
+# one nested tag node), not a rename -- it can't be expressed through
+# _DEVICE_RENAMES, so it's the one section needing a real override
+# instead of the generic recursive walker. The device also supports
+# multiple OIDs per view and multiple excludes per OID (both <multi/> /
+# tagNode); the argspec only models one of each -- a documented
+# limitation, preserved as-is, not expanded here.
+# ---------------------------------------------------------------------------
+
+
+def _view_entry_to_device(rest):
+ entry = autoclean({k: v for k, v in rest.items() if k not in ("oid", "exclude", "mask")})
+ if rest.get("oid"):
+ oid_entry = {}
+ if rest.get("exclude"):
+ oid_entry["exclude"] = [rest["exclude"]]
+ if rest.get("mask"):
+ oid_entry["mask"] = rest["mask"]
+ entry["oid"] = {rest["oid"]: oid_entry}
+ return entry
+
+
+def _view_entry_from_device(data):
entry = {}
- if "address" in raw:
- entry["address"] = raw["address"]
- if "community" in raw:
- entry["community"] = raw["community"]
- if "port" in raw:
- entry["port"] = int(raw["port"])
- return entry if entry else None
-
-
-def _parse_v3_auth_privacy(raw, key):
- block = raw.get(key) if isinstance(raw, dict) else None
- if not block:
- return None
- result = {}
- if "type" in block:
- result["type"] = block["type"]
- if "encrypted-password" in block:
- result["encrypted_key"] = block["encrypted-password"]
- if "plaintext-key" in block:
- result["plaintext_key"] = block["plaintext-key"]
- return result if result else None
-
-
-def _parse_v3_users(raw):
- if not raw or not isinstance(raw, dict):
- return []
- result = []
- for username, data in sorted(raw.items()):
- entry = {"user": username}
- auth = _parse_v3_auth_privacy(data, "auth")
- if auth:
- entry["authentication"] = auth
- priv = _parse_v3_auth_privacy(data, "privacy")
- if priv:
- entry["privacy"] = priv
- if isinstance(data, dict):
- if "group" in data:
- entry["group"] = data["group"]
- if "mode" in data:
- entry["mode"] = data["mode"]
- if "tsm-key" in data:
- entry["tsm_key"] = data["tsm-key"]
- result.append(entry)
- return result
+ oid_raw = (data or {}).get("oid")
+ if oid_raw:
+ oid_dict = to_tag_dict(oid_raw)
+ oid_value, oid_data = sorted(oid_dict.items())[0]
+ entry["oid"] = oid_value
+ oid_data = oid_data or {}
+ excl_raw = oid_data.get("exclude")
+ if excl_raw:
+ excl_list = (
+ [excl_raw] if isinstance(excl_raw, str) else sorted(to_tag_dict(excl_raw).keys())
+ )
+ entry["exclude"] = excl_list[0]
+ if oid_data.get("mask"):
+ entry["mask"] = oid_data["mask"]
+ return entry
+
+
+# Sections needing something other than the generic recursive walker,
+# keyed by the argspec field name -- a second small value map, kept
+# separate from _DEVICE_RENAMES because it answers a different
+# question (how to build/parse each entry, not what to call a field).
+# Every other named-list section in this module (communities,
+# listen_addresses, v3 groups/users/trap_targets) needs neither: their
+# member fields either match the device 1:1 or are covered by
+# _DEVICE_RENAMES, so the generic walker handles them with no entry
+# here at all.
+_ENTRY_OVERRIDES = {
+ "views": (_view_entry_to_device, _view_entry_from_device),
+}
-def _parse_v3_groups(raw):
- if not raw or not isinstance(raw, dict):
- return []
- result = []
- for name, data in sorted(raw.items()):
- entry = {"group": name}
- if isinstance(data, dict):
- for key in ("mode", "seclevel", "view"):
- if key in data:
- entry[key] = data[key]
- result.append(entry)
- return result
+# ---------------------------------------------------------------------------
+# The generic recursive walker. Driven entirely by ARGUMENT_SPEC's own
+# structure (type=dict -> recurse; type=list with options -> a named
+# list, keyed by _derive_key_field; type=list with no options -> a
+# plain multi-value leaf, left to dict_op's own list handling) plus the
+# two small value maps above for the handful of cases structure alone
+# can't resolve. This is what replaced a hand-written to-device/from-
+# device function pair for every single section in this module.
+# ---------------------------------------------------------------------------
-def _parse_v3_views(raw):
- if not raw or not isinstance(raw, dict):
- return []
- result = []
- for name, data in sorted(raw.items()):
- entry = {"view": name}
- if isinstance(data, dict) and "oid" in data:
- oid_data = data["oid"]
- if isinstance(oid_data, dict) and oid_data:
- entry["oid"] = str(list(oid_data.keys())[0])
- elif isinstance(oid_data, str):
- entry["oid"] = oid_data
- if isinstance(data, dict):
- if "exclude" in data:
- entry["exclude"] = data["exclude"]
- if "mask" in data:
- entry["mask"] = data["mask"]
- result.append(entry)
- return result
-
-
-def _parse_v3_trap_targets(raw):
- if not raw or not isinstance(raw, dict):
- return []
- result = []
- for addr, data in sorted(raw.items()):
- entry = {"address": addr}
- if isinstance(data, dict):
- if "port" in data:
- entry["port"] = int(data["port"])
- if "protocol" in data:
- entry["protocol"] = data["protocol"]
- if "type" in data:
- entry["type"] = data["type"]
- auth = _parse_v3_auth_privacy(data, "auth")
- if auth:
- entry["authentication"] = auth
- priv = _parse_v3_auth_privacy(data, "privacy")
- if priv:
- entry["privacy"] = priv
- result.append(entry)
+def _spec_to_device(value, options_spec):
+ if not isinstance(value, dict):
+ return value
+ result = {}
+ for arg_key, sub_spec in options_spec.items():
+ val = value.get(arg_key)
+ if val is None or val is False:
+ continue
+ device_key = _DEVICE_RENAMES.get(arg_key, arg_key)
+ sub_type = sub_spec.get("type")
+ sub_options = sub_spec.get("options")
+ if sub_type == "dict" and sub_options:
+ converted = _spec_to_device(val, sub_options)
+ if converted:
+ result[device_key] = converted
+ elif sub_type == "list" and sub_options:
+ key_field = _derive_key_field(sub_options)
+ entry_to, _entry_from = _ENTRY_OVERRIDES.get(arg_key, (None, None))
+ entry_transform = entry_to or (
+ lambda rest, spec=sub_options: _spec_to_device(rest, spec)
+ )
+ result[device_key] = _keyed_list_to_device(val, key_field, entry_transform)
+ elif val is True:
+ result[device_key] = {}
+ elif sub_type == "list":
+ result[device_key] = list(val)
+ else:
+ result[device_key] = val
return result
-def parse_snmp_config(raw):
+def _device_to_spec(raw, options_spec):
if not raw or not isinstance(raw, dict):
return {}
+ have_idx = {k.replace("-", "_"): k for k in raw}
result = {}
- for argspec_key, api_key in SCALAR_FIELDS.items():
- if api_key in raw:
- result[argspec_key] = raw[api_key]
- communities = _parse_communities(raw.get("community"))
- if communities:
- result["communities"] = communities
- listen = _parse_listen_addresses(raw.get("listen-address"))
- if listen:
- result["listen_addresses"] = listen
- trap = _parse_trap_target(raw.get("trap-target"))
- if trap:
- result["trap_target"] = trap
- v3_raw = raw.get("v3")
- if v3_raw and isinstance(v3_raw, dict):
- v3 = {}
- if "engineid" in v3_raw:
- v3["engine_id"] = v3_raw["engineid"]
- groups = _parse_v3_groups(v3_raw.get("group"))
- if groups:
- v3["groups"] = groups
- users = _parse_v3_users(v3_raw.get("user"))
- if users:
- v3["users"] = users
- views = _parse_v3_views(v3_raw.get("view"))
- if views:
- v3["views"] = views
- trap_targets = _parse_v3_trap_targets(v3_raw.get("trap-target"))
- if trap_targets:
- v3["trap_targets"] = trap_targets
- if v3:
- result["snmp_v3"] = v3
+ for arg_key, sub_spec in options_spec.items():
+ device_key = _DEVICE_RENAMES.get(arg_key, arg_key)
+ orig_key = device_key if device_key in raw else have_idx.get(arg_key)
+ if orig_key is None:
+ continue
+ raw_val = raw[orig_key]
+ sub_type = sub_spec.get("type")
+ sub_options = sub_spec.get("options")
+ if sub_type == "dict" and sub_options:
+ converted = _device_to_spec(raw_val, sub_options)
+ if converted:
+ result[arg_key] = converted
+ elif sub_type == "list" and sub_options:
+ key_field = _derive_key_field(sub_options)
+ _entry_to, entry_from = _ENTRY_OVERRIDES.get(arg_key, (None, None))
+ entry_transform = entry_from or (lambda d, spec=sub_options: _device_to_spec(d, spec))
+ entries = _keyed_list_from_device(raw_val, key_field, entry_transform)
+ if entries:
+ result[arg_key] = entries
+ elif sub_type == "list":
+ if raw_val:
+ result[arg_key] = sorted(to_tag_dict(raw_val).keys())
+ elif isinstance(raw_val, dict) and not raw_val:
+ result[arg_key] = True
+ else:
+ result[arg_key] = raw_val
return result
+def _want_to_device(config):
+ config = config or {}
+ want = _spec_to_device(
+ {k: v for k, v in config.items() if k != "trap_target"},
+ _TOP_OPTIONS,
+ )
+ if config.get("trap_target"):
+ tt = _single_to_device(config["trap_target"], _derive_key_field(_TRAP_TARGET_OPTIONS))
+ if tt:
+ want["trap-target"] = tt
+ return want
+
+
def get_running_config(vyos):
try:
- raw = vyos.get_config(SNMP_BASE)
+ return vyos.get_config(_BASE) or {}
except Exception as e:
if "Configuration under specified path is empty" in str(e):
return {}
raise
- return parse_snmp_config(raw)
-
-
-def _build_scalar_commands(want, have, state):
- cmds = []
- for argspec_key, api_key in SCALAR_FIELDS.items():
- want_val = want.get(argspec_key)
- have_val = have.get(argspec_key)
- path = SNMP_BASE + [api_key]
- if state in ("replaced", "overridden"):
- if have_val and want_val != have_val:
- cmds.append(_delete(path))
- if state in ("merged", "replaced", "overridden"):
- if want_val and want_val != have_val:
- cmds.append(_set(path + [want_val]))
- return cmds
-
-
-def _build_community_commands(want_list, have_list, state):
- cmds = []
- want_map = {c["name"]: c for c in (want_list or [])}
- have_map = {c["name"]: c for c in (have_list or [])}
- if state in ("replaced", "overridden"):
- for name in have_map:
- if name not in want_map:
- cmds.append(_delete(SNMP_BASE + ["community", name]))
- for name, want_comm in want_map.items():
- have_comm = have_map.get(name, {})
- base = SNMP_BASE + ["community", name]
- want_auth = want_comm.get("authorization_type")
- have_auth = have_comm.get("authorization_type")
- if state in ("replaced", "overridden") and have_auth and want_auth != have_auth:
- cmds.append(_delete(base + ["authorization"]))
- if want_auth and want_auth != have_auth:
- cmds.append(_set(base + ["authorization", want_auth]))
- want_clients = set(want_comm.get("clients") or [])
- have_clients = set(have_comm.get("clients") or [])
- for c in want_clients - have_clients:
- cmds.append(_set(base + ["client", c]))
- if state in ("replaced", "overridden"):
- for c in have_clients - want_clients:
- cmds.append(_delete(base + ["client", c]))
- want_nets = set(want_comm.get("networks") or [])
- have_nets = set(have_comm.get("networks") or [])
- for n in want_nets - have_nets:
- cmds.append(_set(base + ["network", n]))
- if state in ("replaced", "overridden"):
- for n in have_nets - want_nets:
- cmds.append(_delete(base + ["network", n]))
- return cmds
-
-
-def _build_listen_address_commands(want_list, have_list, state):
- cmds = []
- want_map = {e["address"]: e for e in (want_list or [])}
- have_map = {e["address"]: e for e in (have_list or [])}
- base = SNMP_BASE + ["listen-address"]
- if state in ("replaced", "overridden"):
- for addr in have_map:
- if addr not in want_map:
- cmds.append(_delete(base + [addr]))
- for addr, want_entry in want_map.items():
- have_entry = have_map.get(addr, {})
- want_port = want_entry.get("port")
- have_port = have_entry.get("port")
- if addr not in have_map:
- if want_port:
- cmds.append(_set(base + [addr, "port", str(want_port)]))
- else:
- cmds.append(_set(base + [addr]))
- elif want_port != have_port:
- cmds.append(_delete(base + [addr]))
- if want_port:
- cmds.append(_set(base + [addr, "port", str(want_port)]))
- else:
- cmds.append(_set(base + [addr]))
- return cmds
-
-
-def _build_trap_target_commands(want, have, state):
- cmds = []
- base = SNMP_BASE + ["trap-target"]
- if state in ("merged", "replaced", "overridden"):
- if want:
- want_addr = want.get("address")
- have_addr = have.get("address") if have else None
- if want_addr and want_addr != have_addr:
- cmds.append(_set(base + [want_addr]))
- if want.get("community"):
- cmds.append(_set(base + [want_addr, "community", want["community"]]))
- if want.get("port"):
- cmds.append(_set(base + [want_addr, "port", str(want["port"])]))
- if state in ("replaced", "overridden"):
- if have and (not want or have.get("address") != (want or {}).get("address")):
- cmds.append(_delete(base))
- return cmds
-
-
-def _build_v3_auth_privacy_commands(base, want_block, have_block, api_key):
- cmds = []
- if not want_block:
- return cmds
- block_base = base + [api_key]
- have_block = have_block or {}
- if want_block.get("type") and want_block["type"] != have_block.get("type"):
- cmds.append(_set(block_base + ["type", want_block["type"]]))
- if want_block.get("encrypted_key") and want_block["encrypted_key"] != have_block.get(
- "encrypted_key",
- ):
- cmds.append(_set(block_base + ["encrypted-password", want_block["encrypted_key"]]))
- if want_block.get("plaintext_key"):
- cmds.append(_set(block_base + ["plaintext-key", want_block["plaintext_key"]]))
- return cmds
-
-
-def _build_v3_user_commands(want_list, have_list, state):
- cmds = []
- want_map = {u["user"]: u for u in (want_list or [])}
- have_map = {u["user"]: u for u in (have_list or [])}
- base = SNMP_BASE + ["v3", "user"]
- if state in ("replaced", "overridden"):
- for username in have_map:
- if username not in want_map:
- cmds.append(_delete(base + [username]))
- for username, want_user in want_map.items():
- have_user = have_map.get(username, {})
- user_base = base + [username]
- cmds += _build_v3_auth_privacy_commands(
- user_base,
- want_user.get("authentication"),
- have_user.get("authentication"),
- "auth",
- )
- cmds += _build_v3_auth_privacy_commands(
- user_base,
- want_user.get("privacy"),
- have_user.get("privacy"),
- "privacy",
- )
- if want_user.get("group") and want_user["group"] != have_user.get("group"):
- cmds.append(_set(user_base + ["group", want_user["group"]]))
- if want_user.get("mode") and want_user["mode"] != have_user.get("mode"):
- cmds.append(_set(user_base + ["mode", want_user["mode"]]))
- if want_user.get("tsm_key") and want_user["tsm_key"] != have_user.get("tsm_key"):
- cmds.append(_set(user_base + ["tsm-key", want_user["tsm_key"]]))
- return cmds
-
-
-def _build_v3_group_commands(want_list, have_list, state):
- cmds = []
- want_map = {g["group"]: g for g in (want_list or [])}
- have_map = {g["group"]: g for g in (have_list or [])}
- base = SNMP_BASE + ["v3", "group"]
- if state in ("replaced", "overridden"):
- for name in have_map:
- if name not in want_map:
- cmds.append(_delete(base + [name]))
- for name, want_group in want_map.items():
- have_group = have_map.get(name, {})
- group_base = base + [name]
- for key, api_key in [("mode", "mode"), ("seclevel", "seclevel"), ("view", "view")]:
- want_val = want_group.get(key)
- have_val = have_group.get(key)
- if want_val and want_val != have_val:
- cmds.append(_set(group_base + [api_key, want_val]))
- if state in ("replaced", "overridden") and have_val and want_val != have_val:
- cmds.append(_delete(group_base + [api_key]))
- return cmds
-
-
-def _build_v3_view_commands(want_list, have_list, state):
- cmds = []
- want_map = {v["view"]: v for v in (want_list or [])}
- have_map = {v["view"]: v for v in (have_list or [])}
- base = SNMP_BASE + ["v3", "view"]
- if state in ("replaced", "overridden"):
- for name in have_map:
- if name not in want_map:
- cmds.append(_delete(base + [name]))
- for name, want_view in want_map.items():
- have_view = have_map.get(name, {})
- view_base = base + [name]
- want_oid = str(want_view["oid"]) if want_view.get("oid") else None
- have_oid = str(have_view.get("oid")) if have_view.get("oid") else None
- if want_oid and want_oid != have_oid:
- cmds.append(_set(view_base + ["oid", want_oid]))
- if state in ("replaced", "overridden") and have_oid and want_oid != have_oid:
- cmds.append(_delete(view_base + ["oid", have_oid]))
- for key in ("exclude", "mask"):
- want_val = want_view.get(key)
- have_val = have_view.get(key)
- if want_val and want_val != have_val:
- cmds.append(_set(view_base + [key, want_val]))
- return cmds
-
-
-def _build_v3_commands(want_v3, have_v3, state):
- cmds = []
- want_v3 = want_v3 or {}
- have_v3 = have_v3 or {}
- want_eid = want_v3.get("engine_id")
- have_eid = have_v3.get("engine_id")
- if want_eid and want_eid != have_eid:
- cmds.append(_set(SNMP_BASE + ["v3", "engineid", want_eid]))
- if state in ("replaced", "overridden") and have_eid and want_eid != have_eid:
- cmds.append(_delete(SNMP_BASE + ["v3", "engineid"]))
- cmds += _build_v3_group_commands(want_v3.get("groups"), have_v3.get("groups"), state)
- cmds += _build_v3_user_commands(want_v3.get("users"), have_v3.get("users"), state)
- cmds += _build_v3_view_commands(want_v3.get("views"), have_v3.get("views"), state)
- return cmds
-
-
-def build_commands(want, have, state):
- if state == "deleted":
- if have:
- return [_delete(SNMP_BASE)]
- return []
- cmds = []
- cmds += _build_scalar_commands(want, have, state)
- cmds += _build_community_commands(want.get("communities"), have.get("communities"), state)
- cmds += _build_listen_address_commands(
- want.get("listen_addresses"),
- have.get("listen_addresses"),
- state,
+
+
+def _device_to_argspec(raw):
+ if not raw:
+ return {}
+ result = _device_to_spec(
+ {k: v for k, v in raw.items() if k != "trap-target"},
+ _TOP_OPTIONS,
)
- cmds += _build_trap_target_commands(want.get("trap_target"), have.get("trap_target"), state)
- cmds += _build_v3_commands(want.get("snmp_v3"), have.get("snmp_v3"), state)
- return cmds
+ if raw.get("trap-target"):
+ tt = _single_from_device(raw["trap-target"], _derive_key_field(_TRAP_TARGET_OPTIONS))
+ if tt:
+ result["trap_target"] = tt
+ cast_by_spec(result, _TOP_OPTIONS)
+ return result
+
+
+# Device key names (as they appear in want/have, underscore-normalized)
+# whose child dict is a tag node keyed by an opaque value -- a username,
+# a community name, any user-supplied identifier -- rather than a schema
+# field name.
+_VERBATIM_KEYS = {"community", "listen_address", "group", "user", "view", "trap_target"}
+
+
+def _seed_tag_node_placeholders(want, have, verbatim_keys):
+ """dict_op's own key lookup falls back to guessing a translated
+ device key whenever a want key is missing from have entirely (a
+ brand-new entry). That guess is correct for a schema field name
+ (e.g. "trap_source" -> "trap-source" on first set) but wrong for a
+ tag-node key, which is an opaque value, not a schema name --
+ confirmed as a real bug: a username like "admin_user" was silently
+ becoming "admin-user" in the generated command the first time that
+ user was created (any tag-node key with an underscore would trigger
+ the same, since dict_op can't otherwise tell a schema name from a
+ value that merely happens to contain one).
+
+ Rather than teach the shared engine that distinction, this seeds an
+ empty placeholder into have (mutated in place) for every tag-node
+ entry present in want but not yet in have, keyed by the exact,
+ verbatim value from want. dict_op's own unmodified exact-match
+ lookup then finds it directly and never reaches its guessing
+ fallback at all -- the fix lives entirely in this module, not in
+ the shared engine, and every field the entry declares still
+ correctly shows up as "missing from have" and gets set, since the
+ placeholder is empty.
+ """
+ if not isinstance(want, dict):
+ return
+ have_idx = {k.replace("-", "_"): k for k in have}
+ for key, want_val in want.items():
+ if not isinstance(want_val, dict):
+ continue
+ norm_key = key.replace("-", "_")
+ orig_key = have_idx.get(norm_key, key)
+ have_val = have.setdefault(orig_key, {})
+ if not isinstance(have_val, dict):
+ continue
+ if norm_key in verbatim_keys:
+ for entry_key in want_val:
+ if entry_key not in have_val:
+ have_val[entry_key] = None
+ else:
+ _seed_tag_node_placeholders(want_val, have_val, verbatim_keys)
+
+
+_CREDENTIAL_LEAVES = {"encrypted-password", "plaintext-password"}
+
+
+def _protect_credentials_from_purge(want, have):
+ """ "replaced"/"overridden" purge deletes anything in have that
+ isn't re-specified in want -- correct for ordinary config, but
+ wrong for a write-only credential leaf: the user can never read
+ back the current encrypted-password to re-supply it, so its
+ absence from a new config must not be read as "remove it".
+ Confirmed as a real device-rejected commit: VyOS requires an
+ auth/privacy node to carry an encrypted-password or plaintext-
+ password whenever the node exists at all, so purging the existing
+ hash out from under an unrelated field-level change (e.g. updating
+ "type") broke the commit entirely, not just the password.
+
+ Copies have's password leaf into want (mutating want in place)
+ wherever want doesn't already supply its own -- purge then sees it
+ as unchanged and never deletes it, while a genuinely new
+ plaintext_key/encrypted_key the user did provide still overrides
+ normally, since this only fills in what's missing.
+ """
+ if not isinstance(want, dict) or not isinstance(have, dict):
+ return
+ have_idx = {k.replace("-", "_"): k for k in have}
+ for key, want_val in want.items():
+ if not isinstance(want_val, dict):
+ continue
+ norm_key = key.replace("-", "_")
+ have_val = have.get(have_idx.get(norm_key, key))
+ if not isinstance(have_val, dict):
+ continue
+ if norm_key in ("auth", "privacy") and not (_CREDENTIAL_LEAVES & set(want_val)):
+ for cred in _CREDENTIAL_LEAVES:
+ if cred in have_val:
+ want_val[cred] = have_val[cred]
+ _protect_credentials_from_purge(want_val, have_val)
+
+
+def build_commands(config, raw_have, state):
+ raw_have = raw_have or {}
+ config = config or {}
+
+ if state == "deleted":
+ return [("delete", _BASE)] if raw_have else []
+
+ want = _want_to_device(config)
+ # Rather than a generic key-name-based normalize_have, round-trip
+ # raw_have through the same structural converters used for want.
+ # This module has several keys that mean genuinely different things
+ # at different nesting depths (community/view/group are each both a
+ # tag node at one level and an unrelated scalar leaf at another) --
+ # a blanket tag_keys set would wrongly coerce the scalar occurrences
+ # into presence-dicts. Going through _device_to_argspec/
+ # _want_to_device instead resolves each occurrence with full
+ # knowledge of its actual position in the tree, not just its name.
+ norm_have = _want_to_device(_device_to_argspec(raw_have))
+ _seed_tag_node_placeholders(want, norm_have, _VERBATIM_KEYS)
+ _protect_credentials_from_purge(want, norm_have)
+
+ commands = []
+ if state == "overridden":
+ commands += dict_op(want, norm_have, _BASE, op="purge")
+ elif state == "replaced":
+ for section, section_want in want.items():
+ if not isinstance(section_want, dict):
+ continue
+ section_have = norm_have.get(section, {})
+ commands += dict_op(section_want, section_have, _BASE + [section], op="purge")
+ commands += dict_op(want, norm_have, _BASE, op="set")
+ return commands
def _auth_privacy_spec():
@@ -784,7 +750,7 @@ ARGUMENT_SPEC = dict(
trap_target=dict(
type="dict",
options=dict(
- address=dict(type="str"),
+ address=dict(type="str", required=True),
community=dict(type="str"),
port=dict(type="int"),
),
@@ -819,7 +785,7 @@ ARGUMENT_SPEC = dict(
type="list",
elements="dict",
options=dict(
- address=dict(type="str"),
+ address=dict(type="str", required=True),
port=dict(type="int"),
protocol=dict(type="str", choices=["tcp", "udp"]),
type=dict(type="str", choices=["inform", "trap"]),
@@ -848,6 +814,9 @@ ARGUMENT_SPEC = dict(
),
)
+_TOP_OPTIONS = ARGUMENT_SPEC["config"]["options"]
+_TRAP_TARGET_OPTIONS = _TOP_OPTIONS["trap_target"]["options"]
+
def main():
module = AnsibleModule(argument_spec=ARGUMENT_SPEC, supports_check_mode=True)
@@ -855,13 +824,13 @@ 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)
if state == "gathered":
module.exit_json(changed=False, gathered=have)
- want = config
- commands = build_commands(want, have, state)
+ commands = build_commands(config, raw_have, state)
if module.check_mode:
module.exit_json(changed=bool(commands), commands=commands, before=have)
@@ -872,7 +841,7 @@ def main():
module.exit_json(
changed=True,
before=have,
- after=want,
+ after=_device_to_argspec(get_running_config(vyos)),
commands=commands,
saved=saved,
response=response,
diff --git a/plugins/modules/vyos_system.py b/plugins/modules/vyos_system.py
new file mode 100644
index 0000000..e2bbed5
--- /dev/null
+++ b/plugins/modules/vyos_system.py
@@ -0,0 +1,146 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+# GNU General Public License v3.0+
+from __future__ import absolute_import, division, print_function
+
+
+__metaclass__ = type
+
+DOCUMENTATION = r"""
+---
+module: vyos_system
+short_description: Manage system settings on VyOS devices using REST API
+description:
+ - Manages basic system settings on VyOS devices via the REST API.
+ - Uses REST API (C(connection=httpapi)) instead of CLI.
+version_added: "1.0.0"
+author:
+ - VyOS Community (@vyos)
+options:
+ host_name:
+ description: Device hostname.
+ type: str
+ domain_name:
+ description: Device domain name.
+ type: str
+ name_server:
+ description: List of DNS name servers.
+ type: list
+ elements: str
+ aliases: [name_servers]
+ domain_search:
+ description: List of domain search suffixes.
+ type: list
+ elements: str
+ state:
+ description:
+ - C(present) applies the configuration.
+ - C(absent) removes the configuration.
+ type: str
+ choices: [present, absent]
+ default: present
+notes:
+ - Requires C(ansible_connection=httpapi) with the VyOS httpapi plugin.
+ - C(ansible_network_os) must be set to C(vyos.rest.vyos).
+"""
+
+EXAMPLES = r"""
+- name: Configure hostname and domain
+ vyos.rest.vyos_system:
+ host_name: router1
+ domain_name: example.com
+ name_server:
+ - 8.8.8.8
+ - 8.8.4.4
+ state: present
+
+- name: Remove domain name and name servers
+ vyos.rest.vyos_system:
+ domain_name: example.com
+ name_server:
+ - 8.8.8.8
+ state: absent
+"""
+
+RETURN = r"""
+before:
+ description: Module-owned system configuration before this module ran.
+ returned: always
+ type: dict
+after:
+ description: Module-owned system configuration after this module ran.
+ returned: when changed
+ type: dict
+commands:
+ description: List of API command tuples sent to the device.
+ returned: always
+ type: list
+saved:
+ description: Whether the config was saved after changes.
+ returned: when changed
+ type: bool
+response:
+ description: Raw API response.
+ returned: always
+ type: dict
+"""
+
+from ansible.module_utils.basic import AnsibleModule
+from ansible_collections.vyos.rest.plugins.module_utils.vyos import (
+ VyOSModule,
+ dict_op,
+ owned_config,
+)
+
+
+_BASE = ["system"]
+
+ARGUMENT_SPEC = dict(
+ host_name=dict(type="str"),
+ domain_name=dict(type="str"),
+ name_server=dict(type="list", elements="str", aliases=["name_servers"]),
+ domain_search=dict(type="list", elements="str"),
+ state=dict(type="str", default="present", choices=["present", "absent"]),
+)
+
+
+def main():
+ module = AnsibleModule(ARGUMENT_SPEC, supports_check_mode=True)
+ vyos = VyOSModule(module)
+
+ state = module.params["state"]
+
+ # want: snake_case keys from YAML, nulls removed
+ _CANONICAL_KEYS = set(ARGUMENT_SPEC.keys()) - {"state"}
+ want = {k: v for k, v in module.params.items() if k in _CANONICAL_KEYS and v is not None}
+
+ # have: raw kebab-case keys from device, scoped by _BASE
+ have = vyos.get_config(_BASE)
+
+ # before/after: only keys owned by this module (declared in argspec)
+ before = owned_config(have, ARGUMENT_SPEC)
+
+ op = "set" if state == "present" else "delete"
+ commands = dict_op(want, have, _BASE, op=op)
+
+ if module.check_mode:
+ module.exit_json(changed=bool(commands), commands=commands, before=before)
+
+ if commands:
+ response = vyos.apply_commands(commands)
+ saved = vyos.save_config()
+ after = owned_config(vyos.get_config(_BASE), ARGUMENT_SPEC)
+ module.exit_json(
+ changed=True,
+ before=before,
+ after=after,
+ commands=commands,
+ saved=saved,
+ response=response,
+ )
+
+ module.exit_json(changed=False, before=before, after=before, commands=[])
+
+
+if __name__ == "__main__":
+ main()
diff --git a/plugins/modules/vyos_user.py b/plugins/modules/vyos_user.py
index 8ccb148..4413887 100644
--- a/plugins/modules/vyos_user.py
+++ b/plugins/modules/vyos_user.py
@@ -126,102 +126,131 @@ gathered:
type: list
saved:
description: Whether the config was saved after changes.
- returned: when changes are applied
+ returned: when changed
type: bool
response:
description: Raw API response.
- returned: when changes are applied
+ returned: always
type: dict
"""
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,
+ dict_op,
+ from_device,
+ normalize_have,
+)
_BASE = ["system", "login", "user"]
+# "public-keys" is a tag node (keyed by key identifier) that could in
+# principle collapse to a bare value for a single entry; defensive only
+# -- "key" is required by the argspec so a real collapse is unlikely,
+# but the guard costs nothing and matches the pattern used everywhere
+# else a tag node is involved.
+_TAG_KEYS = {"public-keys"}
+
+# Users this module will never delete under state=absent, no matter what
+# the playbook asks for -- "vyos" is required for REST API access itself,
+# so deleting it would lock out every subsequent module call.
+_PROTECTED_USERS = {"vyos"}
+
+
+def _public_keys_to_device(keys):
+ return {
+ k["name"]: autoclean({kk: vv for kk, vv in k.items() if kk != "name"}) for k in keys or []
+ }
+
+
+def _public_keys_from_device(raw):
+ return [{"name": name, **from_device(data or {})} for name, data in sorted((raw or {}).items())]
+
+
+def _user_to_device(user):
+ """password/update_password are deliberately excluded here and
+ handled entirely outside dict_op in build_commands() -- "password"
+ (plaintext, write-only) and have's "encrypted-password" are
+ structurally different data with no valid equality comparison
+ between them, so whether to set it is a policy decision
+ (update_password), never a diff. public_keys nests under a literal
+ "authentication" wrapper the argspec doesn't have.
+ """
+ entry = autoclean(
+ {
+ k: v
+ for k, v in user.items()
+ if k not in ("name", "password", "update_password", "public_keys")
+ },
+ )
+ if user.get("public_keys"):
+ entry["authentication"] = {"public_keys": _public_keys_to_device(user["public_keys"])}
+ return entry
+
+
+def _user_from_device(name, data):
+ data = dict(data or {})
+ auth = data.pop("authentication", None) or {}
+ entry = {"name": name, **from_device(data)}
+ if auth.get("encrypted-password"):
+ entry["encrypted_password"] = auth["encrypted-password"]
+ pub_keys_raw = auth.get("public-keys")
+ if pub_keys_raw:
+ entry["public_keys"] = _public_keys_from_device(pub_keys_raw)
+ return entry
+
def get_running_config(vyos):
- raw = vyos.get_config(_BASE)
+ raw = vyos.get_config(_BASE) or {}
+ if isinstance(raw, dict):
+ raw = raw.get("user", raw)
+ return raw if isinstance(raw, dict) else {}
+
+
+def _device_to_argspec(raw):
if not raw or not isinstance(raw, dict):
return []
- raw = raw.get("user", raw)
- result = []
- for username, data in sorted(raw.items()):
- user = {"name": username}
- data = data or {}
- if data.get("full-name"):
- user["full_name"] = data["full-name"]
- auth = data.get("authentication", {}) or {}
- if auth.get("encrypted-password"):
- user["encrypted_password"] = auth["encrypted-password"]
- pub_keys = auth.get("public-keys", {}) or {}
- if pub_keys and isinstance(pub_keys, dict):
- keys = []
- for key_name, key_data in sorted(pub_keys.items()):
- key_data = key_data or {}
- k = {"name": key_name}
- if key_data.get("key"):
- k["key"] = key_data["key"]
- if key_data.get("type"):
- k["type"] = key_data["type"]
- keys.append(k)
- if keys:
- user["public_keys"] = keys
- result.append(user)
- return result
-
-
-def build_commands(users, have_list, state):
- cmds = []
- have_map = {u["name"]: u for u in have_list}
+ return [_user_from_device(name, data) for name, data in sorted(raw.items())]
+
+
+def build_commands(users, raw_have, state):
+ raw_have = raw_have or {}
+ users = users or []
if state == "absent":
+ commands = []
for user in users:
name = user["name"]
- if name in have_map:
- cmds.append(("delete", _BASE + [name]))
- return cmds
-
- # state == "present"
+ if name in _PROTECTED_USERS:
+ continue
+ if name in raw_have:
+ commands.append(("delete", _BASE + [name]))
+ return commands
+
+ # state == "present": additive-only, matches the original module's
+ # scope exactly -- existing fields/keys not mentioned in a user's
+ # config are left alone, never removed (there's no "replaced" state
+ # here to make a full-model rewrite meaningful).
+ commands = []
+ norm_have = normalize_have(raw_have, _TAG_KEYS)
for user in users:
name = user["name"]
- have = have_map.get(name, {})
+ is_new = name not in raw_have
ubase = _BASE + [name]
- is_new = name not in have_map
+ have_user = norm_have.get(name) or {}
- # full_name
- if user.get("full_name") and user["full_name"] != have.get("full_name"):
- cmds.append(("set", ubase + ["full-name", user["full_name"]]))
+ commands += dict_op(_user_to_device(user), have_user, ubase, op="set")
- # password
if user.get("password"):
- update_pw = user.get("update_password", "always")
- if update_pw == "always" or is_new:
- cmds.append(
- (
- "set",
- ubase
- + [
- "authentication",
- "plaintext-password",
- user["password"],
- ],
- ),
+ update_policy = user.get("update_password", "always")
+ if update_policy == "always" or is_new:
+ commands.append(
+ ("set", ubase + ["authentication", "plaintext-password", user["password"]]),
)
- # public_keys
- want_keys = {k["name"]: k for k in (user.get("public_keys") or [])}
- have_keys = {k["name"]: k for k in (have.get("public_keys") or [])}
- for key_name, key_data in want_keys.items():
- have_key = have_keys.get(key_name, {})
- kbase = ubase + ["authentication", "public-keys", key_name]
- if key_data.get("key") and key_data["key"] != have_key.get("key"):
- cmds.append(("set", kbase + ["key", key_data["key"]]))
- if key_data.get("type") and key_data["type"] != have_key.get("type"):
- cmds.append(("set", kbase + ["type", key_data["type"]]))
-
- return cmds
+ return commands
ARGUMENT_SPEC = dict(
@@ -273,12 +302,13 @@ def main():
state = module.params["state"]
users = module.params.get("users") or []
- 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(users, have, state)
+ commands = build_commands(users, raw_have, state)
if module.check_mode:
module.exit_json(changed=bool(commands), commands=commands, before=have)
@@ -289,7 +319,7 @@ def main():
module.exit_json(
changed=True,
before=have,
- after=get_running_config(vyos),
+ after=_device_to_argspec(get_running_config(vyos)),
commands=commands,
saved=saved,
response=response,
diff --git a/plugins/modules/vyos_vlan.py b/plugins/modules/vyos_vlan.py
new file mode 100644
index 0000000..3c66122
--- /dev/null
+++ b/plugins/modules/vyos_vlan.py
@@ -0,0 +1,241 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+# GNU General Public License v3.0+
+from __future__ import absolute_import, division, print_function
+
+
+__metaclass__ = type
+
+DOCUMENTATION = r"""
+---
+module: vyos_vlan
+short_description: Manage VLAN (vif) configuration on VyOS devices using REST API
+description:
+ - Manages VLAN sub-interface configuration on VyOS Ethernet interfaces
+ via the REST API.
+ - Uses REST API (C(connection=httpapi)) instead of CLI.
+version_added: "1.0.0"
+author:
+ - VyOS Community (@vyos)
+options:
+ config:
+ description: List of VLAN configurations.
+ type: list
+ elements: dict
+ suboptions:
+ vlan_id:
+ description: VLAN ID (0-4094).
+ type: int
+ required: true
+ description:
+ description: VLAN description.
+ type: str
+ address:
+ description: IP address for the VLAN interface.
+ type: str
+ interfaces:
+ description: List of Ethernet interfaces to configure this VLAN on.
+ type: list
+ elements: str
+ required: true
+ state:
+ description:
+ - C(present) creates or updates VLANs.
+ - C(absent) removes VLANs.
+ - C(gathered) returns current VLAN configuration.
+ type: str
+ choices: [present, absent, gathered]
+ default: present
+notes:
+ - Requires C(ansible_connection=httpapi) with the VyOS httpapi plugin.
+ - C(ansible_network_os) must be set to C(vyos.rest.vyos).
+"""
+
+EXAMPLES = r"""
+- name: Configure VLANs
+ vyos.rest.vyos_vlan:
+ config:
+ - vlan_id: 10
+ description: VLAN10
+ address: 192.168.10.1/24
+ interfaces:
+ - eth1
+ - vlan_id: 20
+ description: VLAN20
+ interfaces:
+ - eth1
+ - eth2
+ state: present
+
+- name: Remove a VLAN
+ vyos.rest.vyos_vlan:
+ config:
+ - vlan_id: 10
+ interfaces:
+ - eth1
+ state: absent
+
+- name: Gather VLAN configuration
+ vyos.rest.vyos_vlan:
+ state: gathered
+"""
+
+RETURN = r"""
+before:
+ description: VLAN configuration before this module ran.
+ returned: always
+ type: list
+after:
+ description: VLAN configuration after this module ran.
+ returned: when changed
+ type: list
+commands:
+ description: List of API command tuples sent to the device.
+ returned: always
+ type: list
+gathered:
+ description: Current VLAN configuration as structured data.
+ returned: when state is gathered
+ type: list
+saved:
+ description: Whether the config was saved after changes.
+ returned: when changed
+ type: bool
+response:
+ description: Raw API response.
+ returned: always
+ type: dict
+"""
+
+from ansible.module_utils.basic import AnsibleModule
+from ansible_collections.vyos.rest.plugins.module_utils.vyos import (
+ VyOSModule,
+ dict_op,
+)
+
+
+_BASE = ["interfaces", "ethernet"]
+
+# Keys that define structure (path construction) vs passthrough (diff engine)
+_STRUCTURAL_KEYS = {"vlan_id", "interfaces"}
+
+
+def get_running_config(vyos):
+ raw = vyos.get_config(_BASE)
+ if not raw or not isinstance(raw, dict):
+ return []
+
+ eth_data = raw.get("ethernet", raw)
+ if not isinstance(eth_data, dict):
+ return []
+
+ # Structural reshape: ethernet.<iface>.vif.<id> -> flat list per vlan_id
+ # Raw device keys preserved — dict_op handles - <-> _ normalization
+ vlan_map = {}
+ for iface_name, iface_data in sorted(eth_data.items()):
+ iface_data = iface_data or {}
+ vif_data = iface_data.get("vif", {}) or {}
+ for vlan_id_str, vif_cfg in sorted(
+ vif_data.items(),
+ key=lambda x: int(x[0]),
+ ):
+ vif_cfg = vif_cfg or {}
+ vlan_id = int(vlan_id_str)
+ if vlan_id not in vlan_map:
+ vlan_map[vlan_id] = {"vlan_id": vlan_id, "interfaces": [], "_raw": {}}
+ vlan_map[vlan_id]["interfaces"].append(iface_name)
+ vlan_map[vlan_id]["_raw"].update(vif_cfg)
+
+ result = []
+ for vid, entry in sorted(vlan_map.items()):
+ item = {"vlan_id": entry["vlan_id"], "interfaces": entry["interfaces"]}
+ for k, v in entry["_raw"].items():
+ item[k] = v[0] if isinstance(v, list) and len(v) == 1 else v
+ result.append(item)
+ return result
+
+
+def build_commands(config, have_list, state):
+ cmds = []
+
+ have_map = {(e["vlan_id"], iface): e for e in have_list for iface in e.get("interfaces", [])}
+
+ for want in config or []:
+ vlan_id = str(want["vlan_id"])
+ for iface in want.get("interfaces") or []:
+ vif_base = _BASE + [iface, "vif", vlan_id]
+ have_entry = have_map.get((want["vlan_id"], iface), {})
+
+ if state == "absent":
+ if have_entry:
+ cmds.append(("delete", vif_base))
+ continue
+
+ # Passthrough fields — dict_op handles - <-> _ normalization
+ want_vif = {
+ k: v for k, v in want.items() if k not in _STRUCTURAL_KEYS and v is not None
+ }
+ have_vif = {k: v for k, v in have_entry.items() if k not in _STRUCTURAL_KEYS}
+
+ new_cmds = dict_op(want_vif, have_vif, vif_base, op="set")
+ if not new_cmds and not have_entry:
+ cmds.append(("set", vif_base))
+ else:
+ cmds += new_cmds
+
+ return cmds
+
+
+ARGUMENT_SPEC = dict(
+ config=dict(
+ type="list",
+ elements="dict",
+ options=dict(
+ vlan_id=dict(type="int", required=True),
+ description=dict(type="str"),
+ address=dict(type="str"),
+ interfaces=dict(type="list", elements="str", required=True),
+ ),
+ ),
+ state=dict(
+ type="str",
+ default="present",
+ choices=["present", "absent", "gathered"],
+ ),
+)
+
+
+def main():
+ module = AnsibleModule(ARGUMENT_SPEC, supports_check_mode=True)
+ vyos = VyOSModule(module)
+
+ state = module.params["state"]
+ config = module.params.get("config") or []
+
+ have = get_running_config(vyos)
+
+ if state == "gathered":
+ module.exit_json(changed=False, gathered=have)
+
+ commands = build_commands(config, have, state)
+
+ if module.check_mode:
+ module.exit_json(changed=bool(commands), commands=commands, before=have)
+
+ if commands:
+ response = vyos.apply_commands(commands)
+ saved = vyos.save_config()
+ module.exit_json(
+ changed=True,
+ before=have,
+ after=get_running_config(vyos),
+ commands=commands,
+ saved=saved,
+ response=response,
+ )
+
+ module.exit_json(changed=False, before=have, after=have, commands=[])
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/integration/targets/vyos_banner/tasks/httpapi.yaml b/tests/integration/targets/vyos_banner/tasks/httpapi.yaml
index 4147e6d..0ed3e42 100644
--- a/tests/integration/targets/vyos_banner/tasks/httpapi.yaml
+++ b/tests/integration/targets/vyos_banner/tasks/httpapi.yaml
@@ -9,7 +9,7 @@
- name: Set test_items
ansible.builtin.set_fact:
- test_items: "{{ test_cases.files | map(attribute='path') | list }}"
+ test_items: "{{ test_cases.files | map(attribute='path') | list | sort }}"
- name: Run test case (connection=httpapi)
ansible.builtin.include_tasks: "{{ test_case_to_run }}"
diff --git a/tests/integration/targets/vyos_banner/tests/httpapi/deleted.yaml b/tests/integration/targets/vyos_banner/tests/httpapi/deleted.yaml
index 82e0f6e..61f3f2d 100644
--- a/tests/integration/targets/vyos_banner/tests/httpapi/deleted.yaml
+++ b/tests/integration/targets/vyos_banner/tests/httpapi/deleted.yaml
@@ -3,9 +3,9 @@
msg: START vyos_banner deleted integration tests on connection={{ ansible_connection }}
- include_tasks: _remove_config.yaml
-- include_tasks: _populate_config.yaml
-
- block:
+ - include_tasks: _populate_config.yaml
+
- name: Delete pre-login banner
register: result
vyos.rest.vyos_banner: &id001
diff --git a/tests/integration/targets/vyos_banner/tests/httpapi/gathered.yaml b/tests/integration/targets/vyos_banner/tests/httpapi/gathered.yaml
index a51ff48..a332563 100644
--- a/tests/integration/targets/vyos_banner/tests/httpapi/gathered.yaml
+++ b/tests/integration/targets/vyos_banner/tests/httpapi/gathered.yaml
@@ -3,9 +3,9 @@
msg: START vyos_banner gathered integration tests on connection={{ ansible_connection }}
- include_tasks: _remove_config.yaml
-- include_tasks: _populate_config.yaml
-
- block:
+ - include_tasks: _populate_config.yaml
+
- name: Gather banner configuration
register: result
vyos.rest.vyos_banner:
diff --git a/tests/integration/targets/vyos_banner/tests/httpapi/merged.yaml b/tests/integration/targets/vyos_banner/tests/httpapi/merged.yaml
index 96a984e..85fafdb 100644
--- a/tests/integration/targets/vyos_banner/tests/httpapi/merged.yaml
+++ b/tests/integration/targets/vyos_banner/tests/httpapi/merged.yaml
@@ -3,9 +3,9 @@
msg: START vyos_banner gathered integration tests on connection={{ ansible_connection }}
- include_tasks: _remove_config.yaml
-- include_tasks: _populate_config.yaml
-
- block:
+ - include_tasks: _populate_config.yaml
+
- name: Gather all banners (no config specified)
register: result
vyos.rest.vyos_banner:
diff --git a/tests/integration/targets/vyos_bgp_address_family/tasks/httpapi.yaml b/tests/integration/targets/vyos_bgp_address_family/tasks/httpapi.yaml
index 4147e6d..0ed3e42 100644
--- a/tests/integration/targets/vyos_bgp_address_family/tasks/httpapi.yaml
+++ b/tests/integration/targets/vyos_bgp_address_family/tasks/httpapi.yaml
@@ -9,7 +9,7 @@
- name: Set test_items
ansible.builtin.set_fact:
- test_items: "{{ test_cases.files | map(attribute='path') | list }}"
+ test_items: "{{ test_cases.files | map(attribute='path') | list | sort }}"
- name: Run test case (connection=httpapi)
ansible.builtin.include_tasks: "{{ test_case_to_run }}"
diff --git a/tests/integration/targets/vyos_bgp_address_family/tests/httpapi/deleted.yaml b/tests/integration/targets/vyos_bgp_address_family/tests/httpapi/deleted.yaml
index ce1ab70..225f057 100644
--- a/tests/integration/targets/vyos_bgp_address_family/tests/httpapi/deleted.yaml
+++ b/tests/integration/targets/vyos_bgp_address_family/tests/httpapi/deleted.yaml
@@ -3,9 +3,9 @@
msg: START vyos_bgp_address_family deleted integration tests on connection={{ ansible_connection }}
- include_tasks: _remove_config.yaml
-- include_tasks: _populate_config.yaml
-
- block:
+ - include_tasks: _populate_config.yaml
+
- name: Delete BGP address-family configuration
register: result
vyos.rest.vyos_bgp_address_family:
diff --git a/tests/integration/targets/vyos_bgp_address_family/tests/httpapi/gathered.yaml b/tests/integration/targets/vyos_bgp_address_family/tests/httpapi/gathered.yaml
index e686a32..50db299 100644
--- a/tests/integration/targets/vyos_bgp_address_family/tests/httpapi/gathered.yaml
+++ b/tests/integration/targets/vyos_bgp_address_family/tests/httpapi/gathered.yaml
@@ -3,9 +3,9 @@
msg: START vyos_bgp_address_family gathered integration tests on connection={{ ansible_connection }}
- include_tasks: _remove_config.yaml
-- include_tasks: _populate_config.yaml
-
- block:
+ - include_tasks: _populate_config.yaml
+
- name: Gather BGP address-family configuration
register: result
vyos.rest.vyos_bgp_address_family:
diff --git a/tests/integration/targets/vyos_bgp_address_family/tests/httpapi/overridden.yaml b/tests/integration/targets/vyos_bgp_address_family/tests/httpapi/overridden.yaml
index 9a497d2..cae014e 100644
--- a/tests/integration/targets/vyos_bgp_address_family/tests/httpapi/overridden.yaml
+++ b/tests/integration/targets/vyos_bgp_address_family/tests/httpapi/overridden.yaml
@@ -3,9 +3,9 @@
msg: START vyos_bgp_address_family overridden integration tests on connection={{ ansible_connection }}
- include_tasks: _remove_config.yaml
-- include_tasks: _populate_config.yaml
-
- block:
+ - include_tasks: _populate_config.yaml
+
- name: Override BGP address-family configuration
register: result
vyos.rest.vyos_bgp_address_family: &id001
diff --git a/tests/integration/targets/vyos_bgp_address_family/tests/httpapi/replaced.yaml b/tests/integration/targets/vyos_bgp_address_family/tests/httpapi/replaced.yaml
index 133ae39..f4ae60f 100644
--- a/tests/integration/targets/vyos_bgp_address_family/tests/httpapi/replaced.yaml
+++ b/tests/integration/targets/vyos_bgp_address_family/tests/httpapi/replaced.yaml
@@ -3,9 +3,9 @@
msg: START vyos_bgp_address_family replaced integration tests on connection={{ ansible_connection }}
- include_tasks: _remove_config.yaml
-- include_tasks: _populate_config.yaml
-
- block:
+ - include_tasks: _populate_config.yaml
+
- name: Replace BGP address-family configuration
register: result
vyos.rest.vyos_bgp_address_family: &id001
diff --git a/tests/integration/targets/vyos_bgp_global/tasks/httpapi.yaml b/tests/integration/targets/vyos_bgp_global/tasks/httpapi.yaml
index 4147e6d..0ed3e42 100644
--- a/tests/integration/targets/vyos_bgp_global/tasks/httpapi.yaml
+++ b/tests/integration/targets/vyos_bgp_global/tasks/httpapi.yaml
@@ -9,7 +9,7 @@
- name: Set test_items
ansible.builtin.set_fact:
- test_items: "{{ test_cases.files | map(attribute='path') | list }}"
+ test_items: "{{ test_cases.files | map(attribute='path') | list | sort }}"
- name: Run test case (connection=httpapi)
ansible.builtin.include_tasks: "{{ test_case_to_run }}"
diff --git a/tests/integration/targets/vyos_bgp_global/tests/httpapi/deleted.yaml b/tests/integration/targets/vyos_bgp_global/tests/httpapi/deleted.yaml
index ed2c285..9fe1f62 100644
--- a/tests/integration/targets/vyos_bgp_global/tests/httpapi/deleted.yaml
+++ b/tests/integration/targets/vyos_bgp_global/tests/httpapi/deleted.yaml
@@ -3,9 +3,9 @@
msg: START vyos_bgp_global deleted integration tests on connection={{ ansible_connection }}
- include_tasks: _remove_config.yaml
-- include_tasks: _populate_config.yaml
-
- block:
+ - include_tasks: _populate_config.yaml
+
- name: Delete BGP configuration
register: result
vyos.rest.vyos_bgp_global:
diff --git a/tests/integration/targets/vyos_bgp_global/tests/httpapi/gathered.yaml b/tests/integration/targets/vyos_bgp_global/tests/httpapi/gathered.yaml
index b38dba0..c8d327c 100644
--- a/tests/integration/targets/vyos_bgp_global/tests/httpapi/gathered.yaml
+++ b/tests/integration/targets/vyos_bgp_global/tests/httpapi/gathered.yaml
@@ -3,9 +3,9 @@
msg: START vyos_bgp_global gathered integration tests on connection={{ ansible_connection }}
- include_tasks: _remove_config.yaml
-- include_tasks: _populate_config.yaml
-
- block:
+ - include_tasks: _populate_config.yaml
+
- name: Gather BGP global configuration
register: result
vyos.rest.vyos_bgp_global:
diff --git a/tests/integration/targets/vyos_bgp_global/tests/httpapi/overridden.yaml b/tests/integration/targets/vyos_bgp_global/tests/httpapi/overridden.yaml
index 603aff3..aad9a9c 100644
--- a/tests/integration/targets/vyos_bgp_global/tests/httpapi/overridden.yaml
+++ b/tests/integration/targets/vyos_bgp_global/tests/httpapi/overridden.yaml
@@ -3,9 +3,9 @@
msg: START vyos_bgp_global overridden integration tests on connection={{ ansible_connection }}
- include_tasks: _remove_config.yaml
-- include_tasks: _populate_config.yaml
-
- block:
+ - include_tasks: _populate_config.yaml
+
- name: Override BGP global configuration
register: result
vyos.rest.vyos_bgp_global: &id001
diff --git a/tests/integration/targets/vyos_bgp_global/tests/httpapi/replaced.yaml b/tests/integration/targets/vyos_bgp_global/tests/httpapi/replaced.yaml
index 1a7d615..d88af15 100644
--- a/tests/integration/targets/vyos_bgp_global/tests/httpapi/replaced.yaml
+++ b/tests/integration/targets/vyos_bgp_global/tests/httpapi/replaced.yaml
@@ -3,9 +3,9 @@
msg: START vyos_bgp_global replaced integration tests on connection={{ ansible_connection }}
- include_tasks: _remove_config.yaml
-- include_tasks: _populate_config.yaml
-
- block:
+ - include_tasks: _populate_config.yaml
+
- name: Replace BGP global configuration
register: result
vyos.rest.vyos_bgp_global: &id001
diff --git a/tests/integration/targets/vyos_command/aliases b/tests/integration/targets/vyos_command/aliases
new file mode 100644
index 0000000..cc0afef
--- /dev/null
+++ b/tests/integration/targets/vyos_command/aliases
@@ -0,0 +1 @@
+network/vyos
diff --git a/tests/integration/targets/vyos_command/defaults/main.yaml b/tests/integration/targets/vyos_command/defaults/main.yaml
new file mode 100644
index 0000000..164afea
--- /dev/null
+++ b/tests/integration/targets/vyos_command/defaults/main.yaml
@@ -0,0 +1,3 @@
+---
+testcase: "[^_].*"
+test_items: []
diff --git a/tests/integration/targets/vyos_command/tasks/httpapi.yaml b/tests/integration/targets/vyos_command/tasks/httpapi.yaml
new file mode 100644
index 0000000..0ed3e42
--- /dev/null
+++ b/tests/integration/targets/vyos_command/tasks/httpapi.yaml
@@ -0,0 +1,21 @@
+---
+- name: Collect all httpapi test cases
+ ansible.builtin.find:
+ paths: "{{ role_path }}/tests/httpapi"
+ patterns: "{{ testcase }}.yaml"
+ use_regex: true
+ register: test_cases
+ delegate_to: localhost
+
+- name: Set test_items
+ ansible.builtin.set_fact:
+ test_items: "{{ test_cases.files | map(attribute='path') | list | sort }}"
+
+- name: Run test case (connection=httpapi)
+ ansible.builtin.include_tasks: "{{ test_case_to_run }}"
+ vars:
+ ansible_connection: ansible.netcommon.httpapi
+ ansible_network_os: vyos.rest.vyos
+ with_items: "{{ test_items }}"
+ loop_control:
+ loop_var: test_case_to_run
diff --git a/tests/integration/targets/vyos_command/tasks/main.yaml b/tests/integration/targets/vyos_command/tasks/main.yaml
new file mode 100644
index 0000000..b1f6193
--- /dev/null
+++ b/tests/integration/targets/vyos_command/tasks/main.yaml
@@ -0,0 +1,5 @@
+---
+- name: Run httpapi tests
+ ansible.builtin.include_tasks: httpapi.yaml
+ tags:
+ - httpapi
diff --git a/tests/integration/targets/vyos_command/tests/httpapi/show.yaml b/tests/integration/targets/vyos_command/tests/httpapi/show.yaml
new file mode 100644
index 0000000..fed35d7
--- /dev/null
+++ b/tests/integration/targets/vyos_command/tests/httpapi/show.yaml
@@ -0,0 +1,45 @@
+---
+- debug:
+ msg: START vyos_command show integration tests on connection={{ ansible_connection }}
+
+- block:
+ - name: Run show version
+ register: result
+ vyos.rest.vyos_command:
+ commands:
+ - version
+
+ - assert:
+ that:
+ - result.stdout | length == 1
+ - result.stdout[0] | length > 0
+ - "'VyOS' in result.stdout[0]"
+ - result.stdout_lines | length == 1
+
+ - name: Run multiple show commands
+ register: result
+ vyos.rest.vyos_command:
+ commands:
+ - interfaces
+ - ip route
+ - system uptime
+
+ - assert:
+ that:
+ - result.stdout | length == 3
+ - result.stdout_lines | length == 3
+ - result.stdout[0] | length > 0
+ - result.stdout[1] | length > 0
+ - result.stdout[2] | length > 0
+
+ - name: Run show commands as lists
+ register: result
+ vyos.rest.vyos_command:
+ commands:
+ - - system
+ - uptime
+
+ - assert:
+ that:
+ - result.stdout | length == 1
+ - "'Uptime' in result.stdout[0]"
diff --git a/tests/integration/targets/vyos_command/tests/httpapi/wait_for.yaml b/tests/integration/targets/vyos_command/tests/httpapi/wait_for.yaml
new file mode 100644
index 0000000..298c68c
--- /dev/null
+++ b/tests/integration/targets/vyos_command/tests/httpapi/wait_for.yaml
@@ -0,0 +1,36 @@
+---
+- debug:
+ msg: START vyos_command wait_for integration tests on connection={{ ansible_connection }}
+
+- block:
+ - name: Wait for version output to contain VyOS
+ register: result
+ vyos.rest.vyos_command:
+ commands:
+ - version
+ wait_for:
+ - result[0] contains VyOS
+ retries: 3
+ interval: 1
+
+ - assert:
+ that:
+ - result.stdout | length == 1
+ - "'VyOS' in result.stdout[0]"
+
+ - name: Wait for any condition to match
+ register: result
+ vyos.rest.vyos_command:
+ commands:
+ - version
+ - interfaces
+ wait_for:
+ - result[0] contains VyOS
+ - result[1] contains eth0
+ match: all
+ retries: 3
+ interval: 1
+
+ - assert:
+ that:
+ - result.stdout | length == 2
diff --git a/tests/integration/targets/vyos_command/vars/main.yaml b/tests/integration/targets/vyos_command/vars/main.yaml
new file mode 100644
index 0000000..4303881
--- /dev/null
+++ b/tests/integration/targets/vyos_command/vars/main.yaml
@@ -0,0 +1,2 @@
+---
+# only common vars here
diff --git a/tests/integration/targets/vyos_config/aliases b/tests/integration/targets/vyos_config/aliases
new file mode 100644
index 0000000..cc0afef
--- /dev/null
+++ b/tests/integration/targets/vyos_config/aliases
@@ -0,0 +1 @@
+network/vyos
diff --git a/tests/integration/targets/vyos_config/defaults/main.yaml b/tests/integration/targets/vyos_config/defaults/main.yaml
new file mode 100644
index 0000000..164afea
--- /dev/null
+++ b/tests/integration/targets/vyos_config/defaults/main.yaml
@@ -0,0 +1,3 @@
+---
+testcase: "[^_].*"
+test_items: []
diff --git a/tests/integration/targets/vyos_config/tasks/httpapi.yaml b/tests/integration/targets/vyos_config/tasks/httpapi.yaml
new file mode 100644
index 0000000..0ed3e42
--- /dev/null
+++ b/tests/integration/targets/vyos_config/tasks/httpapi.yaml
@@ -0,0 +1,21 @@
+---
+- name: Collect all httpapi test cases
+ ansible.builtin.find:
+ paths: "{{ role_path }}/tests/httpapi"
+ patterns: "{{ testcase }}.yaml"
+ use_regex: true
+ register: test_cases
+ delegate_to: localhost
+
+- name: Set test_items
+ ansible.builtin.set_fact:
+ test_items: "{{ test_cases.files | map(attribute='path') | list | sort }}"
+
+- name: Run test case (connection=httpapi)
+ ansible.builtin.include_tasks: "{{ test_case_to_run }}"
+ vars:
+ ansible_connection: ansible.netcommon.httpapi
+ ansible_network_os: vyos.rest.vyos
+ with_items: "{{ test_items }}"
+ loop_control:
+ loop_var: test_case_to_run
diff --git a/tests/integration/targets/vyos_config/tasks/main.yaml b/tests/integration/targets/vyos_config/tasks/main.yaml
new file mode 100644
index 0000000..b1f6193
--- /dev/null
+++ b/tests/integration/targets/vyos_config/tasks/main.yaml
@@ -0,0 +1,5 @@
+---
+- name: Run httpapi tests
+ ansible.builtin.include_tasks: httpapi.yaml
+ tags:
+ - httpapi
diff --git a/tests/integration/targets/vyos_config/tests/httpapi/_remove_config.yaml b/tests/integration/targets/vyos_config/tests/httpapi/_remove_config.yaml
new file mode 100644
index 0000000..2c602fa
--- /dev/null
+++ b/tests/integration/targets/vyos_config/tests/httpapi/_remove_config.yaml
@@ -0,0 +1,6 @@
+---
+- name: Remove test configuration
+ vyos.rest.vyos_config:
+ lines:
+ - delete interfaces ethernet eth1 description
+ match: none
diff --git a/tests/integration/targets/vyos_config/tests/httpapi/delete.yaml b/tests/integration/targets/vyos_config/tests/httpapi/delete.yaml
new file mode 100644
index 0000000..0db8e84
--- /dev/null
+++ b/tests/integration/targets/vyos_config/tests/httpapi/delete.yaml
@@ -0,0 +1,37 @@
+---
+- debug:
+ msg: START vyos_config delete integration tests on connection={{ ansible_connection }}
+
+- include_tasks: _remove_config.yaml
+
+- block:
+ - name: Setup - add description
+ vyos.rest.vyos_config:
+ lines:
+ - set interfaces ethernet eth1 description "To be deleted"
+ match: none
+
+ - name: Delete configuration
+ register: result
+ vyos.rest.vyos_config:
+ lines:
+ - delete interfaces ethernet eth1 description
+ save: true
+
+ - assert:
+ that:
+ - result.changed == true
+
+ - name: Delete again (IDEMPOTENT)
+ register: result
+ vyos.rest.vyos_config:
+ lines:
+ - delete interfaces ethernet eth1 description
+
+ - assert:
+ that:
+ - result.changed == false
+ - result.commands == []
+
+ always:
+ - include_tasks: _remove_config.yaml
diff --git a/tests/integration/targets/vyos_config/tests/httpapi/lines.yaml b/tests/integration/targets/vyos_config/tests/httpapi/lines.yaml
new file mode 100644
index 0000000..11b69ce
--- /dev/null
+++ b/tests/integration/targets/vyos_config/tests/httpapi/lines.yaml
@@ -0,0 +1,44 @@
+---
+- debug:
+ msg: START vyos_config lines integration tests on connection={{ ansible_connection }}
+
+- include_tasks: _remove_config.yaml
+
+- block:
+ - name: Apply configuration lines
+ register: result
+ vyos.rest.vyos_config:
+ lines:
+ - set interfaces ethernet eth1 description "Test description"
+ save: true
+
+ - assert:
+ that:
+ - result.changed == true
+ - result.commands | length == 1
+
+ - name: Apply same config (match=line IDEMPOTENT)
+ register: result
+ vyos.rest.vyos_config:
+ lines:
+ - set interfaces ethernet eth1 description "Test description"
+ match: line
+
+ - assert:
+ that:
+ - result.changed == false
+ - result.commands == []
+
+ - name: Apply with match=none (always apply)
+ register: result
+ vyos.rest.vyos_config:
+ lines:
+ - set interfaces ethernet eth1 description "Test description"
+ match: none
+
+ - assert:
+ that:
+ - result.changed == true
+
+ always:
+ - include_tasks: _remove_config.yaml
diff --git a/tests/integration/targets/vyos_config/vars/main.yaml b/tests/integration/targets/vyos_config/vars/main.yaml
new file mode 100644
index 0000000..4303881
--- /dev/null
+++ b/tests/integration/targets/vyos_config/vars/main.yaml
@@ -0,0 +1,2 @@
+---
+# only common vars here
diff --git a/tests/integration/targets/vyos_configure/tasks/httpapi.yaml b/tests/integration/targets/vyos_configure/tasks/httpapi.yaml
index 4147e6d..0ed3e42 100644
--- a/tests/integration/targets/vyos_configure/tasks/httpapi.yaml
+++ b/tests/integration/targets/vyos_configure/tasks/httpapi.yaml
@@ -9,7 +9,7 @@
- name: Set test_items
ansible.builtin.set_fact:
- test_items: "{{ test_cases.files | map(attribute='path') | list }}"
+ test_items: "{{ test_cases.files | map(attribute='path') | list | sort }}"
- name: Run test case (connection=httpapi)
ansible.builtin.include_tasks: "{{ test_case_to_run }}"
diff --git a/tests/integration/targets/vyos_facts/tasks/httpapi.yaml b/tests/integration/targets/vyos_facts/tasks/httpapi.yaml
index 4147e6d..0ed3e42 100644
--- a/tests/integration/targets/vyos_facts/tasks/httpapi.yaml
+++ b/tests/integration/targets/vyos_facts/tasks/httpapi.yaml
@@ -9,7 +9,7 @@
- name: Set test_items
ansible.builtin.set_fact:
- test_items: "{{ test_cases.files | map(attribute='path') | list }}"
+ test_items: "{{ test_cases.files | map(attribute='path') | list | sort }}"
- name: Run test case (connection=httpapi)
ansible.builtin.include_tasks: "{{ test_case_to_run }}"
diff --git a/tests/integration/targets/vyos_facts/tests/httpapi/_populate_config.yaml b/tests/integration/targets/vyos_facts/tests/httpapi/_populate_config.yaml
new file mode 100644
index 0000000..690c0c8
--- /dev/null
+++ b/tests/integration/targets/vyos_facts/tests/httpapi/_populate_config.yaml
@@ -0,0 +1,16 @@
+---
+- name: Configure logging for facts testing
+ vyos.rest.vyos_logging_global:
+ config:
+ global_params:
+ facilities:
+ - facility: all
+ severity: notice
+ state: merged
+
+- name: Configure NTP for facts testing
+ vyos.rest.vyos_ntp_global:
+ config:
+ servers:
+ - server: time1.vyos.net
+ state: merged
diff --git a/tests/integration/targets/vyos_facts/tests/httpapi/_remove_config.yaml b/tests/integration/targets/vyos_facts/tests/httpapi/_remove_config.yaml
new file mode 100644
index 0000000..fbc2e73
--- /dev/null
+++ b/tests/integration/targets/vyos_facts/tests/httpapi/_remove_config.yaml
@@ -0,0 +1,8 @@
+---
+- name: Remove logging config
+ vyos.rest.vyos_logging_global:
+ state: deleted
+
+- name: Remove NTP config
+ vyos.rest.vyos_ntp_global:
+ state: deleted
diff --git a/tests/integration/targets/vyos_facts/tests/httpapi/all.yaml b/tests/integration/targets/vyos_facts/tests/httpapi/all.yaml
index 419634f..4d6ad53 100644
--- a/tests/integration/targets/vyos_facts/tests/httpapi/all.yaml
+++ b/tests/integration/targets/vyos_facts/tests/httpapi/all.yaml
@@ -3,6 +3,8 @@
msg: START vyos_facts all integration tests on connection={{ ansible_connection }}
- block:
+ - include_tasks: _populate_config.yaml
+
- name: Gather all facts
vyos.rest.vyos_facts:
gather_subset: all
@@ -14,3 +16,6 @@
- ansible_facts.vyos_users is defined
- ansible_facts.vyos_config is defined
- ansible_facts.vyos_logging is defined
+
+ always:
+ - include_tasks: _remove_config.yaml
diff --git a/tests/integration/targets/vyos_facts/tests/httpapi/default.yaml b/tests/integration/targets/vyos_facts/tests/httpapi/default.yaml
index ff1caee..b5937be 100644
--- a/tests/integration/targets/vyos_facts/tests/httpapi/default.yaml
+++ b/tests/integration/targets/vyos_facts/tests/httpapi/default.yaml
@@ -5,9 +5,9 @@
- block:
- name: Gather default facts
vyos.rest.vyos_facts:
+ gather_subset: default
- assert:
that:
- ansible_facts.vyos_hostname is defined
- - ansible_facts.vyos_interfaces is defined
- - ansible_facts.vyos_hostname | length > 0
+ - ansible_facts.vyos_config is defined
diff --git a/tests/integration/targets/vyos_facts/tests/httpapi/subsets.yaml b/tests/integration/targets/vyos_facts/tests/httpapi/subsets.yaml
index eb4ca4f..5151b4a 100644
--- a/tests/integration/targets/vyos_facts/tests/httpapi/subsets.yaml
+++ b/tests/integration/targets/vyos_facts/tests/httpapi/subsets.yaml
@@ -3,31 +3,34 @@
msg: START vyos_facts subsets integration tests on connection={{ ansible_connection }}
- block:
- - name: Gather hostname only
+ - include_tasks: _populate_config.yaml
+
+ - name: Gather logging subset
vyos.rest.vyos_facts:
gather_subset:
- - hostname
+ - logging
- assert:
that:
- - ansible_facts.vyos_hostname is defined
+ - ansible_facts.vyos_logging is defined
- - name: Gather users
+ - name: Gather ntp subset
vyos.rest.vyos_facts:
gather_subset:
- - users
+ - ntp
- assert:
that:
- - ansible_facts.vyos_users is defined
- - ansible_facts.vyos_users | selectattr('name', 'eq', 'vyos') | list | length == 1
+ - ansible_facts.vyos_ntp is defined
- - name: Gather config
+ - name: Gather interfaces subset
vyos.rest.vyos_facts:
gather_subset:
- - config
+ - interfaces
- assert:
that:
- - ansible_facts.vyos_config is defined
- - ansible_facts.vyos_config.system is defined
+ - ansible_facts.vyos_interfaces is defined
+
+ always:
+ - include_tasks: _remove_config.yaml
diff --git a/tests/integration/targets/vyos_firewall_global/tasks/httpapi.yaml b/tests/integration/targets/vyos_firewall_global/tasks/httpapi.yaml
index 4147e6d..0ed3e42 100644
--- a/tests/integration/targets/vyos_firewall_global/tasks/httpapi.yaml
+++ b/tests/integration/targets/vyos_firewall_global/tasks/httpapi.yaml
@@ -9,7 +9,7 @@
- name: Set test_items
ansible.builtin.set_fact:
- test_items: "{{ test_cases.files | map(attribute='path') | list }}"
+ test_items: "{{ test_cases.files | map(attribute='path') | list | sort }}"
- name: Run test case (connection=httpapi)
ansible.builtin.include_tasks: "{{ test_case_to_run }}"
diff --git a/tests/integration/targets/vyos_firewall_global/tests/httpapi/deleted.yaml b/tests/integration/targets/vyos_firewall_global/tests/httpapi/deleted.yaml
index ade5fe4..8f1b327 100644
--- a/tests/integration/targets/vyos_firewall_global/tests/httpapi/deleted.yaml
+++ b/tests/integration/targets/vyos_firewall_global/tests/httpapi/deleted.yaml
@@ -3,9 +3,9 @@
msg: START vyos_firewall_global deleted integration tests on connection={{ ansible_connection }}
- include_tasks: _remove_config.yaml
-- include_tasks: _populate_config.yaml
-
- block:
+ - include_tasks: _populate_config.yaml
+
- name: Delete firewall global configuration
register: result
vyos.rest.vyos_firewall_global:
diff --git a/tests/integration/targets/vyos_firewall_global/tests/httpapi/gathered.yaml b/tests/integration/targets/vyos_firewall_global/tests/httpapi/gathered.yaml
index 6f470bd..50df093 100644
--- a/tests/integration/targets/vyos_firewall_global/tests/httpapi/gathered.yaml
+++ b/tests/integration/targets/vyos_firewall_global/tests/httpapi/gathered.yaml
@@ -3,9 +3,9 @@
msg: START vyos_firewall_global gathered integration tests on connection={{ ansible_connection }}
- include_tasks: _remove_config.yaml
-- include_tasks: _populate_config.yaml
-
- block:
+ - include_tasks: _populate_config.yaml
+
- name: Gather firewall global configuration
register: result
vyos.rest.vyos_firewall_global:
diff --git a/tests/integration/targets/vyos_firewall_global/tests/httpapi/overridden.yaml b/tests/integration/targets/vyos_firewall_global/tests/httpapi/overridden.yaml
index 04d7c95..97e76b5 100644
--- a/tests/integration/targets/vyos_firewall_global/tests/httpapi/overridden.yaml
+++ b/tests/integration/targets/vyos_firewall_global/tests/httpapi/overridden.yaml
@@ -3,9 +3,9 @@
msg: START vyos_firewall_global overridden integration tests on connection={{ ansible_connection }}
- include_tasks: _remove_config.yaml
-- include_tasks: _populate_config.yaml
-
- block:
+ - include_tasks: _populate_config.yaml
+
- name: Override firewall global configuration
register: result
vyos.rest.vyos_firewall_global: &id001
diff --git a/tests/integration/targets/vyos_firewall_global/tests/httpapi/replaced.yaml b/tests/integration/targets/vyos_firewall_global/tests/httpapi/replaced.yaml
index b5af06f..661d983 100644
--- a/tests/integration/targets/vyos_firewall_global/tests/httpapi/replaced.yaml
+++ b/tests/integration/targets/vyos_firewall_global/tests/httpapi/replaced.yaml
@@ -3,9 +3,9 @@
msg: START vyos_firewall_global replaced integration tests on connection={{ ansible_connection }}
- include_tasks: _remove_config.yaml
-- include_tasks: _populate_config.yaml
-
- block:
+ - include_tasks: _populate_config.yaml
+
- name: Replace firewall global configuration
register: result
vyos.rest.vyos_firewall_global: &id001
diff --git a/tests/integration/targets/vyos_firewall_interfaces/tasks/httpapi.yaml b/tests/integration/targets/vyos_firewall_interfaces/tasks/httpapi.yaml
index 4147e6d..0ed3e42 100644
--- a/tests/integration/targets/vyos_firewall_interfaces/tasks/httpapi.yaml
+++ b/tests/integration/targets/vyos_firewall_interfaces/tasks/httpapi.yaml
@@ -9,7 +9,7 @@
- name: Set test_items
ansible.builtin.set_fact:
- test_items: "{{ test_cases.files | map(attribute='path') | list }}"
+ test_items: "{{ test_cases.files | map(attribute='path') | list | sort }}"
- name: Run test case (connection=httpapi)
ansible.builtin.include_tasks: "{{ test_case_to_run }}"
diff --git a/tests/integration/targets/vyos_firewall_interfaces/tests/httpapi/deleted.yaml b/tests/integration/targets/vyos_firewall_interfaces/tests/httpapi/deleted.yaml
index 00025f7..9e76d63 100644
--- a/tests/integration/targets/vyos_firewall_interfaces/tests/httpapi/deleted.yaml
+++ b/tests/integration/targets/vyos_firewall_interfaces/tests/httpapi/deleted.yaml
@@ -3,9 +3,9 @@
msg: START vyos_firewall_interfaces deleted integration tests on connection={{ ansible_connection }}
- include_tasks: _remove_config.yaml
-- include_tasks: _populate_config.yaml
-
- block:
+ - include_tasks: _populate_config.yaml
+
- name: Delete all firewall interfaces configuration
register: result
vyos.rest.vyos_firewall_interfaces:
diff --git a/tests/integration/targets/vyos_firewall_interfaces/tests/httpapi/gathered.yaml b/tests/integration/targets/vyos_firewall_interfaces/tests/httpapi/gathered.yaml
index 27ad537..192f2f0 100644
--- a/tests/integration/targets/vyos_firewall_interfaces/tests/httpapi/gathered.yaml
+++ b/tests/integration/targets/vyos_firewall_interfaces/tests/httpapi/gathered.yaml
@@ -3,9 +3,9 @@
msg: START vyos_firewall_interfaces gathered integration tests on connection={{ ansible_connection }}
- include_tasks: _remove_config.yaml
-- include_tasks: _populate_config.yaml
-
- block:
+ - include_tasks: _populate_config.yaml
+
- name: Gather firewall interfaces configuration
register: result
vyos.rest.vyos_firewall_interfaces:
diff --git a/tests/integration/targets/vyos_firewall_interfaces/tests/httpapi/overridden.yaml b/tests/integration/targets/vyos_firewall_interfaces/tests/httpapi/overridden.yaml
index 8a20a48..ff69cc1 100644
--- a/tests/integration/targets/vyos_firewall_interfaces/tests/httpapi/overridden.yaml
+++ b/tests/integration/targets/vyos_firewall_interfaces/tests/httpapi/overridden.yaml
@@ -3,9 +3,9 @@
msg: START vyos_firewall_interfaces overridden integration tests on connection={{ ansible_connection }}
- include_tasks: _remove_config.yaml
-- include_tasks: _populate_config.yaml
-
- block:
+ - include_tasks: _populate_config.yaml
+
- name: Override firewall interfaces configuration
register: result
vyos.rest.vyos_firewall_interfaces: &id001
diff --git a/tests/integration/targets/vyos_firewall_interfaces/tests/httpapi/replaced.yaml b/tests/integration/targets/vyos_firewall_interfaces/tests/httpapi/replaced.yaml
index f782fc6..7109dd4 100644
--- a/tests/integration/targets/vyos_firewall_interfaces/tests/httpapi/replaced.yaml
+++ b/tests/integration/targets/vyos_firewall_interfaces/tests/httpapi/replaced.yaml
@@ -3,9 +3,9 @@
msg: START vyos_firewall_interfaces replaced integration tests on connection={{ ansible_connection }}
- include_tasks: _remove_config.yaml
-- include_tasks: _populate_config.yaml
-
- block:
+ - include_tasks: _populate_config.yaml
+
- name: Replace firewall interfaces configuration
register: result
vyos.rest.vyos_firewall_interfaces: &id001
diff --git a/tests/integration/targets/vyos_firewall_rules/tasks/httpapi.yaml b/tests/integration/targets/vyos_firewall_rules/tasks/httpapi.yaml
index 4147e6d..0ed3e42 100644
--- a/tests/integration/targets/vyos_firewall_rules/tasks/httpapi.yaml
+++ b/tests/integration/targets/vyos_firewall_rules/tasks/httpapi.yaml
@@ -9,7 +9,7 @@
- name: Set test_items
ansible.builtin.set_fact:
- test_items: "{{ test_cases.files | map(attribute='path') | list }}"
+ test_items: "{{ test_cases.files | map(attribute='path') | list | sort }}"
- name: Run test case (connection=httpapi)
ansible.builtin.include_tasks: "{{ test_case_to_run }}"
diff --git a/tests/integration/targets/vyos_firewall_rules/tests/httpapi/deleted.yaml b/tests/integration/targets/vyos_firewall_rules/tests/httpapi/deleted.yaml
index db4cbf0..78a4d62 100644
--- a/tests/integration/targets/vyos_firewall_rules/tests/httpapi/deleted.yaml
+++ b/tests/integration/targets/vyos_firewall_rules/tests/httpapi/deleted.yaml
@@ -3,9 +3,9 @@
msg: START vyos_firewall_rules deleted integration tests on connection={{ ansible_connection }}
- include_tasks: _remove_config.yaml
-- include_tasks: _populate_config.yaml
-
- block:
+ - include_tasks: _populate_config.yaml
+
- name: Delete all firewall rules configuration
register: result
vyos.rest.vyos_firewall_rules:
diff --git a/tests/integration/targets/vyos_firewall_rules/tests/httpapi/gathered.yaml b/tests/integration/targets/vyos_firewall_rules/tests/httpapi/gathered.yaml
index 6729976..89f54ca 100644
--- a/tests/integration/targets/vyos_firewall_rules/tests/httpapi/gathered.yaml
+++ b/tests/integration/targets/vyos_firewall_rules/tests/httpapi/gathered.yaml
@@ -3,9 +3,9 @@
msg: START vyos_firewall_rules gathered integration tests on connection={{ ansible_connection }}
- include_tasks: _remove_config.yaml
-- include_tasks: _populate_config.yaml
-
- block:
+ - include_tasks: _populate_config.yaml
+
- name: Gather firewall rules configuration
register: result
vyos.rest.vyos_firewall_rules:
diff --git a/tests/integration/targets/vyos_firewall_rules/tests/httpapi/overridden.yaml b/tests/integration/targets/vyos_firewall_rules/tests/httpapi/overridden.yaml
index d5db4b9..31dcc8d 100644
--- a/tests/integration/targets/vyos_firewall_rules/tests/httpapi/overridden.yaml
+++ b/tests/integration/targets/vyos_firewall_rules/tests/httpapi/overridden.yaml
@@ -3,9 +3,9 @@
msg: START vyos_firewall_rules overridden integration tests on connection={{ ansible_connection }}
- include_tasks: _remove_config.yaml
-- include_tasks: _populate_config.yaml
-
- block:
+ - include_tasks: _populate_config.yaml
+
- name: Override firewall rules configuration
register: result
vyos.rest.vyos_firewall_rules: &id001
diff --git a/tests/integration/targets/vyos_firewall_rules/tests/httpapi/replaced.yaml b/tests/integration/targets/vyos_firewall_rules/tests/httpapi/replaced.yaml
index 92033c4..1200fb7 100644
--- a/tests/integration/targets/vyos_firewall_rules/tests/httpapi/replaced.yaml
+++ b/tests/integration/targets/vyos_firewall_rules/tests/httpapi/replaced.yaml
@@ -3,9 +3,9 @@
msg: START vyos_firewall_rules replaced integration tests on connection={{ ansible_connection }}
- include_tasks: _remove_config.yaml
-- include_tasks: _populate_config.yaml
-
- block:
+ - include_tasks: _populate_config.yaml
+
- name: Replace firewall rules configuration
register: result
vyos.rest.vyos_firewall_rules: &id001
diff --git a/tests/integration/targets/vyos_ha/aliases b/tests/integration/targets/vyos_ha/aliases
new file mode 100644
index 0000000..cc0afef
--- /dev/null
+++ b/tests/integration/targets/vyos_ha/aliases
@@ -0,0 +1 @@
+network/vyos
diff --git a/tests/integration/targets/vyos_ha/defaults/main.yaml b/tests/integration/targets/vyos_ha/defaults/main.yaml
new file mode 100644
index 0000000..164afea
--- /dev/null
+++ b/tests/integration/targets/vyos_ha/defaults/main.yaml
@@ -0,0 +1,3 @@
+---
+testcase: "[^_].*"
+test_items: []
diff --git a/tests/integration/targets/vyos_ha/tasks/httpapi.yaml b/tests/integration/targets/vyos_ha/tasks/httpapi.yaml
new file mode 100644
index 0000000..014aa9d
--- /dev/null
+++ b/tests/integration/targets/vyos_ha/tasks/httpapi.yaml
@@ -0,0 +1,18 @@
+---
+- name: Collect all httpapi test cases
+ ansible.builtin.find:
+ paths: "{{ role_path }}/tests/httpapi"
+ patterns: "*.yaml"
+ excludes: "_*.yaml"
+ register: test_cases
+ delegate_to: localhost
+
+- name: Set test_items
+ ansible.builtin.set_fact:
+ test_items: "{{ test_cases.files | map(attribute='path') | list | sort }}"
+
+- name: Run test case (connection=httpapi)
+ ansible.builtin.include_tasks: "{{ test_case_to_run }}"
+ loop: "{{ test_items }}"
+ loop_control:
+ loop_var: test_case_to_run
diff --git a/tests/integration/targets/vyos_ha/tasks/main.yaml b/tests/integration/targets/vyos_ha/tasks/main.yaml
new file mode 100644
index 0000000..d14215c
--- /dev/null
+++ b/tests/integration/targets/vyos_ha/tasks/main.yaml
@@ -0,0 +1,3 @@
+---
+- name: Run httpapi tests
+ ansible.builtin.include_tasks: httpapi.yaml
diff --git a/tests/integration/targets/vyos_ha/tests/httpapi/_populate_config.yaml b/tests/integration/targets/vyos_ha/tests/httpapi/_populate_config.yaml
new file mode 100644
index 0000000..0a73b89
--- /dev/null
+++ b/tests/integration/targets/vyos_ha/tests/httpapi/_populate_config.yaml
@@ -0,0 +1,40 @@
+---
+- name: Populate HA config for testing
+ vyos.rest.vyos_ha:
+ config:
+ vrrp:
+ global_parameters:
+ startup_delay: 30
+ garp:
+ master_repeat: 6
+ groups:
+ - name: g1
+ interface: eth0
+ vrid: 20
+ priority: 100
+ address:
+ - 192.168.1.100/24
+ peer_address: 192.168.1.3
+ - name: g2
+ interface: eth1
+ vrid: 21
+ priority: 50
+ address:
+ - 192.168.2.100/24
+ sync_groups:
+ - name: sg1
+ member: [g1]
+ health_check:
+ failure_count: 5
+ ping: 192.168.1.1
+ snmp: enabled
+ virtual_servers:
+ - name: s1
+ address: 10.10.10.5
+ algorithm: round-robin
+ port: 80
+ protocol: tcp
+ real_server:
+ - address: 10.10.50.2
+ port: 8080
+ state: merged
diff --git a/tests/integration/targets/vyos_ha/tests/httpapi/_remove_config.yaml b/tests/integration/targets/vyos_ha/tests/httpapi/_remove_config.yaml
new file mode 100644
index 0000000..77a90d0
--- /dev/null
+++ b/tests/integration/targets/vyos_ha/tests/httpapi/_remove_config.yaml
@@ -0,0 +1,4 @@
+---
+- name: Remove all HA configuration
+ vyos.rest.vyos_ha:
+ state: deleted
diff --git a/tests/integration/targets/vyos_ha/tests/httpapi/deleted.yaml b/tests/integration/targets/vyos_ha/tests/httpapi/deleted.yaml
new file mode 100644
index 0000000..571e02d
--- /dev/null
+++ b/tests/integration/targets/vyos_ha/tests/httpapi/deleted.yaml
@@ -0,0 +1,36 @@
+---
+- debug:
+ msg: START vyos_ha deleted integration tests on connection={{ ansible_connection }}
+
+- include_tasks: _remove_config.yaml
+- block:
+ - include_tasks: _populate_config.yaml
+
+ - name: Delete all HA configuration
+ vyos.rest.vyos_ha: &id001
+ state: deleted
+ register: result
+
+ - assert:
+ that:
+ - result.changed == true
+
+ - name: Gather after delete
+ vyos.rest.vyos_ha:
+ state: gathered
+ register: gathered
+
+ - assert:
+ that:
+ - gathered.gathered == {}
+
+ - name: Delete again (IDEMPOTENT)
+ vyos.rest.vyos_ha: *id001
+ register: result
+
+ - assert:
+ that:
+ - result.changed == false
+
+ always:
+ - include_tasks: _remove_config.yaml
diff --git a/tests/integration/targets/vyos_ha/tests/httpapi/gathered.yaml b/tests/integration/targets/vyos_ha/tests/httpapi/gathered.yaml
new file mode 100644
index 0000000..cf261fb
--- /dev/null
+++ b/tests/integration/targets/vyos_ha/tests/httpapi/gathered.yaml
@@ -0,0 +1,29 @@
+---
+- debug:
+ msg: START vyos_ha gathered integration tests on connection={{ ansible_connection }}
+
+- include_tasks: _remove_config.yaml
+- block:
+ - include_tasks: _populate_config.yaml
+
+ - name: Gather HA configuration
+ vyos.rest.vyos_ha:
+ state: gathered
+ register: result
+
+ - assert:
+ that:
+ - result.changed == false
+ - "'vrrp' in result.gathered"
+ - "'virtual_servers' in result.gathered"
+ - result.gathered.vrrp.groups | length == 2
+ - result.gathered.vrrp.groups | selectattr('name','equalto','g1') | list | length == 1
+ - result.gathered.vrrp.groups | selectattr('name','equalto','g2') | list | length == 1
+ - result.gathered.vrrp.snmp == "enabled"
+ - result.gathered.vrrp.global_parameters.startup_delay == 30
+ - result.gathered.vrrp.sync_groups[0].name == "sg1"
+ - result.gathered.vrrp.sync_groups[0].member == ["g1"]
+ - result.gathered.virtual_servers[0].name == "s1"
+
+ always:
+ - include_tasks: _remove_config.yaml
diff --git a/tests/integration/targets/vyos_ha/tests/httpapi/merged.yaml b/tests/integration/targets/vyos_ha/tests/httpapi/merged.yaml
new file mode 100644
index 0000000..5614720
--- /dev/null
+++ b/tests/integration/targets/vyos_ha/tests/httpapi/merged.yaml
@@ -0,0 +1,54 @@
+---
+- debug:
+ msg: START vyos_ha merged integration tests on connection={{ ansible_connection }}
+
+- include_tasks: _remove_config.yaml
+
+- block:
+ - name: Merge HA configuration
+ vyos.rest.vyos_ha: &id001
+ config:
+ vrrp:
+ global_parameters:
+ startup_delay: 30
+ groups:
+ - name: g1
+ interface: eth0
+ vrid: 20
+ priority: 100
+ address:
+ - 192.168.1.100/24
+ sync_groups:
+ - name: sg1
+ member: [g1]
+ health_check:
+ failure_count: 3
+ ping: 192.168.1.1
+ snmp: enabled
+ virtual_servers:
+ - name: s1
+ address: 10.10.10.5
+ port: 80
+ protocol: tcp
+ real_server:
+ - address: 10.10.50.2
+ port: 8080
+ state: merged
+ register: result
+
+ - assert:
+ that:
+ - result.changed == true
+ - result.commands | length > 0
+
+ - name: Merge again (IDEMPOTENT)
+ vyos.rest.vyos_ha: *id001
+ register: result
+
+ - assert:
+ that:
+ - result.changed == false
+ - result.commands == []
+
+ always:
+ - include_tasks: _remove_config.yaml
diff --git a/tests/integration/targets/vyos_ha/tests/httpapi/overridden.yaml b/tests/integration/targets/vyos_ha/tests/httpapi/overridden.yaml
new file mode 100644
index 0000000..c916e2d
--- /dev/null
+++ b/tests/integration/targets/vyos_ha/tests/httpapi/overridden.yaml
@@ -0,0 +1,50 @@
+---
+- debug:
+ msg: START vyos_ha overridden integration tests on connection={{ ansible_connection }}
+
+- include_tasks: _remove_config.yaml
+- block:
+ - include_tasks: _populate_config.yaml
+
+ - name: Override - only g1 remains, virtual_servers removed
+ vyos.rest.vyos_ha: &id001
+ config:
+ vrrp:
+ groups:
+ - name: g1
+ interface: eth0
+ vrid: 20
+ priority: 100
+ address:
+ - 192.168.1.100/24
+ state: overridden
+ register: result
+
+ - assert:
+ that:
+ - result.changed == true
+
+ - name: Gather after override
+ vyos.rest.vyos_ha:
+ state: gathered
+ register: gathered
+
+ - assert:
+ that:
+ - gathered.gathered.vrrp.groups | length == 1
+ - gathered.gathered.vrrp.groups[0].name == "g1"
+ - "'virtual_servers' not in gathered.gathered"
+ - "'sync_groups' not in gathered.gathered.vrrp"
+ - "'snmp' not in gathered.gathered.vrrp"
+
+ - name: Override again (IDEMPOTENT)
+ vyos.rest.vyos_ha: *id001
+ register: result
+
+ - assert:
+ that:
+ - result.changed == false
+ - result.commands == []
+
+ always:
+ - include_tasks: _remove_config.yaml
diff --git a/tests/integration/targets/vyos_ha/tests/httpapi/replaced.yaml b/tests/integration/targets/vyos_ha/tests/httpapi/replaced.yaml
new file mode 100644
index 0000000..6ad458b
--- /dev/null
+++ b/tests/integration/targets/vyos_ha/tests/httpapi/replaced.yaml
@@ -0,0 +1,48 @@
+---
+- debug:
+ msg: START vyos_ha replaced integration tests on connection={{ ansible_connection }}
+
+- include_tasks: _remove_config.yaml
+- block:
+ - include_tasks: _populate_config.yaml
+
+ - name: Replace vrrp groups - removes g2, keeps virtual_servers
+ vyos.rest.vyos_ha: &id001
+ config:
+ vrrp:
+ groups:
+ - name: g1
+ interface: eth0
+ vrid: 20
+ priority: 100
+ address:
+ - 192.168.1.100/24
+ state: replaced
+ register: result
+
+ - assert:
+ that:
+ - result.changed == true
+
+ - name: Gather after replace
+ vyos.rest.vyos_ha:
+ state: gathered
+ register: gathered
+
+ - assert:
+ that:
+ - gathered.gathered.vrrp.groups | length == 1
+ - gathered.gathered.vrrp.groups[0].name == "g1"
+ - "'virtual_servers' in gathered.gathered"
+
+ - name: Replace again (IDEMPOTENT)
+ vyos.rest.vyos_ha: *id001
+ register: result
+
+ - assert:
+ that:
+ - result.changed == false
+ - result.commands == []
+
+ always:
+ - include_tasks: _remove_config.yaml
diff --git a/tests/integration/targets/vyos_ha/vars/main.yaml b/tests/integration/targets/vyos_ha/vars/main.yaml
new file mode 100644
index 0000000..4303881
--- /dev/null
+++ b/tests/integration/targets/vyos_ha/vars/main.yaml
@@ -0,0 +1,2 @@
+---
+# only common vars here
diff --git a/tests/integration/targets/vyos_hostname/tasks/httpapi.yaml b/tests/integration/targets/vyos_hostname/tasks/httpapi.yaml
index 4147e6d..0ed3e42 100644
--- a/tests/integration/targets/vyos_hostname/tasks/httpapi.yaml
+++ b/tests/integration/targets/vyos_hostname/tasks/httpapi.yaml
@@ -9,7 +9,7 @@
- name: Set test_items
ansible.builtin.set_fact:
- test_items: "{{ test_cases.files | map(attribute='path') | list }}"
+ test_items: "{{ test_cases.files | map(attribute='path') | list | sort }}"
- name: Run test case (connection=httpapi)
ansible.builtin.include_tasks: "{{ test_case_to_run }}"
diff --git a/tests/integration/targets/vyos_hostname/tests/httpapi/gathered.yaml b/tests/integration/targets/vyos_hostname/tests/httpapi/gathered.yaml
index dfc8896..e8e0973 100644
--- a/tests/integration/targets/vyos_hostname/tests/httpapi/gathered.yaml
+++ b/tests/integration/targets/vyos_hostname/tests/httpapi/gathered.yaml
@@ -2,9 +2,9 @@
- debug:
msg: START vyos_hostname gathered integration tests on connection={{ ansible_connection }}
-- include_tasks: _populate_config.yaml
-
- block:
+ - include_tasks: _populate_config.yaml
+
- name: Gather hostname configuration
register: result
vyos.rest.vyos_hostname:
diff --git a/tests/integration/targets/vyos_hostname/tests/httpapi/replaced.yaml b/tests/integration/targets/vyos_hostname/tests/httpapi/replaced.yaml
index b4e318d..0d418e2 100644
--- a/tests/integration/targets/vyos_hostname/tests/httpapi/replaced.yaml
+++ b/tests/integration/targets/vyos_hostname/tests/httpapi/replaced.yaml
@@ -2,9 +2,9 @@
- debug:
msg: START vyos_hostname replaced integration tests on connection={{ ansible_connection }}
-- include_tasks: _populate_config.yaml
-
- block:
+ - include_tasks: _populate_config.yaml
+
- name: Replace hostname configuration
register: result
vyos.rest.vyos_hostname: &id001
diff --git a/tests/integration/targets/vyos_lldp_global/tasks/httpapi.yaml b/tests/integration/targets/vyos_lldp_global/tasks/httpapi.yaml
index 4147e6d..0ed3e42 100644
--- a/tests/integration/targets/vyos_lldp_global/tasks/httpapi.yaml
+++ b/tests/integration/targets/vyos_lldp_global/tasks/httpapi.yaml
@@ -9,7 +9,7 @@
- name: Set test_items
ansible.builtin.set_fact:
- test_items: "{{ test_cases.files | map(attribute='path') | list }}"
+ test_items: "{{ test_cases.files | map(attribute='path') | list | sort }}"
- name: Run test case (connection=httpapi)
ansible.builtin.include_tasks: "{{ test_case_to_run }}"
diff --git a/tests/integration/targets/vyos_lldp_global/tests/httpapi/deleted.yaml b/tests/integration/targets/vyos_lldp_global/tests/httpapi/deleted.yaml
index d084d4a..bb0f7fc 100644
--- a/tests/integration/targets/vyos_lldp_global/tests/httpapi/deleted.yaml
+++ b/tests/integration/targets/vyos_lldp_global/tests/httpapi/deleted.yaml
@@ -3,9 +3,9 @@
msg: START vyos_lldp_global deleted integration tests on connection={{ ansible_connection }}
- include_tasks: _remove_config.yaml
-- include_tasks: _populate_config.yaml
-
- block:
+ - include_tasks: _populate_config.yaml
+
- name: Delete all lldp_global configuration
register: result
vyos.rest.vyos_lldp_global: &id001
diff --git a/tests/integration/targets/vyos_lldp_global/tests/httpapi/gathered.yaml b/tests/integration/targets/vyos_lldp_global/tests/httpapi/gathered.yaml
index 20ac2b2..58c55cf 100644
--- a/tests/integration/targets/vyos_lldp_global/tests/httpapi/gathered.yaml
+++ b/tests/integration/targets/vyos_lldp_global/tests/httpapi/gathered.yaml
@@ -3,9 +3,9 @@
msg: START vyos_lldp_global gathered integration tests on connection={{ ansible_connection }}
- include_tasks: _remove_config.yaml
-- include_tasks: _populate_config.yaml
-
- block:
+ - include_tasks: _populate_config.yaml
+
- name: Gather lldp_global configuration
register: result
vyos.rest.vyos_lldp_global:
diff --git a/tests/integration/targets/vyos_lldp_global/tests/httpapi/replaced.yaml b/tests/integration/targets/vyos_lldp_global/tests/httpapi/replaced.yaml
index d5523eb..edc908a 100644
--- a/tests/integration/targets/vyos_lldp_global/tests/httpapi/replaced.yaml
+++ b/tests/integration/targets/vyos_lldp_global/tests/httpapi/replaced.yaml
@@ -3,9 +3,9 @@
msg: START vyos_lldp_global replaced integration tests on connection={{ ansible_connection }}
- include_tasks: _remove_config.yaml
-- include_tasks: _populate_config.yaml
-
- block:
+ - include_tasks: _populate_config.yaml
+
- name: Replace lldp_global configuration
register: result
vyos.rest.vyos_lldp_global: &id001
diff --git a/tests/integration/targets/vyos_logging_global/tasks/httpapi.yaml b/tests/integration/targets/vyos_logging_global/tasks/httpapi.yaml
index 4147e6d..0ed3e42 100644
--- a/tests/integration/targets/vyos_logging_global/tasks/httpapi.yaml
+++ b/tests/integration/targets/vyos_logging_global/tasks/httpapi.yaml
@@ -9,7 +9,7 @@
- name: Set test_items
ansible.builtin.set_fact:
- test_items: "{{ test_cases.files | map(attribute='path') | list }}"
+ test_items: "{{ test_cases.files | map(attribute='path') | list | sort }}"
- name: Run test case (connection=httpapi)
ansible.builtin.include_tasks: "{{ test_case_to_run }}"
diff --git a/tests/integration/targets/vyos_logging_global/tests/httpapi/deleted.yaml b/tests/integration/targets/vyos_logging_global/tests/httpapi/deleted.yaml
index 8c62627..6eb4ed9 100644
--- a/tests/integration/targets/vyos_logging_global/tests/httpapi/deleted.yaml
+++ b/tests/integration/targets/vyos_logging_global/tests/httpapi/deleted.yaml
@@ -3,9 +3,9 @@
msg: START vyos_logging_global deleted integration tests on connection={{ ansible_connection }}
- include_tasks: _remove_config.yaml
-- include_tasks: _populate_config.yaml
-
- block:
+ - include_tasks: _populate_config.yaml
+
- name: Delete all logging_global configuration
register: result
vyos.rest.vyos_logging_global: &id001
diff --git a/tests/integration/targets/vyos_logging_global/tests/httpapi/gathered.yaml b/tests/integration/targets/vyos_logging_global/tests/httpapi/gathered.yaml
index 667c088..660d1fc 100644
--- a/tests/integration/targets/vyos_logging_global/tests/httpapi/gathered.yaml
+++ b/tests/integration/targets/vyos_logging_global/tests/httpapi/gathered.yaml
@@ -3,9 +3,9 @@
msg: START vyos_logging_global gathered integration tests on connection={{ ansible_connection }}
- include_tasks: _remove_config.yaml
-- include_tasks: _populate_config.yaml
-
- block:
+ - include_tasks: _populate_config.yaml
+
- name: Gather logging_global configuration
register: result
vyos.rest.vyos_logging_global:
diff --git a/tests/integration/targets/vyos_logging_global/tests/httpapi/replaced.yaml b/tests/integration/targets/vyos_logging_global/tests/httpapi/replaced.yaml
index a1c24c0..3ecd341 100644
--- a/tests/integration/targets/vyos_logging_global/tests/httpapi/replaced.yaml
+++ b/tests/integration/targets/vyos_logging_global/tests/httpapi/replaced.yaml
@@ -3,9 +3,9 @@
msg: START vyos_logging_global replaced integration tests on connection={{ ansible_connection }}
- include_tasks: _remove_config.yaml
-- include_tasks: _populate_config.yaml
-
- block:
+ - include_tasks: _populate_config.yaml
+
- name: Replace logging_global configuration
register: result
vyos.rest.vyos_logging_global: &id001
diff --git a/tests/integration/targets/vyos_nat/aliases b/tests/integration/targets/vyos_nat/aliases
new file mode 100644
index 0000000..cc0afef
--- /dev/null
+++ b/tests/integration/targets/vyos_nat/aliases
@@ -0,0 +1 @@
+network/vyos
diff --git a/tests/integration/targets/vyos_nat/defaults/main.yaml b/tests/integration/targets/vyos_nat/defaults/main.yaml
new file mode 100644
index 0000000..164afea
--- /dev/null
+++ b/tests/integration/targets/vyos_nat/defaults/main.yaml
@@ -0,0 +1,3 @@
+---
+testcase: "[^_].*"
+test_items: []
diff --git a/tests/integration/targets/vyos_nat/tasks/httpapi.yaml b/tests/integration/targets/vyos_nat/tasks/httpapi.yaml
new file mode 100644
index 0000000..014aa9d
--- /dev/null
+++ b/tests/integration/targets/vyos_nat/tasks/httpapi.yaml
@@ -0,0 +1,18 @@
+---
+- name: Collect all httpapi test cases
+ ansible.builtin.find:
+ paths: "{{ role_path }}/tests/httpapi"
+ patterns: "*.yaml"
+ excludes: "_*.yaml"
+ register: test_cases
+ delegate_to: localhost
+
+- name: Set test_items
+ ansible.builtin.set_fact:
+ test_items: "{{ test_cases.files | map(attribute='path') | list | sort }}"
+
+- name: Run test case (connection=httpapi)
+ ansible.builtin.include_tasks: "{{ test_case_to_run }}"
+ loop: "{{ test_items }}"
+ loop_control:
+ loop_var: test_case_to_run
diff --git a/tests/integration/targets/vyos_nat/tasks/main.yaml b/tests/integration/targets/vyos_nat/tasks/main.yaml
new file mode 100644
index 0000000..b1f6193
--- /dev/null
+++ b/tests/integration/targets/vyos_nat/tasks/main.yaml
@@ -0,0 +1,5 @@
+---
+- name: Run httpapi tests
+ ansible.builtin.include_tasks: httpapi.yaml
+ tags:
+ - httpapi
diff --git a/tests/integration/targets/vyos_nat/tests/httpapi/_populate_config.yaml b/tests/integration/targets/vyos_nat/tests/httpapi/_populate_config.yaml
new file mode 100644
index 0000000..fd543d5
--- /dev/null
+++ b/tests/integration/targets/vyos_nat/tests/httpapi/_populate_config.yaml
@@ -0,0 +1,39 @@
+---
+- name: Populate NAT config for testing
+ vyos.rest.vyos_nat:
+ config:
+ nat:
+ source:
+ rule:
+ - id: 100
+ description: "Source rule 100"
+ outbound_interface:
+ name: eth0
+ translation:
+ address: masquerade
+ - id: 101
+ outbound_interface:
+ name: eth1
+ translation:
+ address: masquerade
+ destination:
+ rule:
+ - id: 200
+ protocol: tcp
+ inbound_interface:
+ name: eth0
+ destination:
+ address: 198.51.100.10
+ port: "80"
+ translation:
+ address: 192.168.1.10
+ port: "8080"
+ static:
+ rule:
+ - id: 300
+ inbound_interface: eth0
+ destination:
+ address: 198.51.100.20
+ translation:
+ address: 192.168.1.20
+ state: merged
diff --git a/tests/integration/targets/vyos_nat/tests/httpapi/_remove_config.yaml b/tests/integration/targets/vyos_nat/tests/httpapi/_remove_config.yaml
new file mode 100644
index 0000000..8ee616b
--- /dev/null
+++ b/tests/integration/targets/vyos_nat/tests/httpapi/_remove_config.yaml
@@ -0,0 +1,5 @@
+---
+- name: Remove all NAT configuration
+ vyos.rest.vyos_nat:
+ state: deleted
+ ignore_errors: true
diff --git a/tests/integration/targets/vyos_nat/tests/httpapi/deleted.yaml b/tests/integration/targets/vyos_nat/tests/httpapi/deleted.yaml
new file mode 100644
index 0000000..2d0cff5
--- /dev/null
+++ b/tests/integration/targets/vyos_nat/tests/httpapi/deleted.yaml
@@ -0,0 +1,36 @@
+---
+- debug:
+ msg: START vyos_nat deleted integration tests on connection={{ ansible_connection }}
+
+- include_tasks: _remove_config.yaml
+- block:
+ - include_tasks: _populate_config.yaml
+
+ - name: Delete all NAT configuration
+ vyos.rest.vyos_nat: &id001
+ state: deleted
+ register: result
+
+ - assert:
+ that:
+ - result.changed == true
+
+ - name: Gather after delete
+ vyos.rest.vyos_nat:
+ state: gathered
+ register: gathered
+
+ - assert:
+ that:
+ - gathered.gathered == {}
+
+ - name: Delete again (IDEMPOTENT)
+ vyos.rest.vyos_nat: *id001
+ register: result
+
+ - assert:
+ that:
+ - result.changed == false
+
+ always:
+ - include_tasks: _remove_config.yaml
diff --git a/tests/integration/targets/vyos_nat/tests/httpapi/gathered.yaml b/tests/integration/targets/vyos_nat/tests/httpapi/gathered.yaml
new file mode 100644
index 0000000..1aca580
--- /dev/null
+++ b/tests/integration/targets/vyos_nat/tests/httpapi/gathered.yaml
@@ -0,0 +1,30 @@
+---
+- debug:
+ msg: START vyos_nat gathered integration tests on connection={{ ansible_connection }}
+
+- include_tasks: _remove_config.yaml
+- block:
+ - include_tasks: _populate_config.yaml
+
+ - name: Gather NAT configuration
+ vyos.rest.vyos_nat:
+ state: gathered
+ register: result
+
+ - assert:
+ that:
+ - result.changed == false
+ - "'nat' in result.gathered"
+ - "'source' in result.gathered.nat"
+ - "'destination' in result.gathered.nat"
+ - "'static' in result.gathered.nat"
+ - result.gathered.nat.source.rule | length == 2
+ - result.gathered.nat.source.rule[0].id == 100
+ - result.gathered.nat.source.rule[1].id == 101
+ - result.gathered.nat.destination.rule[0].id == 200
+ - result.gathered.nat.destination.rule[0].protocol == "tcp"
+ - result.gathered.nat.static.rule[0].id == 300
+ - result.gathered.nat.static.rule[0].inbound_interface == "eth0"
+
+ always:
+ - include_tasks: _remove_config.yaml
diff --git a/tests/integration/targets/vyos_nat/tests/httpapi/merged.yaml b/tests/integration/targets/vyos_nat/tests/httpapi/merged.yaml
new file mode 100644
index 0000000..c64fdb8
--- /dev/null
+++ b/tests/integration/targets/vyos_nat/tests/httpapi/merged.yaml
@@ -0,0 +1,49 @@
+---
+- debug:
+ msg: START vyos_nat merged integration tests on connection={{ ansible_connection }}
+
+- include_tasks: _remove_config.yaml
+
+- block:
+ - name: Merge NAT configuration
+ vyos.rest.vyos_nat: &id001
+ config:
+ nat:
+ source:
+ rule:
+ - id: 100
+ outbound_interface:
+ name: eth0
+ translation:
+ address: masquerade
+ destination:
+ rule:
+ - id: 200
+ protocol: tcp
+ inbound_interface:
+ name: eth0
+ destination:
+ address: 198.51.100.10
+ port: "80"
+ translation:
+ address: 192.168.1.10
+ port: "8080"
+ state: merged
+ register: result
+
+ - assert:
+ that:
+ - result.changed == true
+ - result.commands | length > 0
+
+ - name: Merge again (IDEMPOTENT)
+ vyos.rest.vyos_nat: *id001
+ register: result
+
+ - assert:
+ that:
+ - result.changed == false
+ - result.commands == []
+
+ always:
+ - include_tasks: _remove_config.yaml
diff --git a/tests/integration/targets/vyos_nat/tests/httpapi/overridden.yaml b/tests/integration/targets/vyos_nat/tests/httpapi/overridden.yaml
new file mode 100644
index 0000000..d753cc4
--- /dev/null
+++ b/tests/integration/targets/vyos_nat/tests/httpapi/overridden.yaml
@@ -0,0 +1,49 @@
+---
+- debug:
+ msg: START vyos_nat overridden integration tests on connection={{ ansible_connection }}
+
+- include_tasks: _remove_config.yaml
+- block:
+ - include_tasks: _populate_config.yaml
+
+ - name: Override - replaces all NAT with only one rule
+ vyos.rest.vyos_nat: &id001
+ config:
+ nat:
+ source:
+ rule:
+ - id: 999
+ outbound_interface:
+ name: eth0
+ translation:
+ address: masquerade
+ state: overridden
+ register: result
+
+ - assert:
+ that:
+ - result.changed == true
+
+ - name: Gather after override
+ vyos.rest.vyos_nat:
+ state: gathered
+ register: gathered
+
+ - assert:
+ that:
+ - gathered.gathered.nat.source.rule | length == 1
+ - gathered.gathered.nat.source.rule[0].id == 999
+ - "'destination' not in gathered.gathered.nat"
+ - "'static' not in gathered.gathered.nat"
+
+ - name: Override again (IDEMPOTENT)
+ vyos.rest.vyos_nat: *id001
+ register: result
+
+ - assert:
+ that:
+ - result.changed == false
+ - result.commands == []
+
+ always:
+ - include_tasks: _remove_config.yaml
diff --git a/tests/integration/targets/vyos_nat/tests/httpapi/replaced.yaml b/tests/integration/targets/vyos_nat/tests/httpapi/replaced.yaml
new file mode 100644
index 0000000..67a16f7
--- /dev/null
+++ b/tests/integration/targets/vyos_nat/tests/httpapi/replaced.yaml
@@ -0,0 +1,49 @@
+---
+- debug:
+ msg: START vyos_nat replaced integration tests on connection={{ ansible_connection }}
+
+- include_tasks: _remove_config.yaml
+- block:
+ - include_tasks: _populate_config.yaml
+
+ - name: Replace source NAT - removes rule 101, keeps destination and static
+ vyos.rest.vyos_nat: &id001
+ config:
+ nat:
+ source:
+ rule:
+ - id: 100
+ outbound_interface:
+ name: eth0
+ translation:
+ address: masquerade
+ state: replaced
+ register: result
+
+ - assert:
+ that:
+ - result.changed == true
+
+ - name: Gather after replace
+ vyos.rest.vyos_nat:
+ state: gathered
+ register: gathered
+
+ - assert:
+ that:
+ - gathered.gathered.nat.source.rule | length == 1
+ - gathered.gathered.nat.source.rule[0].id == 100
+ - "'destination' in gathered.gathered.nat"
+ - "'static' in gathered.gathered.nat"
+
+ - name: Replace again (IDEMPOTENT)
+ vyos.rest.vyos_nat: *id001
+ register: result
+
+ - assert:
+ that:
+ - result.changed == false
+ - result.commands == []
+
+ always:
+ - include_tasks: _remove_config.yaml
diff --git a/tests/integration/targets/vyos_nat/vars/main.yaml b/tests/integration/targets/vyos_nat/vars/main.yaml
new file mode 100644
index 0000000..4303881
--- /dev/null
+++ b/tests/integration/targets/vyos_nat/vars/main.yaml
@@ -0,0 +1,2 @@
+---
+# only common vars here
diff --git a/tests/integration/targets/vyos_ntp_global/tasks/httpapi.yaml b/tests/integration/targets/vyos_ntp_global/tasks/httpapi.yaml
index 4147e6d..0ed3e42 100644
--- a/tests/integration/targets/vyos_ntp_global/tasks/httpapi.yaml
+++ b/tests/integration/targets/vyos_ntp_global/tasks/httpapi.yaml
@@ -9,7 +9,7 @@
- name: Set test_items
ansible.builtin.set_fact:
- test_items: "{{ test_cases.files | map(attribute='path') | list }}"
+ test_items: "{{ test_cases.files | map(attribute='path') | list | sort }}"
- name: Run test case (connection=httpapi)
ansible.builtin.include_tasks: "{{ test_case_to_run }}"
diff --git a/tests/integration/targets/vyos_ntp_global/tests/httpapi/deleted.yaml b/tests/integration/targets/vyos_ntp_global/tests/httpapi/deleted.yaml
index 957e119..d831f18 100644
--- a/tests/integration/targets/vyos_ntp_global/tests/httpapi/deleted.yaml
+++ b/tests/integration/targets/vyos_ntp_global/tests/httpapi/deleted.yaml
@@ -3,9 +3,9 @@
msg: START vyos_ntp_global deleted integration tests on connection={{ ansible_connection }}
- include_tasks: _remove_config.yaml
-- include_tasks: _populate_config.yaml
-
- block:
+ - include_tasks: _populate_config.yaml
+
- name: Delete all NTP configuration
register: result
vyos.rest.vyos_ntp_global: &id001
@@ -20,7 +20,7 @@
that:
- result.after.allow_clients == []
- result.after.listen_addresses == []
- - result.after.servers == {}
+ - result.after.servers == []
- name: Delete the existing configuration (IDEMPOTENT)
register: result
diff --git a/tests/integration/targets/vyos_ntp_global/tests/httpapi/gathered.yaml b/tests/integration/targets/vyos_ntp_global/tests/httpapi/gathered.yaml
index 73ee1ff..61a7d3d 100644
--- a/tests/integration/targets/vyos_ntp_global/tests/httpapi/gathered.yaml
+++ b/tests/integration/targets/vyos_ntp_global/tests/httpapi/gathered.yaml
@@ -3,9 +3,9 @@
msg: START vyos_ntp_global gathered integration tests on connection={{ ansible_connection }}
- include_tasks: _remove_config.yaml
-- include_tasks: _populate_config.yaml
-
- block:
+ - include_tasks: _populate_config.yaml
+
- name: Gather NTP configuration
register: result
vyos.rest.vyos_ntp_global:
@@ -16,8 +16,8 @@
- result.changed == false
- "'10.4.9.0/24' in result.gathered.allow_clients"
- "'10.1.9.16' in result.gathered.listen_addresses"
- - "'10.3.6.5' in result.gathered.servers"
- - "'server4.example.com' in result.gathered.servers"
+ - "'10.3.6.5' in result.gathered.servers | map(attribute='server')"
+ - "'server4.example.com' in result.gathered.servers | map(attribute='server')"
always:
- include_tasks: _remove_config.yaml
diff --git a/tests/integration/targets/vyos_ntp_global/tests/httpapi/overridden.yaml b/tests/integration/targets/vyos_ntp_global/tests/httpapi/overridden.yaml
index deee2c0..a405b81 100644
--- a/tests/integration/targets/vyos_ntp_global/tests/httpapi/overridden.yaml
+++ b/tests/integration/targets/vyos_ntp_global/tests/httpapi/overridden.yaml
@@ -3,9 +3,9 @@
msg: START vyos_ntp_global overridden integration tests on connection={{ ansible_connection }}
- include_tasks: _remove_config.yaml
-- include_tasks: _populate_config.yaml
-
- block:
+ - include_tasks: _populate_config.yaml
+
- name: Override the existing configuration with the provided configuration
register: result
vyos.rest.vyos_ntp_global: &id001
diff --git a/tests/integration/targets/vyos_ntp_global/tests/httpapi/replaced.yaml b/tests/integration/targets/vyos_ntp_global/tests/httpapi/replaced.yaml
index 77136fc..cd21412 100644
--- a/tests/integration/targets/vyos_ntp_global/tests/httpapi/replaced.yaml
+++ b/tests/integration/targets/vyos_ntp_global/tests/httpapi/replaced.yaml
@@ -3,9 +3,9 @@
msg: START vyos_ntp_global replaced integration tests on connection={{ ansible_connection }}
- include_tasks: _remove_config.yaml
-- include_tasks: _populate_config.yaml
-
- block:
+ - include_tasks: _populate_config.yaml
+
- name: Replace the existing configuration with the provided configuration
register: result
vyos.rest.vyos_ntp_global: &id001
@@ -31,8 +31,8 @@
that:
- "'10.4.9.0/24' not in gathered.gathered.allow_clients"
- "'10.99.99.0/24' in gathered.gathered.allow_clients"
- - "'server-new.example.com' in gathered.gathered.servers"
- - "'10.3.6.5' not in gathered.gathered.servers"
+ - "'server-new.example.com' in gathered.gathered.servers | map(attribute='server')"
+ - "'10.3.6.5' not in gathered.gathered.servers | map(attribute='server')"
- name: Replace the provided configuration (IDEMPOTENT)
register: result
diff --git a/tests/integration/targets/vyos_ntp_global/tests/httpapi/rtt.yaml b/tests/integration/targets/vyos_ntp_global/tests/httpapi/rtt.yaml
index d05e203..41daa42 100644
--- a/tests/integration/targets/vyos_ntp_global/tests/httpapi/rtt.yaml
+++ b/tests/integration/targets/vyos_ntp_global/tests/httpapi/rtt.yaml
@@ -8,58 +8,56 @@
- name: RTT - Apply base configuration
vyos.rest.vyos_ntp_global:
config:
+ allow_clients:
+ - 10.5.5.0/24
+ - 10.6.6.0/24
+ listen_addresses:
+ - 10.1.2.3
servers:
- - server: 10.3.6.5
+ - server: 203.0.113.10
options:
- - noselect
- prefer
- - server: server4.example.com
- options:
- - pool
- allow_clients:
- - 10.4.9.0/24
- listen_addresses:
- - 10.1.9.16
state: merged
- - name: RTT - Gather configuration
+ - name: RTT - Gather
register: gathered
vyos.rest.vyos_ntp_global:
state: gathered
- - name: RTT - Assert gathered matches applied
+ - name: RTT - Assert base configuration present
assert:
that:
- - "'10.3.6.5' in gathered.gathered.servers"
- - "'10.4.9.0/24' in gathered.gathered.allow_clients"
- - "'10.1.9.16' in gathered.gathered.listen_addresses"
+ - "'10.5.5.0/24' in gathered.gathered.allow_clients"
+ - "'10.6.6.0/24' in gathered.gathered.allow_clients"
+ - "'10.1.2.3' in gathered.gathered.listen_addresses"
+ - "'203.0.113.10' in gathered.gathered.servers | map(attribute='server')"
+ - (gathered.gathered.servers | selectattr('server', 'eq', '203.0.113.10') | first).options == ['prefer']
- - name: RTT - Modify configuration
+ - name: RTT - Modify configuration (remove a stale allow_client, add a server option)
vyos.rest.vyos_ntp_global:
config:
- servers:
- - server: 10.3.6.5
- options:
- - noselect
- - server: server5.example.com
allow_clients:
- - 10.4.8.0/24
+ - 10.5.5.0/24
listen_addresses:
- - 10.1.9.16
+ - 10.1.2.3
+ servers:
+ - server: 203.0.113.10
+ options:
+ - prefer
+ - nts
state: replaced
- - name: RTT - Gather modified configuration
+ - name: RTT - Gather after modify
register: gathered2
vyos.rest.vyos_ntp_global:
state: gathered
- - name: RTT - Assert modification applied correctly
+ - name: RTT - Assert stale entry removed and update applied
assert:
that:
- - "'server5.example.com' in gathered2.gathered.servers"
- - "'server4.example.com' not in gathered2.gathered.servers"
- - "'10.4.8.0/24' in gathered2.gathered.allow_clients"
- - "'10.4.9.0/24' not in gathered2.gathered.allow_clients"
+ - "'10.5.5.0/24' in gathered2.gathered.allow_clients"
+ - "'10.6.6.0/24' not in gathered2.gathered.allow_clients"
+ - (gathered2.gathered.servers | selectattr('server', 'eq', '203.0.113.10') | first).options | sort == ['nts', 'prefer']
always:
- include_tasks: _remove_config.yaml
diff --git a/tests/integration/targets/vyos_ospf_interfaces/tasks/httpapi.yaml b/tests/integration/targets/vyos_ospf_interfaces/tasks/httpapi.yaml
index 4147e6d..0ed3e42 100644
--- a/tests/integration/targets/vyos_ospf_interfaces/tasks/httpapi.yaml
+++ b/tests/integration/targets/vyos_ospf_interfaces/tasks/httpapi.yaml
@@ -9,7 +9,7 @@
- name: Set test_items
ansible.builtin.set_fact:
- test_items: "{{ test_cases.files | map(attribute='path') | list }}"
+ test_items: "{{ test_cases.files | map(attribute='path') | list | sort }}"
- name: Run test case (connection=httpapi)
ansible.builtin.include_tasks: "{{ test_case_to_run }}"
diff --git a/tests/integration/targets/vyos_prefix_lists/tests/httpapi/deleted.yaml b/tests/integration/targets/vyos_prefix_lists/tests/httpapi/deleted.yaml
index 6cfb063..145cfaf 100644
--- a/tests/integration/targets/vyos_prefix_lists/tests/httpapi/deleted.yaml
+++ b/tests/integration/targets/vyos_prefix_lists/tests/httpapi/deleted.yaml
@@ -3,9 +3,9 @@
msg: START vyos_prefix_lists deleted integration tests on connection={{ ansible_connection }}
- include_tasks: _remove_config.yaml
-- include_tasks: _populate_config.yaml
-
- block:
+ - include_tasks: _populate_config.yaml
+
- name: Delete all prefix_lists configuration
register: result
vyos.rest.vyos_prefix_lists: &id001
diff --git a/tests/integration/targets/vyos_prefix_lists/tests/httpapi/gathered.yaml b/tests/integration/targets/vyos_prefix_lists/tests/httpapi/gathered.yaml
index 083cb48..ce752a8 100644
--- a/tests/integration/targets/vyos_prefix_lists/tests/httpapi/gathered.yaml
+++ b/tests/integration/targets/vyos_prefix_lists/tests/httpapi/gathered.yaml
@@ -3,9 +3,9 @@
msg: START vyos_prefix_lists gathered integration tests on connection={{ ansible_connection }}
- include_tasks: _remove_config.yaml
-- include_tasks: _populate_config.yaml
-
- block:
+ - include_tasks: _populate_config.yaml
+
- name: Gather prefix_lists configuration
register: result
vyos.rest.vyos_prefix_lists:
diff --git a/tests/integration/targets/vyos_prefix_lists/tests/httpapi/replaced.yaml b/tests/integration/targets/vyos_prefix_lists/tests/httpapi/replaced.yaml
index bd8c5c1..8c9754a 100644
--- a/tests/integration/targets/vyos_prefix_lists/tests/httpapi/replaced.yaml
+++ b/tests/integration/targets/vyos_prefix_lists/tests/httpapi/replaced.yaml
@@ -3,9 +3,9 @@
msg: START vyos_prefix_lists replaced integration tests on connection={{ ansible_connection }}
- include_tasks: _remove_config.yaml
-- include_tasks: _populate_config.yaml
-
- block:
+ - include_tasks: _populate_config.yaml
+
- name: Replace prefix_lists configuration
register: result
vyos.rest.vyos_prefix_lists: &id001
diff --git a/tests/integration/targets/vyos_route_maps/tasks/httpapi.yaml b/tests/integration/targets/vyos_route_maps/tasks/httpapi.yaml
index 4147e6d..0ed3e42 100644
--- a/tests/integration/targets/vyos_route_maps/tasks/httpapi.yaml
+++ b/tests/integration/targets/vyos_route_maps/tasks/httpapi.yaml
@@ -9,7 +9,7 @@
- name: Set test_items
ansible.builtin.set_fact:
- test_items: "{{ test_cases.files | map(attribute='path') | list }}"
+ test_items: "{{ test_cases.files | map(attribute='path') | list | sort }}"
- name: Run test case (connection=httpapi)
ansible.builtin.include_tasks: "{{ test_case_to_run }}"
diff --git a/tests/integration/targets/vyos_route_maps/tests/httpapi/deleted.yaml b/tests/integration/targets/vyos_route_maps/tests/httpapi/deleted.yaml
index ec80b23..f826d45 100644
--- a/tests/integration/targets/vyos_route_maps/tests/httpapi/deleted.yaml
+++ b/tests/integration/targets/vyos_route_maps/tests/httpapi/deleted.yaml
@@ -3,9 +3,9 @@
msg: START vyos_route_maps deleted integration tests on connection={{ ansible_connection }}
- include_tasks: _remove_config.yaml
-- include_tasks: _populate_config.yaml
-
- block:
+ - include_tasks: _populate_config.yaml
+
- name: Delete all route_maps configuration
register: result
vyos.rest.vyos_route_maps: &id001
diff --git a/tests/integration/targets/vyos_route_maps/tests/httpapi/gathered.yaml b/tests/integration/targets/vyos_route_maps/tests/httpapi/gathered.yaml
index 494a23e..569d9b3 100644
--- a/tests/integration/targets/vyos_route_maps/tests/httpapi/gathered.yaml
+++ b/tests/integration/targets/vyos_route_maps/tests/httpapi/gathered.yaml
@@ -3,9 +3,9 @@
msg: START vyos_route_maps gathered integration tests on connection={{ ansible_connection }}
- include_tasks: _remove_config.yaml
-- include_tasks: _populate_config.yaml
-
- block:
+ - include_tasks: _populate_config.yaml
+
- name: Gather route_maps configuration
register: result
vyos.rest.vyos_route_maps:
diff --git a/tests/integration/targets/vyos_route_maps/tests/httpapi/replaced.yaml b/tests/integration/targets/vyos_route_maps/tests/httpapi/replaced.yaml
index dece9a8..6e06ab7 100644
--- a/tests/integration/targets/vyos_route_maps/tests/httpapi/replaced.yaml
+++ b/tests/integration/targets/vyos_route_maps/tests/httpapi/replaced.yaml
@@ -3,9 +3,9 @@
msg: START vyos_route_maps replaced integration tests on connection={{ ansible_connection }}
- include_tasks: _remove_config.yaml
-- include_tasks: _populate_config.yaml
-
- block:
+ - include_tasks: _populate_config.yaml
+
- name: Replace route_maps configuration
register: result
vyos.rest.vyos_route_maps: &id001
diff --git a/tests/integration/targets/vyos_route_maps/tests/httpapi/rtt.yaml b/tests/integration/targets/vyos_route_maps/tests/httpapi/rtt.yaml
index 641a46f..1646775 100644
--- a/tests/integration/targets/vyos_route_maps/tests/httpapi/rtt.yaml
+++ b/tests/integration/targets/vyos_route_maps/tests/httpapi/rtt.yaml
@@ -14,40 +14,150 @@
action: permit
match:
peer: 192.0.2.32
+ metric: 100
+ protocol: bgp
set:
- metric: "5"
+ metric: 5
+ as_path_exclude: "111"
+ aggregator:
+ as: 100
+ community:
+ add:
+ - "no-export"
+ - route_map: my_route_map
+ entries:
+ - sequence: 10
+ action: permit
+ continue_sequence: 20
+ - sequence: 20
+ action: permit
+ set:
+ large_community:
+ none: true
+ - route_map: test3
+ entries:
+ - sequence: 1
+ action: permit
+ match:
+ metric: 1
+ peer: 192.0.2.32
+ set:
+ local_preference: 4
+ metric: 5
+ metric_type: type-1
+ origin: egp
+ originator_id: 192.0.2.34
+ tag: 5
+ weight: 4
state: merged
- - name: RTT - Gather configuration
+ - name: RTT - Gather
register: gathered
vyos.rest.vyos_route_maps:
state: gathered
- - name: RTT - Assert gathered matches applied
+ - name: RTT - Assert base configuration present
assert:
that:
- - gathered.gathered | selectattr('route_map', 'eq', 'RM-TEST-EXPORT-POLICY') | list | length == 1
+ - "'RM-TEST-EXPORT-POLICY' in gathered.gathered | map(attribute='route_map')"
+ - "'my_route_map' in gathered.gathered | map(attribute='route_map')"
+ - "'test3' in gathered.gathered | map(attribute='route_map')"
+ - (gathered.gathered | selectattr('route_map', 'eq', 'RM-TEST-EXPORT-POLICY') | first).entries[0].match.metric == 100
+ - (gathered.gathered | selectattr('route_map', 'eq', 'RM-TEST-EXPORT-POLICY') | first).entries[0].match.protocol == 'bgp'
+ - (gathered.gathered | selectattr('route_map', 'eq', 'RM-TEST-EXPORT-POLICY') | first).entries[0].set.aggregator.as_ == 100
+ - (gathered.gathered | selectattr('route_map', 'eq', 'RM-TEST-EXPORT-POLICY') | first).entries[0].set.community.add == ['no-export']
+ - (gathered.gathered | selectattr('route_map', 'eq', 'my_route_map') | first).entries[0].continue_sequence == 20
+ - (gathered.gathered | selectattr('route_map', 'eq', 'my_route_map') | first).entries[1].set.large_community.none == true
+ - (gathered.gathered | selectattr('route_map', 'eq', 'test3') | first).entries[0].set.local_preference == 4
+ - (gathered.gathered | selectattr('route_map', 'eq', 'test3') | first).entries[0].set.metric_type == 'type-1'
+ - (gathered.gathered | selectattr('route_map', 'eq', 'test3') | first).entries[0].set.origin == 'egp'
+ - (gathered.gathered | selectattr('route_map', 'eq', 'test3') | first).entries[0].set.originator_id == '192.0.2.34'
+ - (gathered.gathered | selectattr('route_map', 'eq', 'test3') | first).entries[0].set.tag == 5
+ - (gathered.gathered | selectattr('route_map', 'eq', 'test3') | first).entries[0].set.weight == 4
- - name: RTT - Modify configuration
+ - name: RTT - Modify configuration (replaced -- remove RM-TEST-EXPORT-POLICY's community, add a new route map)
vyos.rest.vyos_route_maps:
config:
- route_map: RM-TEST-EXPORT-POLICY
entries:
- sequence: 10
action: permit
+ match:
+ peer: 192.0.2.32
set:
- metric: "20"
+ metric: 5
+ as_path_exclude: "111"
+ aggregator:
+ as: 100
+ - route_map: my_route_map
+ entries:
+ - sequence: 10
+ action: permit
+ continue_sequence: 20
+ - sequence: 20
+ action: permit
+ set:
+ large_community:
+ none: true
state: replaced
- - name: RTT - Gather modified configuration
+ - name: RTT - Gather after modify
register: gathered2
vyos.rest.vyos_route_maps:
state: gathered
- - name: RTT - Assert modification applied correctly
+ - name: RTT - Assert stale community removed and match cleared, my_route_map untouched
+ assert:
+ that:
+ - "'community' not in (gathered2.gathered | selectattr('route_map', 'eq', 'RM-TEST-EXPORT-POLICY') | first).entries[0].set"
+ - "'metric' not in (gathered2.gathered | selectattr('route_map', 'eq', 'RM-TEST-EXPORT-POLICY') | first).entries[0].match"
+ - (gathered2.gathered | selectattr('route_map', 'eq', 'RM-TEST-EXPORT-POLICY') | first).entries[0].set.aggregator.as_ == 100
+ - (gathered2.gathered | selectattr('route_map', 'eq', 'my_route_map') | first).entries[0].continue_sequence == 20
+
+ - name: RTT - Re-establish the original baseline
+ vyos.rest.vyos_route_maps: &baseline_config
+ config: "{{ gathered.gathered }}"
+ state: overridden
+
+ - name: RTT - Gather the re-established baseline
+ register: rebaselined
+ vyos.rest.vyos_route_maps:
+ state: gathered
+
+ - name: RTT - Apply an unrelated temporary route map
+ vyos.rest.vyos_route_maps:
+ config:
+ - route_map: temp_policy
+ entries:
+ - sequence: 5
+ action: deny
+ state: merged
+
+ - name: RTT - Revert to the original baseline via gather + overridden
+ register: reverted
+ vyos.rest.vyos_route_maps: *baseline_config
+
+ - name: RTT - Gather after revert
+ register: gathered3
+ vyos.rest.vyos_route_maps:
+ state: gathered
+
+ - name: RTT - Assert the revert exactly restores the baseline and is then idempotent
+ assert:
+ that:
+ - reverted.changed == true
+ - "'temp_policy' not in gathered3.gathered | map(attribute='route_map')"
+ - gathered3.gathered | symmetric_difference(rebaselined.gathered) == []
+
+ - name: RTT - Confirm reverted baseline is idempotent
+ register: reverted_again
+ vyos.rest.vyos_route_maps: *baseline_config
+
+ - name: RTT - Assert idempotent
assert:
that:
- - gathered2.gathered | selectattr('route_map', 'eq', 'RM-TEST-EXPORT-POLICY') | list | length == 1
+ - reverted_again.changed == false
+ - reverted_again.commands == []
always:
- include_tasks: _remove_config.yaml
diff --git a/tests/integration/targets/vyos_snmp_server/tasks/httpapi.yaml b/tests/integration/targets/vyos_snmp_server/tasks/httpapi.yaml
index 1ba3569..80d9e23 100644
--- a/tests/integration/targets/vyos_snmp_server/tasks/httpapi.yaml
+++ b/tests/integration/targets/vyos_snmp_server/tasks/httpapi.yaml
@@ -9,7 +9,7 @@
- name: Set test_items
ansible.builtin.set_fact:
- test_items: "{{ test_cases.files | map(attribute='path') | list }}"
+ test_items: "{{ test_cases.files | map(attribute='path') | list | sort }}"
- name: Run test case (connection=httpapi)
ansible.builtin.include_tasks: "{{ test_case_to_run }}"
diff --git a/tests/integration/targets/vyos_snmp_server/tests/httpapi/deleted.yaml b/tests/integration/targets/vyos_snmp_server/tests/httpapi/deleted.yaml
index a626a26..9c1c288 100644
--- a/tests/integration/targets/vyos_snmp_server/tests/httpapi/deleted.yaml
+++ b/tests/integration/targets/vyos_snmp_server/tests/httpapi/deleted.yaml
@@ -2,9 +2,9 @@
- debug:
msg: START vyos_snmp_server deleted integration tests on connection={{ ansible_connection }}
-- include_tasks: _populate_config.yaml
-
- block:
+ - include_tasks: _populate_config.yaml
+
- name: Delete all snmp_server configuration
register: result
vyos.rest.vyos_snmp_server: &id001
diff --git a/tests/integration/targets/vyos_snmp_server/tests/httpapi/gathered.yaml b/tests/integration/targets/vyos_snmp_server/tests/httpapi/gathered.yaml
index 770845c..5956f51 100644
--- a/tests/integration/targets/vyos_snmp_server/tests/httpapi/gathered.yaml
+++ b/tests/integration/targets/vyos_snmp_server/tests/httpapi/gathered.yaml
@@ -2,9 +2,9 @@
- debug:
msg: START vyos_snmp_server gathered integration tests on connection={{ ansible_connection }}
-- include_tasks: _populate_config.yaml
-
- block:
+ - include_tasks: _populate_config.yaml
+
- name: Gather snmp_server configuration
register: result
vyos.rest.vyos_snmp_server:
diff --git a/tests/integration/targets/vyos_snmp_server/tests/httpapi/replaced.yaml b/tests/integration/targets/vyos_snmp_server/tests/httpapi/replaced.yaml
index d989272..e659731 100644
--- a/tests/integration/targets/vyos_snmp_server/tests/httpapi/replaced.yaml
+++ b/tests/integration/targets/vyos_snmp_server/tests/httpapi/replaced.yaml
@@ -2,9 +2,9 @@
- debug:
msg: START vyos_snmp_server replaced integration tests on connection={{ ansible_connection }}
-- include_tasks: _populate_config.yaml
-
- block:
+ - include_tasks: _populate_config.yaml
+
- name: Replace snmp_server configuration
register: result
vyos.rest.vyos_snmp_server: &id001
diff --git a/tests/integration/targets/vyos_snmp_server/tests/httpapi/rtt.yaml b/tests/integration/targets/vyos_snmp_server/tests/httpapi/rtt.yaml
index 1d354b7..00bc93e 100644
--- a/tests/integration/targets/vyos_snmp_server/tests/httpapi/rtt.yaml
+++ b/tests/integration/targets/vyos_snmp_server/tests/httpapi/rtt.yaml
@@ -8,46 +8,101 @@
- name: RTT - Apply base configuration
vyos.rest.vyos_snmp_server:
config:
+ contact: admin@example.com
communities:
- name: switches
authorization_type: rw
- contact: admin@example.com
+ - name: bridges
+ clients:
+ - 1.1.1.1
+ - 12.1.1.10
listen_addresses:
- address: 20.1.1.1
+ snmp_v3:
+ engine_id: "000000000000000000000002"
+ groups:
+ - group: admins
+ mode: rw
+ seclevel: priv
+ view: all
+ users:
+ - user: admin_user
+ group: admins
+ authentication:
+ type: sha
+ plaintext_key: authpass123
+ privacy:
+ type: aes
+ plaintext_key: privpass123
+ views:
+ - view: all
+ oid: "1.3.6.1.2.1.1"
+ mask: "ff.ff"
state: merged
- - name: RTT - Gather configuration
+ - name: RTT - Gather
register: gathered
vyos.rest.vyos_snmp_server:
state: gathered
- - name: RTT - Assert gathered matches applied
+ - name: RTT - Assert base configuration present
assert:
that:
- gathered.gathered.contact == "admin@example.com"
- - gathered.gathered.communities | selectattr('name', 'eq', 'switches') | list | length == 1
+ - "'switches' in gathered.gathered.communities | map(attribute='name')"
+ - "'bridges' in gathered.gathered.communities | map(attribute='name')"
+ - gathered.gathered.snmp_v3.engine_id == "000000000000000000000002"
+ - "'admins' in gathered.gathered.snmp_v3.groups | map(attribute='group')"
+ - (gathered.gathered.snmp_v3.users | selectattr('user', 'eq', 'admin_user') | first).authentication.type == 'sha'
+ - (gathered.gathered.snmp_v3.views | selectattr('view', 'eq', 'all') | first).oid == '1.3.6.1.2.1.1'
+ - (gathered.gathered.snmp_v3.views | selectattr('view', 'eq', 'all') | first).mask == 'ff.ff'
- - name: RTT - Modify configuration
+ - name: RTT - Modify configuration (remove a stale community, add a trap target)
vyos.rest.vyos_snmp_server:
config:
+ contact: admin@example.com
communities:
- name: switches
- authorization_type: ro
- contact: ops@example.com
+ authorization_type: rw
listen_addresses:
- address: 20.1.1.1
+ trap_target:
+ address: 203.0.113.5
+ community: switches
+ port: 162
+ snmp_v3:
+ engine_id: "000000000000000000000002"
+ groups:
+ - group: admins
+ mode: rw
+ seclevel: priv
+ view: all
+ users:
+ - user: admin_user
+ group: admins
+ authentication:
+ type: sha
+ privacy:
+ type: aes
+ views:
+ - view: all
+ oid: "1.3.6.1.2.1.1"
+ mask: "ff.ff"
state: replaced
- - name: RTT - Gather modified configuration
+ - name: RTT - Gather after modify
register: gathered2
vyos.rest.vyos_snmp_server:
state: gathered
- - name: RTT - Assert modification applied correctly
+ - name: RTT - Assert stale community removed and trap target added
assert:
that:
- - gathered2.gathered.contact == "ops@example.com"
- - gathered2.gathered.communities | selectattr('name', 'eq', 'switches') | map(attribute='authorization_type') | first == 'ro'
+ - "'bridges' not in gathered2.gathered.communities | map(attribute='name')"
+ - "'switches' in gathered2.gathered.communities | map(attribute='name')"
+ - gathered2.gathered.trap_target.address == "203.0.113.5"
+ - gathered2.gathered.trap_target.community == "switches"
+ - gathered2.gathered.trap_target.port == 162
always:
- include_tasks: _remove_config.yaml
diff --git a/tests/integration/targets/vyos_system/aliases b/tests/integration/targets/vyos_system/aliases
new file mode 100644
index 0000000..cc0afef
--- /dev/null
+++ b/tests/integration/targets/vyos_system/aliases
@@ -0,0 +1 @@
+network/vyos
diff --git a/tests/integration/targets/vyos_system/defaults/main.yaml b/tests/integration/targets/vyos_system/defaults/main.yaml
new file mode 100644
index 0000000..164afea
--- /dev/null
+++ b/tests/integration/targets/vyos_system/defaults/main.yaml
@@ -0,0 +1,3 @@
+---
+testcase: "[^_].*"
+test_items: []
diff --git a/tests/integration/targets/vyos_system/tasks/httpapi.yaml b/tests/integration/targets/vyos_system/tasks/httpapi.yaml
new file mode 100644
index 0000000..0ed3e42
--- /dev/null
+++ b/tests/integration/targets/vyos_system/tasks/httpapi.yaml
@@ -0,0 +1,21 @@
+---
+- name: Collect all httpapi test cases
+ ansible.builtin.find:
+ paths: "{{ role_path }}/tests/httpapi"
+ patterns: "{{ testcase }}.yaml"
+ use_regex: true
+ register: test_cases
+ delegate_to: localhost
+
+- name: Set test_items
+ ansible.builtin.set_fact:
+ test_items: "{{ test_cases.files | map(attribute='path') | list | sort }}"
+
+- name: Run test case (connection=httpapi)
+ ansible.builtin.include_tasks: "{{ test_case_to_run }}"
+ vars:
+ ansible_connection: ansible.netcommon.httpapi
+ ansible_network_os: vyos.rest.vyos
+ with_items: "{{ test_items }}"
+ loop_control:
+ loop_var: test_case_to_run
diff --git a/tests/integration/targets/vyos_system/tasks/main.yaml b/tests/integration/targets/vyos_system/tasks/main.yaml
new file mode 100644
index 0000000..b1f6193
--- /dev/null
+++ b/tests/integration/targets/vyos_system/tasks/main.yaml
@@ -0,0 +1,5 @@
+---
+- name: Run httpapi tests
+ ansible.builtin.include_tasks: httpapi.yaml
+ tags:
+ - httpapi
diff --git a/tests/integration/targets/vyos_system/tests/httpapi/_remove_config.yaml b/tests/integration/targets/vyos_system/tests/httpapi/_remove_config.yaml
new file mode 100644
index 0000000..f8a4750
--- /dev/null
+++ b/tests/integration/targets/vyos_system/tests/httpapi/_remove_config.yaml
@@ -0,0 +1,12 @@
+---
+- name: Remove test system configuration
+ vyos.rest.vyos_system:
+ domain_name: lab.example.com
+ name_server:
+ - 8.8.8.8
+ - 8.8.4.4
+ - 1.1.1.1
+ domain_search:
+ - sub1.example.com
+ state: absent
+ ignore_errors: true
diff --git a/tests/integration/targets/vyos_system/tests/httpapi/absent.yaml b/tests/integration/targets/vyos_system/tests/httpapi/absent.yaml
new file mode 100644
index 0000000..f82e4d3
--- /dev/null
+++ b/tests/integration/targets/vyos_system/tests/httpapi/absent.yaml
@@ -0,0 +1,48 @@
+---
+- debug:
+ msg: START vyos_system absent integration tests on connection={{ ansible_connection }}
+
+- include_tasks: _remove_config.yaml
+
+- block:
+ - name: Setup system configuration
+ vyos.rest.vyos_system:
+ domain_name: lab.example.com
+ name_server:
+ - 8.8.8.8
+ state: present
+
+ - name: Remove domain name
+ register: result
+ vyos.rest.vyos_system:
+ domain_name: lab.example.com
+ state: absent
+
+ - assert:
+ that:
+ - result.changed == true
+
+ - name: Remove domain name (IDEMPOTENT)
+ register: result
+ vyos.rest.vyos_system:
+ domain_name: lab.example.com
+ state: absent
+
+ - assert:
+ that:
+ - result.changed == false
+ - result.commands == []
+
+ - name: Remove name server
+ register: result
+ vyos.rest.vyos_system:
+ name_server:
+ - 8.8.8.8
+ state: absent
+
+ - assert:
+ that:
+ - result.changed == true
+
+ always:
+ - include_tasks: _remove_config.yaml
diff --git a/tests/integration/targets/vyos_system/tests/httpapi/gathered.yaml b/tests/integration/targets/vyos_system/tests/httpapi/gathered.yaml
new file mode 100644
index 0000000..cf5f46d
--- /dev/null
+++ b/tests/integration/targets/vyos_system/tests/httpapi/gathered.yaml
@@ -0,0 +1,14 @@
+---
+- debug:
+ msg: START vyos_system gathered integration tests on connection={{ ansible_connection }}
+
+- block:
+ - name: Gather system configuration
+ register: result
+ vyos.rest.vyos_system:
+ state: present
+
+ - assert:
+ that:
+ - result.before['host-name'] is defined
+ - result.before['host-name'] | length > 0
diff --git a/tests/integration/targets/vyos_system/tests/httpapi/present.yaml b/tests/integration/targets/vyos_system/tests/httpapi/present.yaml
new file mode 100644
index 0000000..f830e60
--- /dev/null
+++ b/tests/integration/targets/vyos_system/tests/httpapi/present.yaml
@@ -0,0 +1,63 @@
+---
+- debug:
+ msg: START vyos_system present integration tests on connection={{ ansible_connection }}
+
+- include_tasks: _remove_config.yaml
+
+- block:
+ - name: Configure domain and name servers
+ register: result
+ vyos.rest.vyos_system:
+ domain_name: lab.example.com
+ name_server:
+ - 8.8.8.8
+ - 8.8.4.4
+ state: present
+
+ - assert:
+ that:
+ - result.changed == true
+
+ - name: Configure domain and name servers (IDEMPOTENT)
+ register: result
+ vyos.rest.vyos_system:
+ domain_name: lab.example.com
+ name_server:
+ - 8.8.8.8
+ - 8.8.4.4
+ state: present
+
+ - assert:
+ that:
+ - result.changed == false
+ - result.commands == []
+
+ - name: Update domain name
+ register: result
+ vyos.rest.vyos_system:
+ domain_name: new.example.com
+ state: present
+
+ - assert:
+ that:
+ - result.changed == true
+
+ - name: Update domain name (IDEMPOTENT)
+ register: result
+ vyos.rest.vyos_system:
+ domain_name: new.example.com
+ state: present
+
+ - assert:
+ that:
+ - result.changed == false
+ - result.commands == []
+
+ always:
+ - include_tasks: _remove_config.yaml
+
+ - name: Remove new domain name
+ vyos.rest.vyos_system:
+ domain_name: new.example.com
+ state: absent
+ ignore_errors: true
diff --git a/tests/integration/targets/vyos_system/vars/main.yaml b/tests/integration/targets/vyos_system/vars/main.yaml
new file mode 100644
index 0000000..4303881
--- /dev/null
+++ b/tests/integration/targets/vyos_system/vars/main.yaml
@@ -0,0 +1,2 @@
+---
+# only common vars here
diff --git a/tests/integration/targets/vyos_user/tasks/httpapi.yaml b/tests/integration/targets/vyos_user/tasks/httpapi.yaml
index 4147e6d..0ed3e42 100644
--- a/tests/integration/targets/vyos_user/tasks/httpapi.yaml
+++ b/tests/integration/targets/vyos_user/tasks/httpapi.yaml
@@ -9,7 +9,7 @@
- name: Set test_items
ansible.builtin.set_fact:
- test_items: "{{ test_cases.files | map(attribute='path') | list }}"
+ test_items: "{{ test_cases.files | map(attribute='path') | list | sort }}"
- name: Run test case (connection=httpapi)
ansible.builtin.include_tasks: "{{ test_case_to_run }}"
diff --git a/tests/integration/targets/vyos_vlan/aliases b/tests/integration/targets/vyos_vlan/aliases
new file mode 100644
index 0000000..cc0afef
--- /dev/null
+++ b/tests/integration/targets/vyos_vlan/aliases
@@ -0,0 +1 @@
+network/vyos
diff --git a/tests/integration/targets/vyos_vlan/defaults/main.yaml b/tests/integration/targets/vyos_vlan/defaults/main.yaml
new file mode 100644
index 0000000..164afea
--- /dev/null
+++ b/tests/integration/targets/vyos_vlan/defaults/main.yaml
@@ -0,0 +1,3 @@
+---
+testcase: "[^_].*"
+test_items: []
diff --git a/tests/integration/targets/vyos_vlan/tasks/httpapi.yaml b/tests/integration/targets/vyos_vlan/tasks/httpapi.yaml
new file mode 100644
index 0000000..217692f
--- /dev/null
+++ b/tests/integration/targets/vyos_vlan/tasks/httpapi.yaml
@@ -0,0 +1,21 @@
+---
+- name: Collect all httpapi test cases
+ ansible.builtin.find:
+ paths: "{{ role_path }}/tests/httpapi"
+ patterns: "{{ testcase }}.yaml"
+ use_regex: true
+ register: test_cases
+ delegate_to: localhost
+
+- name: Set test_items
+ ansible.builtin.set_fact:
+ test_items: "{{ test_cases.files | map(attribute='path') | list | sort}}"
+
+- name: Run test case (connection=httpapi)
+ ansible.builtin.include_tasks: "{{ test_case_to_run }}"
+ vars:
+ ansible_connection: ansible.netcommon.httpapi
+ ansible_network_os: vyos.rest.vyos
+ with_items: "{{ test_items }}"
+ loop_control:
+ loop_var: test_case_to_run
diff --git a/tests/integration/targets/vyos_vlan/tasks/main.yaml b/tests/integration/targets/vyos_vlan/tasks/main.yaml
new file mode 100644
index 0000000..b1f6193
--- /dev/null
+++ b/tests/integration/targets/vyos_vlan/tasks/main.yaml
@@ -0,0 +1,5 @@
+---
+- name: Run httpapi tests
+ ansible.builtin.include_tasks: httpapi.yaml
+ tags:
+ - httpapi
diff --git a/tests/integration/targets/vyos_vlan/tests/httpapi/_remove_config.yaml b/tests/integration/targets/vyos_vlan/tests/httpapi/_remove_config.yaml
new file mode 100644
index 0000000..a0959b8
--- /dev/null
+++ b/tests/integration/targets/vyos_vlan/tests/httpapi/_remove_config.yaml
@@ -0,0 +1,9 @@
+---
+- name: Remove test VLANs
+ vyos.rest.vyos_vlan:
+ config:
+ - vlan_id: 10
+ interfaces: [eth1]
+ - vlan_id: 20
+ interfaces: [eth1]
+ state: absent
diff --git a/tests/integration/targets/vyos_vlan/tests/httpapi/absent.yaml b/tests/integration/targets/vyos_vlan/tests/httpapi/absent.yaml
new file mode 100644
index 0000000..3547ed1
--- /dev/null
+++ b/tests/integration/targets/vyos_vlan/tests/httpapi/absent.yaml
@@ -0,0 +1,45 @@
+---
+- debug:
+ msg: START vyos_vlan absent integration tests on connection={{ ansible_connection }}
+
+- include_tasks: _remove_config.yaml
+
+- block:
+ - name: Setup VLANs
+ vyos.rest.vyos_vlan:
+ config:
+ - vlan_id: 10
+ description: VLAN10
+ interfaces: [eth1]
+ - vlan_id: 20
+ description: VLAN20
+ interfaces: [eth1]
+ state: present
+
+ - name: Remove VLAN
+ register: result
+ vyos.rest.vyos_vlan:
+ config:
+ - vlan_id: 10
+ interfaces: [eth1]
+ state: absent
+
+ - assert:
+ that:
+ - result.changed == true
+
+ - name: Remove VLAN (IDEMPOTENT)
+ register: result
+ vyos.rest.vyos_vlan:
+ config:
+ - vlan_id: 10
+ interfaces: [eth1]
+ state: absent
+
+ - assert:
+ that:
+ - result.changed == false
+ - result.commands == []
+
+ always:
+ - include_tasks: _remove_config.yaml
diff --git a/tests/integration/targets/vyos_vlan/tests/httpapi/gathered.yaml b/tests/integration/targets/vyos_vlan/tests/httpapi/gathered.yaml
new file mode 100644
index 0000000..483f7ad
--- /dev/null
+++ b/tests/integration/targets/vyos_vlan/tests/httpapi/gathered.yaml
@@ -0,0 +1,28 @@
+---
+- debug:
+ msg: START vyos_vlan gathered integration tests on connection={{ ansible_connection }}
+
+- include_tasks: _remove_config.yaml
+
+- block:
+ - name: Setup VLANs
+ vyos.rest.vyos_vlan:
+ config:
+ - vlan_id: 10
+ description: VLAN10
+ address: 192.168.10.1/24
+ interfaces: [eth1]
+ state: present
+
+ - name: Gather VLANs
+ register: result
+ vyos.rest.vyos_vlan:
+ state: gathered
+
+ - assert:
+ that:
+ - result.gathered | selectattr('vlan_id', 'eq', 10) | list | length == 1
+ - (result.gathered | selectattr('vlan_id', 'eq', 10) | first).description == 'VLAN10'
+
+ always:
+ - include_tasks: _remove_config.yaml
diff --git a/tests/integration/targets/vyos_vlan/tests/httpapi/present.yaml b/tests/integration/targets/vyos_vlan/tests/httpapi/present.yaml
new file mode 100644
index 0000000..cfbec27
--- /dev/null
+++ b/tests/integration/targets/vyos_vlan/tests/httpapi/present.yaml
@@ -0,0 +1,44 @@
+---
+- debug:
+ msg: START vyos_vlan present integration tests on connection={{ ansible_connection }}
+
+- include_tasks: _remove_config.yaml
+
+- block:
+ - name: Configure VLANs
+ register: result
+ vyos.rest.vyos_vlan:
+ config:
+ - vlan_id: 10
+ description: VLAN10
+ address: 192.168.10.1/24
+ interfaces: [eth1]
+ - vlan_id: 20
+ description: VLAN20
+ interfaces: [eth1]
+ state: present
+
+ - assert:
+ that:
+ - result.changed == true
+
+ - name: Configure VLANs (IDEMPOTENT)
+ register: result
+ vyos.rest.vyos_vlan:
+ config:
+ - vlan_id: 10
+ description: VLAN10
+ address: 192.168.10.1/24
+ interfaces: [eth1]
+ - vlan_id: 20
+ description: VLAN20
+ interfaces: [eth1]
+ state: present
+
+ - assert:
+ that:
+ - result.changed == false
+ - result.commands == []
+
+ always:
+ - include_tasks: _remove_config.yaml
diff --git a/tests/integration/targets/vyos_vlan/tests/httpapi/rtt.yaml b/tests/integration/targets/vyos_vlan/tests/httpapi/rtt.yaml
new file mode 100644
index 0000000..e30032f
--- /dev/null
+++ b/tests/integration/targets/vyos_vlan/tests/httpapi/rtt.yaml
@@ -0,0 +1,43 @@
+---
+- debug:
+ msg: START vyos_vlan round trip integration tests on connection={{ ansible_connection }}
+
+- include_tasks: _remove_config.yaml
+
+- block:
+ - name: RTT - Configure VLAN
+ vyos.rest.vyos_vlan:
+ config:
+ - vlan_id: 10
+ description: VLAN10
+ interfaces: [eth1]
+ state: present
+
+ - name: RTT - Gather
+ register: gathered
+ vyos.rest.vyos_vlan:
+ state: gathered
+
+ - assert:
+ that:
+ - gathered.gathered | selectattr('vlan_id', 'eq', 10) | list | length == 1
+
+ - name: RTT - Update description
+ vyos.rest.vyos_vlan:
+ config:
+ - vlan_id: 10
+ description: VLAN10-updated
+ interfaces: [eth1]
+ state: present
+
+ - name: RTT - Gather after update
+ register: gathered2
+ vyos.rest.vyos_vlan:
+ state: gathered
+
+ - assert:
+ that:
+ - (gathered2.gathered | selectattr('vlan_id', 'eq', 10) | first).description == 'VLAN10-updated'
+
+ always:
+ - include_tasks: _remove_config.yaml
diff --git a/tests/integration/targets/vyos_vlan/vars/main.yaml b/tests/integration/targets/vyos_vlan/vars/main.yaml
new file mode 100644
index 0000000..4303881
--- /dev/null
+++ b/tests/integration/targets/vyos_vlan/vars/main.yaml
@@ -0,0 +1,2 @@
+---
+# only common vars here
diff --git a/tests/unit/fixtures/bgp_af_running.json b/tests/unit/fixtures/bgp_af_running.json
index f9c36d9..8e14e52 100644
--- a/tests/unit/fixtures/bgp_af_running.json
+++ b/tests/unit/fixtures/bgp_af_running.json
@@ -4,7 +4,7 @@
"ipv4-unicast": {
"network": {
"192.0.2.0/24": {},
- "192.0.3.0/24": {}
+ "192.0.3.0/24": { "route-map": "RM-OUT", "backdoor": {} }
},
"redistribute": {
"connected": { "metric": "10" },
@@ -18,7 +18,20 @@
"address-family": {
"ipv4-unicast": {
"nexthop-self": {},
- "soft-reconfiguration": { "inbound": {} }
+ "soft-reconfiguration": { "inbound": {} },
+ "attribute-unchanged": {
+ "as-path": {},
+ "med": {}
+ },
+ "capability": {
+ "orf": {
+ "prefix-list": { "receive": {} }
+ }
+ },
+ "distribute-list": {
+ "import": "10",
+ "export": "20"
+ }
},
"ipv6-unicast": {
"soft-reconfiguration": { "inbound": {} }
diff --git a/tests/unit/fixtures/bgp_global_running.json b/tests/unit/fixtures/bgp_global_running.json
index dc76159..65c3f58 100644
--- a/tests/unit/fixtures/bgp_global_running.json
+++ b/tests/unit/fixtures/bgp_global_running.json
@@ -2,7 +2,14 @@
"system-as": "65000",
"parameters": {
"router-id": "192.0.1.1",
- "log-neighbor-changes": {}
+ "log-neighbor-changes": {},
+ "bestpath": {
+ "as-path": "multipath-relax"
+ },
+ "confederation": {
+ "identifier": "100",
+ "peers": ["65001", "65002"]
+ }
},
"neighbor": {
"192.0.2.1": {
@@ -11,6 +18,11 @@
"timers": {
"holdtime": "30",
"keepalive": "10"
+ },
+ "address-family": {
+ "ipv4-unicast": {
+ "nexthop-self": {}
+ }
}
},
"192.0.2.2": {
diff --git a/tests/unit/fixtures/firewall_interfaces_running.json b/tests/unit/fixtures/firewall_interfaces_running.json
index b0ce2d5..af120ce 100644
--- a/tests/unit/fixtures/firewall_interfaces_running.json
+++ b/tests/unit/fixtures/firewall_interfaces_running.json
@@ -5,7 +5,12 @@
"default-action": "accept",
"rule": {
"10": { "action": "accept", "state": "established" },
- "20": { "action": "drop", "state": "invalid" }
+ "20": {
+ "action": "drop",
+ "state": "invalid",
+ "source": { "address": "10.0.0.0/8" },
+ "destination": { "port": "22" }
+ }
}
}
},
@@ -18,6 +23,13 @@
"filter": {
"default-action": "accept"
}
+ },
+ "name": {
+ "my-ruleset": {
+ "rule": {
+ "1": { "action": "accept" }
+ }
+ }
}
},
"ipv6": {
diff --git a/tests/unit/fixtures/ha_running.json b/tests/unit/fixtures/ha_running.json
new file mode 100644
index 0000000..ee7a257
--- /dev/null
+++ b/tests/unit/fixtures/ha_running.json
@@ -0,0 +1,44 @@
+{
+ "disable": {},
+ "virtual-server": {
+ "s1": {
+ "address": "10.10.10.5",
+ "algorithm": "round-robin",
+ "port": "80",
+ "protocol": "tcp",
+ "real-server": {
+ "10.10.50.2": { "port": "8443" }
+ }
+ }
+ },
+ "vrrp": {
+ "global-parameters": {
+ "startup-delay": "30",
+ "garp": { "master-repeat": "6" }
+ },
+ "snmp": {},
+ "group": {
+ "g1": {
+ "interface": "eth0",
+ "vrid": "20",
+ "priority": "100",
+ "peer-address": "192.168.1.3",
+ "address": { "192.168.1.100/24": {} },
+ "no-preempt": {},
+ "track": { "interface": ["eth1", "eth2"] }
+ },
+ "g2": {
+ "interface": "eth1",
+ "vrid": "21",
+ "priority": "50",
+ "address": "192.168.2.100/24"
+ }
+ },
+ "sync-group": {
+ "sg1": {
+ "member": "g1",
+ "health-check": { "failure-count": "5", "ping": "192.168.1.1" }
+ }
+ }
+ }
+}
diff --git a/tests/unit/fixtures/nat_running.json b/tests/unit/fixtures/nat_running.json
new file mode 100644
index 0000000..abaede0
--- /dev/null
+++ b/tests/unit/fixtures/nat_running.json
@@ -0,0 +1,80 @@
+{
+ "nat": {
+ "source": {
+ "rule": {
+ "100": {
+ "description": "Source rule 100",
+ "outbound-interface": { "name": "eth0" },
+ "translation": { "address": "masquerade" },
+ "load-balance": {
+ "hash": "random",
+ "backend": {
+ "192.168.1.10": { "weight": "50" },
+ "192.168.1.11": {}
+ }
+ }
+ },
+ "101": {
+ "outbound-interface": { "name": "eth1" },
+ "translation": { "address": "masquerade" }
+ }
+ }
+ },
+ "destination": {
+ "rule": {
+ "200": {
+ "protocol": "tcp",
+ "inbound-interface": { "name": "eth0" },
+ "destination": { "address": "198.51.100.10", "port": "80" },
+ "translation": { "address": "192.168.1.10", "port": "8080" }
+ }
+ }
+ },
+ "static": {
+ "rule": {
+ "300": {
+ "inbound-interface": "eth0",
+ "destination": { "address": "198.51.100.20" },
+ "translation": { "address": "192.168.1.20" }
+ }
+ }
+ },
+ "cgnat": {
+ "log-allocation": {},
+ "pool": {
+ "external": {
+ "EXT1": {
+ "external-port-range": "1024-65535",
+ "range": {
+ "203.0.113.1-203.0.113.10": { "seq": "1" }
+ }
+ }
+ },
+ "internal": {
+ "INT1": {
+ "range": ["10.0.0.0/24", "10.0.1.0/24"]
+ }
+ }
+ },
+ "rule": {
+ "1": {
+ "destination": { "group": { "address-group": "CGNAT-DST" } }
+ }
+ }
+ }
+ },
+ "nat64": {
+ "source": {
+ "rule": {
+ "10": {
+ "source": { "prefix": "2001:db8::/96" },
+ "translation": {
+ "pool": {
+ "1": { "address": "192.168.100.10", "port": "1-65535" }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/tests/unit/fixtures/route_maps_running.json b/tests/unit/fixtures/route_maps_running.json
index acf733b..0ae4939 100644
--- a/tests/unit/fixtures/route_maps_running.json
+++ b/tests/unit/fixtures/route_maps_running.json
@@ -4,11 +4,18 @@
"rule": {
"10": {
"action": "permit",
- "match": { "peer": "192.0.2.32" },
+ "match": {
+ "peer": "192.0.2.32",
+ "ip": {
+ "address": { "prefix-list": "PL-MATCH" },
+ "nexthop": { "address": "10.0.0.1" }
+ }
+ },
"set": {
"aggregator": { "as": "100" },
"as-path": { "exclude": "111" },
- "metric": "5"
+ "metric": "5",
+ "community": { "add": ["no-export", "no-advertise"] }
}
}
}
diff --git a/tests/unit/fixtures/snmp_server_running.json b/tests/unit/fixtures/snmp_server_running.json
index 7acc59f..47d1067 100644
--- a/tests/unit/fixtures/snmp_server_running.json
+++ b/tests/unit/fixtures/snmp_server_running.json
@@ -10,6 +10,9 @@
"20.1.1.1": {},
"100.1.2.1": { "port": "33" }
},
+ "trap-target": {
+ "203.0.113.5": { "community": "public", "port": "162" }
+ },
"v3": {
"engineid": "000000000000000000000002",
"group": {
@@ -30,6 +33,14 @@
},
"view": {
"default": { "oid": { "1": {} } }
+ },
+ "trap-target": {
+ "198.51.100.5": {
+ "port": "162",
+ "protocol": "udp",
+ "type": "inform",
+ "auth": { "type": "sha", "encrypted-password": "def456" }
+ }
}
}
}
diff --git a/tests/unit/fixtures/system_running.json b/tests/unit/fixtures/system_running.json
new file mode 100644
index 0000000..1193645
--- /dev/null
+++ b/tests/unit/fixtures/system_running.json
@@ -0,0 +1,11 @@
+{
+ "config-management": { "commit-revisions": "100" },
+ "console": { "device": { "ttyS0": { "speed": "115200" } } },
+ "host-name": "vyos150",
+ "domain-name": "lab.example.com",
+ "login": {
+ "user": { "vyos": { "authentication": { "encrypted-password": "$6$abc" } } }
+ },
+ "name-server": ["8.8.8.8", "8.8.4.4"],
+ "syslog": { "local": {} }
+}
diff --git a/tests/unit/fixtures/vlan_running.json b/tests/unit/fixtures/vlan_running.json
new file mode 100644
index 0000000..80edcb1
--- /dev/null
+++ b/tests/unit/fixtures/vlan_running.json
@@ -0,0 +1,24 @@
+{
+ "ethernet": {
+ "eth1": {
+ "hw-id": "52:54:00:xx",
+ "vif": {
+ "10": {
+ "description": "VLAN10",
+ "address": "192.168.10.1/24"
+ },
+ "20": {
+ "description": "VLAN20"
+ }
+ }
+ },
+ "eth2": {
+ "hw-id": "52:54:00:yy",
+ "vif": {
+ "10": {
+ "description": "VLAN10"
+ }
+ }
+ }
+ }
+}
diff --git a/tests/unit/modules/base.py b/tests/unit/modules/base.py
index 4d49fbb..a3e2eeb 100644
--- a/tests/unit/modules/base.py
+++ b/tests/unit/modules/base.py
@@ -13,12 +13,23 @@ import unittest
from unittest.mock import MagicMock # noqa: F401
+_fixture_cache = {}
+
+
def load_fixture(filename):
- """Load a JSON fixture file from tests/unit/fixtures/."""
+ """Load a fixture file from tests/unit/fixtures/. Results are cached."""
fixtures_dir = os.path.join(os.path.dirname(__file__), "..", "fixtures")
path = os.path.join(fixtures_dir, filename)
+ if path in _fixture_cache:
+ return _fixture_cache[path]
with open(path) as f:
- return json.load(f)
+ data = f.read()
+ try:
+ data = json.loads(data)
+ except json.JSONDecodeError:
+ pass
+ _fixture_cache[path] = data
+ return data
class VyOSModuleTestCase(unittest.TestCase):
diff --git a/tests/unit/modules/test_vyos_bgp_address_family.py b/tests/unit/modules/test_vyos_bgp_address_family.py
index fc080af..a63dd56 100644
--- a/tests/unit/modules/test_vyos_bgp_address_family.py
+++ b/tests/unit/modules/test_vyos_bgp_address_family.py
@@ -4,25 +4,24 @@ 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_bgp_address_family import (
+ _device_to_argspec,
+ _global_af_from_device,
+ _global_af_to_device,
+ _neighbor_af_from_device,
+ _neighbor_af_to_device,
build_commands,
get_running_config,
)
-
-_BASE = ["protocols", "bgp"]
+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", "bgp"]
class VyOSModuleTestCase(unittest.TestCase):
@@ -32,204 +31,233 @@ class VyOSModuleTestCase(unittest.TestCase):
self.mock_vyos.get_config = MagicMock(return_value=self.fixture)
-class TestVyOSBgpAFGetRunning(VyOSModuleTestCase):
-
- def test_parses_as_number(self):
- result = get_running_config(self.mock_vyos)
- self.assertEqual(result["as_number"], 65000)
-
- def test_parses_global_af_networks(self):
- result = get_running_config(self.mock_vyos)
- ipv4 = next(af for af in result["address_family"] if af["afi"] == "ipv4")
- prefixes = [n["prefix"] for n in ipv4["networks"]]
- self.assertIn("192.0.2.0/24", prefixes)
- self.assertIn("192.0.3.0/24", prefixes)
-
- def test_parses_global_af_redistribute(self):
- result = get_running_config(self.mock_vyos)
- ipv4 = next(af for af in result["address_family"] if af["afi"] == "ipv4")
- protos = [r["protocol"] for r in ipv4["redistribute"]]
- self.assertIn("connected", protos)
- connected = next(r for r in ipv4["redistribute"] if r["protocol"] == "connected")
- self.assertEqual(connected["metric"], 10)
-
- def test_parses_neighbor_af(self):
- result = get_running_config(self.mock_vyos)
- nb = next(n for n in result["neighbors"] if n["neighbor_address"] == "192.0.2.1")
- afis = [af["afi"] for af in nb["address_family"]]
- self.assertIn("ipv4", afis)
- self.assertIn("ipv6", afis)
- ipv4 = next(af for af in nb["address_family"] if af["afi"] == "ipv4")
- self.assertTrue(ipv4["nexthop_self"])
- self.assertTrue(ipv4["soft_reconfiguration"])
+class TestGetRunningConfig(VyOSModuleTestCase):
+ def test_returns_raw_device_dict(self):
+ self.assertEqual(get_running_config(self.mock_vyos), self.fixture)
def test_empty_config(self):
- self.mock_vyos.get_config = MagicMock(return_value={})
- result = get_running_config(self.mock_vyos)
- self.assertEqual(result, {})
+ self.mock_vyos.get_config = MagicMock(return_value=None)
+ self.assertEqual(get_running_config(self.mock_vyos), {})
-class TestVyOSBgpAFBuildCommands(unittest.TestCase):
+class TestNeighborAfToDevice(unittest.TestCase):
+ """The three genuine device-shape exceptions, individually, plus proof
+ that everything else is untouched by _autoclean alone."""
- def _have(self):
- return {
- "as_number": 65000,
- "address_family": [
+ def test_soft_reconfiguration_nests_under_inbound(self):
+ result = _neighbor_af_to_device([{"afi": "ipv4", "soft_reconfiguration": True}])
+ self.assertEqual(result, {"ipv4-unicast": {"soft_reconfiguration": {"inbound": {}}}})
+
+ def test_allowas_in_wraps_under_number(self):
+ result = _neighbor_af_to_device([{"afi": "ipv4", "allowas_in": 3}])
+ self.assertEqual(result, {"ipv4-unicast": {"allowas_in": {"number": 3}}})
+
+ def test_capability_orf_value_becomes_dict_key(self):
+ result = _neighbor_af_to_device([{"afi": "ipv4", "capability": {"orf": "send"}}])
+ self.assertEqual(
+ result,
+ {"ipv4-unicast": {"capability": {"orf": {"prefix-list": {"send": {}}}}}},
+ )
+
+ def test_generic_options_pass_through_autoclean_only(self):
+ result = _neighbor_af_to_device(
+ [
{
"afi": "ipv4",
- "networks": [{"prefix": "192.0.2.0/24"}],
- "redistribute": [{"protocol": "connected", "metric": 10}],
+ "nexthop_self": True,
+ "weight": 50,
+ "route_map": {"import": "RM-IN"},
+ "distribute_list": {"import": 10, "export": 20},
+ "attribute_unchanged": {"as_path": True, "next_hop": False},
},
],
- "neighbors": [
- {
- "neighbor_address": "192.0.2.1",
- "address_family": [
- {"afi": "ipv4", "soft_reconfiguration": True, "nexthop_self": True},
- ],
+ )
+ self.assertEqual(
+ result,
+ {
+ "ipv4-unicast": {
+ "nexthop_self": {},
+ "weight": 50,
+ "route_map": {"import": "RM-IN"},
+ "distribute_list": {"import": 10, "export": 20},
+ "attribute_unchanged": {"as_path": {}},
},
- ],
- }
+ },
+ )
- def test_deleted_removes_global_af(self):
- cmds = build_commands({"as_number": 65000}, self._have(), "deleted")
- self.assertIn(("delete", _BASE + ["address-family"]), cmds)
+ def test_no_options_is_bare_presence(self):
+ self.assertEqual(_neighbor_af_to_device([{"afi": "ipv4"}]), {"ipv4-unicast": {}})
- def test_deleted_removes_neighbor_af(self):
- cmds = build_commands({"as_number": 65000}, self._have(), "deleted")
- self.assertIn(
- ("delete", _BASE + ["neighbor", "192.0.2.1", "address-family"]),
- cmds,
+
+class TestNeighborAfFromDevice(unittest.TestCase):
+ def test_soft_reconfiguration_from_nested_inbound(self):
+ result = _neighbor_af_from_device(
+ {"ipv4-unicast": {"soft-reconfiguration": {"inbound": {}}}},
)
+ self.assertEqual(result, [{"afi": "ipv4", "soft_reconfiguration": True}])
- def test_merged_network(self):
- config = {
- "as_number": 65000,
- "address_family": [
- {"afi": "ipv4", "networks": [{"prefix": "192.0.5.0/24"}]},
- ],
- }
- cmds = build_commands(config, {}, "merged")
- self.assertIn(
- ("set", _BASE + ["address-family", "ipv4-unicast", "network", "192.0.5.0/24"]),
- cmds,
+ def test_allowas_in_from_number_wrapper(self):
+ result = _neighbor_af_from_device({"ipv4-unicast": {"allowas-in": {"number": "3"}}})
+ self.assertEqual(result, [{"afi": "ipv4", "allowas_in": 3}])
+
+ def test_allowas_in_bare_presence_defaults_to_one(self):
+ result = _neighbor_af_from_device({"ipv4-unicast": {"allowas-in": {}}})
+ self.assertEqual(result, [{"afi": "ipv4", "allowas_in": 1}])
+
+ def test_capability_orf_receive_and_send(self):
+ r1 = _neighbor_af_from_device(
+ {"ipv4-unicast": {"capability": {"orf": {"prefix-list": {"receive": {}}}}}},
)
+ self.assertEqual(r1[0]["capability"], {"orf": "receive"})
+ r2 = _neighbor_af_from_device(
+ {"ipv4-unicast": {"capability": {"orf": {"prefix-list": {"send": {}}}}}},
+ )
+ self.assertEqual(r2[0]["capability"], {"orf": "send"})
- def test_merged_redistribute(self):
- config = {
- "as_number": 65000,
- "address_family": [
- {"afi": "ipv4", "redistribute": [{"protocol": "connected", "metric": 10}]},
- ],
- }
- cmds = build_commands(config, {}, "merged")
- self.assertIn(
- ("set", _BASE + ["address-family", "ipv4-unicast", "redistribute", "connected"]),
- cmds,
+ def test_ints_cast_via_argspec_not_hardcoded_list(self):
+ result = _neighbor_af_from_device(
+ {
+ "ipv4-unicast": {
+ "maximum-prefix": "100",
+ "weight": "50",
+ "distribute-list": {"import": "10", "export": "20"},
+ },
+ },
)
+ entry = result[0]
+ self.assertEqual(entry["maximum_prefix"], 100)
+ self.assertEqual(entry["weight"], 50)
+ self.assertEqual(entry["distribute_list"], {"import": 10, "export": 20})
+
+
+class TestGlobalAfToDeviceFromDevice(unittest.TestCase):
+ def test_networks_keyed_by_prefix(self):
+ result = _global_af_to_device(
+ [{"afi": "ipv4", "networks": [{"prefix": "192.0.2.0/24", "backdoor": True}]}],
+ )
+ self.assertEqual(
+ result,
+ {"ipv4-unicast": {"network": {"192.0.2.0/24": {"backdoor": {}}}}},
+ )
+
+ def test_redistribute_keyed_by_protocol(self):
+ result = _global_af_to_device(
+ [{"afi": "ipv4", "redistribute": [{"protocol": "connected", "metric": 10}]}],
+ )
+ self.assertEqual(
+ result,
+ {"ipv4-unicast": {"redistribute": {"connected": {"metric": 10}}}},
+ )
+
+ def test_from_device_metric_cast_via_argspec(self):
+ result = _global_af_from_device(
+ {"ipv4-unicast": {"redistribute": {"connected": {"metric": "10"}}}},
+ )
+ self.assertEqual(result[0]["redistribute"], [{"protocol": "connected", "metric": 10}])
+
+
+class TestDeviceToArgspecFixture(VyOSModuleTestCase):
+ def test_as_number(self):
+ self.assertEqual(_device_to_argspec(self.fixture)["as_number"], 65000)
+
+ def test_global_networks_and_redistribute(self):
+ af = _device_to_argspec(self.fixture)["address_family"][0]
+ prefixes = {n["prefix"]: n for n in af["networks"]}
+ self.assertEqual(
+ prefixes["192.0.3.0/24"],
+ {"prefix": "192.0.3.0/24", "route_map": "RM-OUT", "backdoor": True},
+ )
+ protocols = {r["protocol"]: r for r in af["redistribute"]}
+ self.assertEqual(protocols["connected"]["metric"], 10)
+
+ def test_neighbor_wired_options(self):
+ nb = _device_to_argspec(self.fixture)["neighbors"][0]
+ ipv4 = next(af for af in nb["address_family"] if af["afi"] == "ipv4")
+ self.assertTrue(ipv4["nexthop_self"])
+ self.assertTrue(ipv4["soft_reconfiguration"])
+ self.assertEqual(ipv4["attribute_unchanged"], {"as_path": True, "med": True})
+ self.assertEqual(ipv4["capability"], {"orf": "receive"})
+ self.assertEqual(ipv4["distribute_list"], {"import": 10, "export": 20})
+
+ def test_empty_config(self):
+ self.assertEqual(_device_to_argspec({}), {})
+ self.assertEqual(_device_to_argspec(None), {})
+
+
+class TestBuildCommands(VyOSModuleTestCase):
+ """End-to-end, exactly as main() calls it."""
+
+ def test_merged_idempotent_against_own_fixture(self):
+ have = _device_to_argspec(self.fixture)
+ self.assertEqual(build_commands(have, self.fixture, "merged"), [])
+
+ def test_replaced_idempotent_against_own_fixture(self):
+ have = _device_to_argspec(self.fixture)
+ self.assertEqual(build_commands(have, self.fixture, "replaced"), [])
+
+ def test_merged_new_option(self):
+ have = _device_to_argspec(self.fixture)
+ have["neighbors"][0]["address_family"][0]["weight"] = 200
+ cmds = build_commands(have, self.fixture, "merged")
self.assertIn(
(
"set",
_BASE
- + [
- "address-family",
- "ipv4-unicast",
- "redistribute",
- "connected",
- "metric",
- "10",
- ],
+ + ["neighbor", "192.0.2.1", "address-family", "ipv4-unicast", "weight", "200"],
),
cmds,
)
- def test_merged_neighbor_soft_reconfig(self):
- config = {
- "as_number": 65000,
- "neighbors": [
- {
- "neighbor_address": "192.0.2.1",
- "address_family": [
- {"afi": "ipv4", "soft_reconfiguration": True},
- ],
- },
- ],
- }
- cmds = build_commands(config, {}, "merged")
+ def test_replaced_never_touches_neighbor_siblings(self):
+ """Regression test: dict_op is scoped strictly to each neighbor's
+ address-family subtree, never the whole neighbor.<addr> entry, so
+ fields owned by other modules (remote-as, timers, ...) are safe."""
+ cmds = build_commands({"as_number": 65000}, self.fixture, "replaced")
+ self.assertTrue(all("remote-as" not in c[1] for c in cmds))
self.assertIn(
- (
- "set",
- _BASE
- + [
- "neighbor",
- "192.0.2.1",
- "address-family",
- "ipv4-unicast",
- "soft-reconfiguration",
- "inbound",
- ],
- ),
+ ("delete", _BASE + ["neighbor", "192.0.2.1", "address-family", "ipv4-unicast"]),
+ cmds,
+ )
+ self.assertIn(
+ ("delete", _BASE + ["neighbor", "192.0.2.1", "address-family", "ipv6-unicast"]),
cmds,
)
- def test_merged_idempotent(self):
- have = self._have()
+ def test_deleted_scoped_to_address_family_only(self):
+ cmds = build_commands({}, self.fixture, "deleted")
+ self.assertIn(("delete", _BASE + ["address-family"]), cmds)
+ self.assertIn(
+ ("delete", _BASE + ["neighbor", "192.0.2.1", "address-family"]),
+ cmds,
+ )
+ self.assertTrue(all(c[1] != _BASE + ["neighbor", "192.0.2.1"] for c in cmds))
+
+ def test_normalize_have_prevents_char_iteration_bug(self):
+ """A single-child tag node collapsed to a bare string by the
+ device must not be iterated character-by-character."""
+ raw_have = {"address-family": {"ipv4-unicast": {"network": "192.0.2.0/24"}}}
config = {
"as_number": 65000,
- "address_family": [
- {
- "afi": "ipv4",
- "networks": [{"prefix": "192.0.2.0/24"}],
- "redistribute": [{"protocol": "connected", "metric": 10}],
- },
- ],
- "neighbors": [
- {
- "neighbor_address": "192.0.2.1",
- "address_family": [
- {"afi": "ipv4", "soft_reconfiguration": True, "nexthop_self": True},
- ],
- },
- ],
+ "address_family": [{"afi": "ipv4", "networks": [{"prefix": "192.0.2.0/24"}]}],
}
- cmds = build_commands(config, have, "merged")
- self.assertEqual(cmds, [])
+ self.assertEqual(build_commands(config, raw_have, "merged"), [])
- def test_replaced_idempotent(self):
- have = self._have()
+ def test_fresh_merged_add(self):
config = {
"as_number": 65000,
- "address_family": [
- {
- "afi": "ipv4",
- "networks": [{"prefix": "192.0.2.0/24"}],
- "redistribute": [{"protocol": "connected", "metric": 10}],
- },
- ],
"neighbors": [
{
- "neighbor_address": "192.0.2.1",
- "address_family": [
- {"afi": "ipv4", "soft_reconfiguration": True, "nexthop_self": True},
- ],
+ "neighbor_address": "10.0.0.1",
+ "address_family": [{"afi": "ipv4", "weight": 200}],
},
],
}
- cmds = build_commands(config, have, "replaced")
- self.assertEqual(cmds, [])
-
- def test_replaced_rebuilds_on_change(self):
- have = self._have()
- config = {
- "as_number": 65000,
- "address_family": [
- {"afi": "ipv4", "networks": [{"prefix": "192.0.9.0/24"}]},
- ],
- }
- cmds = build_commands(config, have, "replaced")
- self.assertIn(("delete", _BASE + ["address-family"]), cmds)
+ cmds = build_commands(config, {}, "merged")
self.assertIn(
- ("set", _BASE + ["address-family", "ipv4-unicast", "network", "192.0.9.0/24"]),
+ (
+ "set",
+ _BASE + ["neighbor", "10.0.0.1", "address-family", "ipv4-unicast", "weight", "200"],
+ ),
cmds,
)
diff --git a/tests/unit/modules/test_vyos_bgp_global.py b/tests/unit/modules/test_vyos_bgp_global.py
index f91516b..118cea8 100644
--- a/tests/unit/modules/test_vyos_bgp_global.py
+++ b/tests/unit/modules/test_vyos_bgp_global.py
@@ -4,25 +4,25 @@ 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_bgp_global import (
+ _device_to_argspec,
+ _neighbors_from_device,
+ _neighbors_to_device,
+ _peer_groups_from_device,
+ _peer_groups_to_device,
+ _want_to_device,
build_commands,
get_running_config,
)
-
-_BASE = ["protocols", "bgp"]
+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", "bgp"]
class VyOSModuleTestCase(unittest.TestCase):
@@ -32,156 +32,253 @@ class VyOSModuleTestCase(unittest.TestCase):
self.mock_vyos.get_config = MagicMock(return_value=self.fixture)
-class TestVyOSBgpGlobalGetRunning(VyOSModuleTestCase):
-
- def test_parses_as_number(self):
- result = get_running_config(self.mock_vyos)
- self.assertEqual(result["as_number"], 65000)
-
- def test_parses_parameters(self):
- result = get_running_config(self.mock_vyos)
- self.assertEqual(result["parameters"]["router_id"], "192.0.1.1")
- self.assertTrue(result["parameters"]["log_neighbor_changes"])
-
- def test_parses_neighbors(self):
- result = get_running_config(self.mock_vyos)
- nb_addrs = [n["neighbor_address"] for n in result["neighbors"]]
- self.assertIn("192.0.2.1", nb_addrs)
- self.assertIn("192.0.2.2", nb_addrs)
- nb1 = next(n for n in result["neighbors"] if n["neighbor_address"] == "192.0.2.1")
- self.assertEqual(nb1["remote_as"], 65001)
- self.assertEqual(nb1["description"], "peer1")
- self.assertEqual(nb1["timers"]["holdtime"], 30)
- self.assertEqual(nb1["timers"]["keepalive"], 10)
- nb2 = next(n for n in result["neighbors"] if n["neighbor_address"] == "192.0.2.2")
- self.assertEqual(nb2["ebgp_multihop"], 2)
- self.assertEqual(nb2["update_source"], "eth0")
-
- def test_parses_peer_groups(self):
- result = get_running_config(self.mock_vyos)
- self.assertEqual(len(result["peer_groups"]), 1)
- self.assertEqual(result["peer_groups"][0]["peer_group"], "PG1")
- self.assertEqual(result["peer_groups"][0]["remote_as"], 65003)
+class TestGetRunningConfig(VyOSModuleTestCase):
+ def test_returns_raw_device_dict(self):
+ self.assertEqual(get_running_config(self.mock_vyos), self.fixture)
def test_empty_config(self):
- self.mock_vyos.get_config = MagicMock(return_value={})
- result = get_running_config(self.mock_vyos)
- self.assertEqual(result, {})
+ self.mock_vyos.get_config = MagicMock(return_value=None)
+ self.assertEqual(get_running_config(self.mock_vyos), {})
-class TestVyOSBgpGlobalBuildCommands(unittest.TestCase):
+class TestNeighborsToDeviceFromDevice(unittest.TestCase):
+ def test_bare_neighbor_is_presence(self):
+ self.assertEqual(
+ _neighbors_to_device([{"neighbor_address": "192.0.2.1"}]),
+ {"192.0.2.1": {}},
+ )
- def _have(self):
- return {
- "as_number": 65000,
- "parameters": {"router_id": "192.0.1.1"},
- "neighbors": [
+ def test_full_neighbor(self):
+ result = _neighbors_to_device(
+ [
{
"neighbor_address": "192.0.2.1",
"remote_as": 65001,
"description": "peer1",
+ "shutdown": True,
+ "timers": {"holdtime": 30, "keepalive": 10},
},
],
- "peer_groups": [{"peer_group": "PG1", "remote_as": 65003}],
- }
+ )
+ self.assertEqual(
+ result,
+ {
+ "192.0.2.1": {
+ "remote_as": 65001,
+ "description": "peer1",
+ "shutdown": {},
+ "timers": {"holdtime": 30, "keepalive": 10},
+ },
+ },
+ )
- def test_deleted_with_have(self):
- cmds = build_commands({}, self._have(), "deleted")
- self.assertEqual(cmds, [("delete", _BASE)])
+ def test_from_device_ints_cast_via_argspec(self):
+ result = _neighbors_from_device(
+ {
+ "192.0.2.1": {
+ "remote-as": "65001",
+ "ebgp-multihop": "2",
+ "timers": {"holdtime": "30", "keepalive": "10"},
+ },
+ },
+ )
+ entry = result[0]
+ self.assertEqual(entry["remote_as"], 65001)
+ self.assertEqual(entry["ebgp_multihop"], 2)
+ self.assertEqual(entry["timers"], {"holdtime": 30, "keepalive": 10})
+
+ def test_from_device_foreign_address_family_never_surfaces(self):
+ """Regression test: a neighbor's address-family subtree (owned by
+ vyos_bgp_address_family) must never appear in this module's have/
+ gathered output."""
+ result = _neighbors_from_device(
+ {
+ "192.0.2.1": {
+ "remote-as": "65001",
+ "address-family": {"ipv4-unicast": {"nexthop-self": {}}},
+ },
+ },
+ )
+ entry = result[0]
+ self.assertEqual(entry["remote_as"], 65001)
+ self.assertNotIn("address_family", entry)
- def test_deleted_without_have(self):
- cmds = build_commands({}, {}, "deleted")
- self.assertEqual(cmds, [])
- def test_merged_as_number(self):
- config = {"as_number": 65000}
- cmds = build_commands(config, {}, "merged")
- self.assertIn(("set", _BASE + ["system-as", "65000"]), cmds)
+class TestPeerGroupsToDeviceFromDevice(unittest.TestCase):
+ def test_bare_peer_group_is_presence(self):
+ self.assertEqual(_peer_groups_to_device([{"peer_group": "PG1"}]), {"PG1": {}})
- def test_merged_router_id(self):
- config = {"as_number": 65000, "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_full_peer_group(self):
+ result = _peer_groups_to_device(
+ [{"peer_group": "PG1", "remote_as": 65002, "timers": {"holdtime": 30}}],
+ )
+ self.assertEqual(
+ result,
+ {"PG1": {"remote_as": 65002, "timers": {"holdtime": 30}}},
+ )
- def test_merged_neighbor(self):
- config = {
- "as_number": 65000,
- "neighbors": [
- {"neighbor_address": "192.0.2.1", "remote_as": 65001},
- ],
- }
- cmds = build_commands(config, {}, "merged")
- self.assertIn(("set", _BASE + ["neighbor", "192.0.2.1", "remote-as", "65001"]), cmds)
+ def test_from_device_cast(self):
+ result = _peer_groups_from_device({"PG1": {"remote-as": "65002"}})
+ self.assertEqual(result, [{"peer_group": "PG1", "remote_as": 65002}])
- def test_merged_neighbor_timers(self):
+
+class TestWantToDevice(unittest.TestCase):
+ def test_empty(self):
+ self.assertEqual(_want_to_device({}), {})
+ self.assertEqual(_want_to_device(None), {})
+
+ def test_confederation_peers_list_passes_through(self):
+ result = _want_to_device(
+ {
+ "as_number": 65000,
+ "parameters": {"confederation": {"identifier": 100, "peers": [65001, 65002]}},
+ },
+ )
+ self.assertEqual(
+ result,
+ {
+ "system_as": 65000,
+ "parameters": {"confederation": {"identifier": 100, "peers": [65001, 65002]}},
+ },
+ )
+
+ def test_full_config(self):
config = {
"as_number": 65000,
- "neighbors": [
- {
- "neighbor_address": "192.0.2.1",
- "remote_as": 65001,
- "timers": {"holdtime": 30, "keepalive": 10},
- },
- ],
+ "parameters": {"router_id": "192.0.1.1", "graceful_restart": True},
+ "neighbors": [{"neighbor_address": "192.0.2.1", "remote_as": 65001}],
+ "peer_groups": [{"peer_group": "PG1", "remote_as": 65002}],
}
- cmds = build_commands(config, {}, "merged")
+ result = _want_to_device(config)
+ self.assertEqual(
+ result,
+ {
+ "system_as": 65000,
+ "parameters": {"router_id": "192.0.1.1", "graceful_restart": {}},
+ "neighbor": {"192.0.2.1": {"remote_as": 65001}},
+ "peer_group": {"PG1": {"remote_as": 65002}},
+ },
+ )
+
+
+class TestDeviceToArgspecFixture(VyOSModuleTestCase):
+ def test_as_number_and_parameters(self):
+ have = _device_to_argspec(self.fixture)
+ self.assertEqual(have["as_number"], 65000)
+ self.assertEqual(have["parameters"]["router_id"], "192.0.1.1")
+ self.assertEqual(
+ have["parameters"]["confederation"],
+ {"identifier": 100, "peers": [65001, 65002]},
+ )
+
+ def test_neighbor_and_peer_group(self):
+ have = _device_to_argspec(self.fixture)
+ nb = next(n for n in have["neighbors"] if n["neighbor_address"] == "192.0.2.1")
+ self.assertEqual(nb["remote_as"], 65001)
+ self.assertEqual(nb["timers"], {"holdtime": 30, "keepalive": 10})
+ self.assertNotIn("address_family", nb)
+ pg = have["peer_groups"][0]
+ self.assertEqual(pg["peer_group"], "PG1")
+ self.assertEqual(pg["remote_as"], 65003)
+
+ 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 = _device_to_argspec(self.fixture)
+ self.assertEqual(build_commands(have, self.fixture, "merged"), [])
+
+ def test_replaced_idempotent_against_own_fixture(self):
+ have = _device_to_argspec(self.fixture)
+ self.assertEqual(build_commands(have, self.fixture, "replaced"), [])
+
+ def test_replaced_purges_extra_confederation_peer(self):
+ """Regression test: dict_op's purge mode originally had no
+ handling for list-valued leaves at all (only dicts), so removing
+ a peer from confederation.peers under 'replaced' silently did
+ nothing. Fixed centrally in dict_op itself."""
+ have = _device_to_argspec(self.fixture)
+ have["parameters"]["confederation"]["peers"] = [65001]
+ cmds = build_commands(have, self.fixture, "replaced")
self.assertIn(
- ("set", _BASE + ["neighbor", "192.0.2.1", "timers", "holdtime", "30"]),
+ ("delete", _BASE + ["parameters", "confederation", "peers", "65002"]),
cmds,
)
+
+ def test_merged_new_neighbor_field(self):
+ have = _device_to_argspec(self.fixture)
+ have["neighbors"][0]["local_as"] = 65099
+ cmds = build_commands(have, self.fixture, "merged")
self.assertIn(
- ("set", _BASE + ["neighbor", "192.0.2.1", "timers", "keepalive", "10"]),
+ ("set", _BASE + ["neighbor", "192.0.2.1", "local-as", "65099"]),
cmds,
)
- def test_merged_idempotent(self):
- have = self._have()
- config = {
- "as_number": 65000,
- "parameters": {"router_id": "192.0.1.1"},
- "neighbors": [
- {
- "neighbor_address": "192.0.2.1",
- "remote_as": 65001,
- "description": "peer1",
- },
- ],
- "peer_groups": [{"peer_group": "PG1", "remote_as": 65003}],
- }
- cmds = build_commands(config, have, "merged")
+ def test_replaced_never_touches_address_family(self):
+ """Regression test: this module shares protocols.bgp with
+ vyos_bgp_address_family; replaced/deleted must never purge or
+ delete that sibling module's address-family subtree."""
+ cmds = build_commands({"as_number": 65000}, self.fixture, "replaced")
+ self.assertTrue(all("address-family" not in c[1] for c in cmds))
+
+ def test_deleted_removes_atomically_not_scoped(self):
+ """Regression test for the real device-model bug: VyOS rejects any
+ commit that removes system-as while other protocols.bgp content
+ (including a neighbor's address-family, owned by
+ vyos_bgp_address_family) still exists. A scoped/incremental
+ deletion here would leave an invalid intermediate state and hard-
+ fail at commit time -- deleted must delete the whole tree in one
+ atomic command whenever system-as is present."""
+ cmds = build_commands({}, self.fixture, "deleted")
+ self.assertEqual(cmds, [("delete", _BASE)])
+
+ def test_deleted_with_no_system_as_is_a_noop(self):
+ cmds = build_commands({}, {}, "deleted")
self.assertEqual(cmds, [])
- def test_replaced_idempotent(self):
- have = self._have()
- config = {
- "as_number": 65000,
- "parameters": {"router_id": "192.0.1.1"},
- "neighbors": [
- {
- "neighbor_address": "192.0.2.1",
- "remote_as": 65001,
- "description": "peer1",
- },
- ],
- "peer_groups": [{"peer_group": "PG1", "remote_as": 65003}],
- }
- cmds = build_commands(config, have, "replaced")
+ def test_replaced_without_as_number_also_nukes_atomically(self):
+ """The same VyOS constraint applies to 'replaced' whenever the new
+ desired state omits as_number -- not just 'deleted'."""
+ cmds = build_commands({"neighbors": []}, self.fixture, "replaced")
+ self.assertEqual(cmds, [("delete", _BASE)])
+
+ def test_merged_with_empty_config_is_a_safe_noop(self):
+ """Regression test: merged must NEVER trigger the nuke short-
+ circuit just because as_number was omitted -- an omitted config
+ for merged means "nothing to change", not "delete everything"."""
+ cmds = build_commands({}, self.fixture, "merged")
+ self.assertEqual(cmds, [])
+
+ def test_replaced_keeping_as_number_still_scopes_normally(self):
+ """When as_number is retained, replaced must still use the normal
+ scoped purge/set flow, not the atomic nuke."""
+ have = _device_to_argspec(self.fixture)
+ cmds = build_commands(have, self.fixture, "replaced")
self.assertEqual(cmds, [])
+ self.assertNotEqual(cmds, [("delete", _BASE)])
- def test_replaced_rebuilds_on_change(self):
- have = self._have()
- config = {"as_number": 65000, "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_collapsed_single_neighbor_no_char_iteration_bug(self):
+ """A neighbor tag node collapsed to a bare address string by the
+ device (single neighbor, otherwise unconfigured) must not be
+ iterated character-by-character."""
+ raw_have = {"system-as": "65000", "neighbor": "192.0.2.1"}
+ config = {"as_number": 65000, "neighbors": [{"neighbor_address": "192.0.2.1"}]}
+ self.assertEqual(build_commands(config, raw_have, "merged"), [])
- def test_merged_peer_group(self):
+ def test_fresh_merged_add(self):
config = {
"as_number": 65000,
- "peer_groups": [{"peer_group": "PG1", "remote_as": 65003}],
+ "neighbors": [{"neighbor_address": "10.0.0.1", "remote_as": 65010}],
}
cmds = build_commands(config, {}, "merged")
- self.assertIn(("set", _BASE + ["peer-group", "PG1", "remote-as", "65003"]), cmds)
+ self.assertIn(
+ ("set", _BASE + ["neighbor", "10.0.0.1", "remote-as", "65010"]),
+ cmds,
+ )
+ self.assertIn(("set", _BASE + ["system-as", "65000"]), cmds)
if __name__ == "__main__":
diff --git a/tests/unit/modules/test_vyos_command.py b/tests/unit/modules/test_vyos_command.py
new file mode 100644
index 0000000..5b474f9
--- /dev/null
+++ b/tests/unit/modules/test_vyos_command.py
@@ -0,0 +1,111 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import, division, print_function
+
+
+__metaclass__ = type
+
+import unittest
+
+from unittest.mock import MagicMock
+
+from ansible_collections.vyos.rest.plugins.modules.vyos_command import (
+ evaluate_conditions,
+ parse_command,
+ run_commands,
+)
+
+
+class TestVyOSCommandParseCommand(unittest.TestCase):
+
+ def test_string_single_word(self):
+ self.assertEqual(parse_command("version"), ["version"])
+
+ def test_string_multi_word(self):
+ self.assertEqual(parse_command("ip route"), ["ip", "route"])
+
+ def test_list_passthrough(self):
+ self.assertEqual(parse_command(["ip", "route"]), ["ip", "route"])
+
+
+class TestVyOSCommandEvaluateConditions(unittest.TestCase):
+
+ def _stdout(self):
+ return ["VyOS 1.5.0 output", "eth0 192.168.1.1"]
+
+ def test_contains_match(self):
+ failed, conds = evaluate_conditions(
+ self._stdout(),
+ ["result[0] contains VyOS"],
+ "all",
+ )
+ self.assertFalse(failed)
+ self.assertEqual(conds, [])
+
+ def test_contains_no_match(self):
+ failed, conds = evaluate_conditions(
+ self._stdout(),
+ ["result[0] contains NonExistent"],
+ "all",
+ )
+ self.assertTrue(failed)
+ self.assertIn("result[0] contains NonExistent", conds)
+
+ def test_match_all_both_pass(self):
+ failed, conditions = evaluate_conditions(
+ self._stdout(),
+ ["result[0] contains VyOS", "result[1] contains eth0"],
+ "all",
+ )
+ self.assertFalse(failed)
+
+ def test_match_all_one_fails(self):
+ failed, conditions = evaluate_conditions(
+ self._stdout(),
+ ["result[0] contains VyOS", "result[1] contains NonExistent"],
+ "all",
+ )
+ self.assertTrue(failed)
+
+ def test_match_any_one_passes(self):
+ failed, conditions = evaluate_conditions(
+ self._stdout(),
+ ["result[0] contains VyOS", "result[1] contains NonExistent"],
+ "any",
+ )
+ self.assertFalse(failed)
+
+ def test_empty_conditions(self):
+ failed, conds = evaluate_conditions(self._stdout(), [], "all")
+ self.assertFalse(failed)
+ self.assertEqual(conds, [])
+
+
+class TestVyOSCommandRunCommands(unittest.TestCase):
+
+ def setUp(self):
+ self.mock_vyos = MagicMock()
+
+ def test_run_list_command(self):
+ self.mock_vyos.show = MagicMock(return_value="VyOS 1.5.0")
+ result = run_commands(self.mock_vyos, [["version"]])
+ self.mock_vyos.show.assert_called_once_with(["version"])
+ self.assertEqual(result, ["VyOS 1.5.0"])
+
+ def test_run_string_command(self):
+ self.mock_vyos.show = MagicMock(return_value="uptime")
+ run_commands(self.mock_vyos, ["system uptime"])
+ self.mock_vyos.show.assert_called_once_with(["system", "uptime"])
+
+ def test_run_multiple_commands(self):
+ self.mock_vyos.show = MagicMock(side_effect=["out1", "out2"])
+ result = run_commands(self.mock_vyos, [["version"], ["interfaces"]])
+ self.assertEqual(result, ["out1", "out2"])
+
+ def test_run_none_returns_empty_string(self):
+ self.mock_vyos.show = MagicMock(return_value=None)
+ result = run_commands(self.mock_vyos, [["version"]])
+ self.assertEqual(result, [""])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/unit/modules/test_vyos_config.py b/tests/unit/modules/test_vyos_config.py
new file mode 100644
index 0000000..749aa82
--- /dev/null
+++ b/tests/unit/modules/test_vyos_config.py
@@ -0,0 +1,107 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import, division, print_function
+
+
+__metaclass__ = type
+
+import unittest
+
+from unittest.mock import MagicMock
+
+from ansible_collections.vyos.rest.plugins.modules.vyos_config import (
+ filter_commands,
+ parse_commands,
+ parse_line,
+)
+
+
+class TestVyOSConfigParseLine(unittest.TestCase):
+
+ def test_set_single_value(self):
+ op, path = parse_line("set system host-name router1")
+ self.assertEqual(op, "set")
+ self.assertEqual(path, ["system", "host-name", "router1"])
+
+ def test_delete(self):
+ op, path = parse_line("delete protocols bgp")
+ self.assertEqual(op, "delete")
+ self.assertEqual(path, ["protocols", "bgp"])
+
+ def test_quoted_value(self):
+ op, path = parse_line('set interfaces ethernet eth0 description "My WAN"')
+ self.assertEqual(op, "set")
+ self.assertEqual(path, ["interfaces", "ethernet", "eth0", "description", "My WAN"])
+
+ def test_blank_line(self):
+ self.assertIsNone(parse_line(""))
+
+ def test_comment_line(self):
+ self.assertIsNone(parse_line("# this is a comment"))
+
+ def test_whitespace_only(self):
+ self.assertIsNone(parse_line(" "))
+
+ def test_invalid_op(self):
+ self.assertIsNone(parse_line("get system host-name"))
+
+
+class TestVyOSConfigParseCommands(unittest.TestCase):
+
+ def test_mixed_lines(self):
+ lines = [
+ "# comment",
+ "",
+ "set system host-name router1",
+ "delete protocols bgp",
+ ]
+ result = parse_commands(lines)
+ self.assertEqual(len(result), 2)
+ self.assertEqual(result[0], ("set", ["system", "host-name", "router1"]))
+ self.assertEqual(result[1], ("delete", ["protocols", "bgp"]))
+
+
+class TestVyOSConfigFilterCommands(unittest.TestCase):
+
+ def setUp(self):
+ self.mock_vyos = MagicMock()
+
+ def test_set_already_exists(self):
+ # API returns {"host-name": "router1"} for path ["system", "host-name"]
+ self.mock_vyos.get_config = MagicMock(
+ return_value={"host-name": "router1"},
+ )
+ cmds = [("set", ["system", "host-name", "router1"])]
+ result = filter_commands(cmds, self.mock_vyos)
+ self.assertEqual(result, [])
+
+ def test_set_different_value(self):
+ self.mock_vyos.get_config = MagicMock(
+ return_value={"host-name": "old-name"},
+ )
+ cmds = [("set", ["system", "host-name", "new-name"])]
+ result = filter_commands(cmds, self.mock_vyos)
+ self.assertEqual(len(result), 1)
+
+ def test_set_not_present(self):
+ self.mock_vyos.get_config = MagicMock(return_value={})
+ cmds = [("set", ["system", "host-name", "router1"])]
+ result = filter_commands(cmds, self.mock_vyos)
+ self.assertEqual(len(result), 1)
+
+ def test_delete_exists(self):
+ self.mock_vyos.get_config = MagicMock(
+ return_value={"description": "some desc"},
+ )
+ cmds = [("delete", ["interfaces", "ethernet", "eth0", "description"])]
+ result = filter_commands(cmds, self.mock_vyos)
+ self.assertEqual(len(result), 1)
+
+ def test_delete_not_exists(self):
+ self.mock_vyos.get_config = MagicMock(return_value={})
+ cmds = [("delete", ["interfaces", "ethernet", "eth0", "description"])]
+ result = filter_commands(cmds, self.mock_vyos)
+ self.assertEqual(result, [])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/unit/modules/test_vyos_facts.py b/tests/unit/modules/test_vyos_facts.py
index 3fcf82e..7eec8d4 100644
--- a/tests/unit/modules/test_vyos_facts.py
+++ b/tests/unit/modules/test_vyos_facts.py
@@ -4,8 +4,6 @@ from __future__ import absolute_import, division, print_function
__metaclass__ = type
-import json
-import os
import unittest
from unittest.mock import MagicMock
@@ -18,11 +16,7 @@ from ansible_collections.vyos.rest.plugins.modules.vyos_facts import (
gather_users,
)
-
-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)
+from .base import load_fixture
class TestVyOSFactsGather(unittest.TestCase):
diff --git a/tests/unit/modules/test_vyos_firewall_global.py b/tests/unit/modules/test_vyos_firewall_global.py
index a507c0c..e3aff0b 100644
--- a/tests/unit/modules/test_vyos_firewall_global.py
+++ b/tests/unit/modules/test_vyos_firewall_global.py
@@ -4,38 +4,165 @@ 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.module_utils.vyos import dict_op
from ansible_collections.vyos.rest.plugins.modules.vyos_firewall_global import (
+ _device_to_argspec,
+ _groups_from_device,
+ _groups_to_device,
+ _want_to_device,
build_commands,
get_running_config,
)
+from .base import load_fixture
+
_BASE = ["firewall", "group"]
-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 TestGroupHelpers(unittest.TestCase):
+ """Test group list <-> device dict conversion helpers."""
+ def test_groups_to_device_with_members(self):
+ groups = [{"name": "SERVERS", "address": ["192.168.1.10", "192.168.1.11"]}]
+ result = _groups_to_device(groups, "address")
+ self.assertIn("SERVERS", result)
+ self.assertIn("192.168.1.10", result["SERVERS"]["address"])
+ self.assertIn("192.168.1.11", result["SERVERS"]["address"])
-class VyOSModuleTestCase(unittest.TestCase):
- def setUp(self):
- self.mock_vyos = MagicMock()
- self.fixture = load_fixture("firewall_global_running.json")
- self.mock_vyos.get_config = MagicMock(return_value=self.fixture)
+ def test_groups_to_device_with_description(self):
+ groups = [{"name": "LAN", "description": "Local network", "network": ["192.168.0.0/16"]}]
+ result = _groups_to_device(groups, "network")
+ self.assertEqual(result["LAN"]["description"], "Local network")
+
+ def test_groups_to_device_empty(self):
+ self.assertEqual(_groups_to_device([], "address"), {})
+ self.assertEqual(_groups_to_device(None, "address"), {})
+
+ def test_groups_from_device_with_members(self):
+ raw = {"SERVERS": {"address": {"192.168.1.10": {}, "192.168.1.11": {}}}}
+ result = _groups_from_device(raw, "address")
+ self.assertEqual(len(result), 1)
+ self.assertEqual(result[0]["name"], "SERVERS")
+ self.assertIn("192.168.1.10", result[0]["address"])
+
+ def test_groups_from_device_single_member_string(self):
+ # VyOS returns single member as string
+ raw = {"WEB": {"port": "80"}}
+ result = _groups_from_device(raw, "port")
+ self.assertIn("80", result[0]["port"])
+
+ def test_groups_from_device_with_description(self):
+ raw = {"LAN": {"description": "Local", "network": {"192.168.0.0/16": {}}}}
+ result = _groups_from_device(raw, "network")
+ self.assertEqual(result[0]["description"], "Local")
+
+ def test_groups_from_device_sorted(self):
+ raw = {"Z-GROUP": {}, "A-GROUP": {}}
+ result = _groups_from_device(raw, "address")
+ self.assertEqual(result[0]["name"], "A-GROUP")
+ self.assertEqual(result[1]["name"], "Z-GROUP")
+
+ def test_groups_from_device_empty(self):
+ self.assertEqual(_groups_from_device({}, "address"), [])
+ self.assertEqual(_groups_from_device(None, "address"), [])
+
+
+class TestWantToDevice(unittest.TestCase):
+ """Test argspec -> device shape conversion."""
+
+ def test_empty(self):
+ self.assertEqual(_want_to_device({}), {})
+ self.assertEqual(_want_to_device(None), {})
+
+ def test_address_group(self):
+ config = {
+ "group": {
+ "address_group": [
+ {"name": "SERVERS", "address": ["192.168.1.10"]},
+ ],
+ },
+ }
+ result = _want_to_device(config)
+ self.assertIn("address-group", result)
+ self.assertIn("SERVERS", result["address-group"])
+ self.assertIn("192.168.1.10", result["address-group"]["SERVERS"]["address"])
+
+ def test_network_group(self):
+ config = {
+ "group": {
+ "network_group": [{"name": "LAN", "network": ["192.168.0.0/16"]}],
+ },
+ }
+ result = _want_to_device(config)
+ self.assertIn("network-group", result)
+ self.assertIn("LAN", result["network-group"])
+
+ def test_port_group(self):
+ config = {
+ "group": {
+ "port_group": [{"name": "WEB", "port": ["80", "443"]}],
+ },
+ }
+ result = _want_to_device(config)
+ self.assertIn("port-group", result)
+ self.assertIn("80", result["port-group"]["WEB"]["port"])
+
+ def test_interface_group(self):
+ config = {
+ "group": {
+ "interface_group": [{"name": "LAN-IFACES", "interface": ["eth1"]}],
+ },
+ }
+ result = _want_to_device(config)
+ self.assertIn("interface-group", result)
+
+ def test_ipv6_network_group(self):
+ config = {
+ "group": {
+ "ipv6_network_group": [{"name": "IPV6-LAN", "network": ["2001:db8::/32"]}],
+ },
+ }
+ result = _want_to_device(config)
+ self.assertIn("ipv6-network-group", result)
-class TestVyOSFirewallGlobalGetRunning(VyOSModuleTestCase):
+class TestDeviceToArgspec(unittest.TestCase):
+ """Test device response -> argspec shape conversion."""
+
+ def test_empty(self):
+ self.assertEqual(_device_to_argspec({}), {})
+ self.assertEqual(_device_to_argspec(None), {})
+
+ def test_address_group(self):
+ raw = {
+ "address-group": {
+ "SERVERS": {
+ "description": "Web servers",
+ "address": {"192.168.1.10": {}, "192.168.1.11": {}},
+ },
+ },
+ }
+ result = _device_to_argspec(raw)
+ groups = result["group"]["address_group"]
+ servers = next(g for g in groups if g["name"] == "SERVERS")
+ self.assertEqual(servers["description"], "Web servers")
+ self.assertIn("192.168.1.10", servers["address"])
+
+ def test_no_group_returns_empty(self):
+ self.assertEqual(_device_to_argspec({}), {})
+
+
+class TestDeviceToArgspecFixture(unittest.TestCase):
+ """Test _device_to_argspec against fixture."""
+
+ def setUp(self):
+ self.fixture = load_fixture("firewall_global_running.json")
def test_parses_address_groups(self):
- result = get_running_config(self.mock_vyos)
+ result = _device_to_argspec(self.fixture)
groups = result["group"]["address_group"]
names = [g["name"] for g in groups]
self.assertIn("SERVERS", names)
@@ -43,148 +170,185 @@ class TestVyOSFirewallGlobalGetRunning(VyOSModuleTestCase):
servers = next(g for g in groups if g["name"] == "SERVERS")
self.assertEqual(servers["description"], "Web servers")
self.assertIn("192.168.1.10", servers["address"])
- self.assertIn("192.168.1.11", servers["address"])
def test_parses_network_groups(self):
- result = get_running_config(self.mock_vyos)
+ result = _device_to_argspec(self.fixture)
groups = result["group"]["network_group"]
dmz = next(g for g in groups if g["name"] == "DMZ")
self.assertIn("10.0.0.0/8", dmz["network"])
- self.assertIn("172.16.0.0/12", dmz["network"])
def test_parses_port_groups(self):
- result = get_running_config(self.mock_vyos)
+ result = _device_to_argspec(self.fixture)
groups = result["group"]["port_group"]
web = next(g for g in groups if g["name"] == "WEB-PORTS")
self.assertIn("80", web["port"])
- self.assertIn("443", web["port"])
def test_parses_interface_groups(self):
- result = get_running_config(self.mock_vyos)
+ result = _device_to_argspec(self.fixture)
groups = result["group"]["interface_group"]
lan = next(g for g in groups if g["name"] == "LAN-IFACES")
self.assertIn("eth1", lan["interface"])
def test_parses_ipv6_network_groups(self):
- result = get_running_config(self.mock_vyos)
+ result = _device_to_argspec(self.fixture)
groups = result["group"]["ipv6_network_group"]
ipv6 = next(g for g in groups if g["name"] == "IPV6-LAN")
self.assertIn("2001:db8::/32", ipv6["network"])
def test_empty_config(self):
- self.mock_vyos.get_config = MagicMock(return_value={})
- result = get_running_config(self.mock_vyos)
+ result = _device_to_argspec({})
self.assertEqual(result, {})
-class TestVyOSFirewallGlobalBuildCommands(unittest.TestCase):
+class TestDictOpFirewall(unittest.TestCase):
+ """Test dict_op behaviour with firewall group shapes."""
- def _have(self):
- return {
- "group": {
- "address_group": [
- {"name": "SERVERS", "address": ["192.168.1.10", "192.168.1.11"]},
- ],
- "network_group": [
- {"name": "LAN", "network": ["192.168.0.0/16"]},
- ],
+ def test_merged_adds_address_group(self):
+ want = _want_to_device(
+ {
+ "group": {
+ "address_group": [{"name": "SERVERS", "address": ["192.168.1.10"]}],
+ },
},
- }
-
- def test_deleted_with_have(self):
- cmds = build_commands({}, self._have(), "deleted")
- self.assertEqual(cmds, [("delete", _BASE)])
-
- def test_deleted_without_have(self):
- cmds = build_commands({}, {}, "deleted")
- self.assertEqual(cmds, [])
-
- def test_merged_address_group(self):
- config = {
- "group": {
- "address_group": [
- {"name": "SERVERS", "address": ["192.168.1.10"]},
- ],
- },
- }
- cmds = build_commands(config, {}, "merged")
- self.assertIn(
- ("set", _BASE + ["address-group", "SERVERS", "address", "192.168.1.10"]),
- cmds,
- )
-
- def test_merged_network_group(self):
- config = {
- "group": {
- "network_group": [
- {"name": "LAN", "network": ["192.168.0.0/16"]},
- ],
- },
- }
- cmds = build_commands(config, {}, "merged")
- self.assertIn(
- ("set", _BASE + ["network-group", "LAN", "network", "192.168.0.0/16"]),
- cmds,
- )
-
- def test_merged_port_group(self):
- config = {
- "group": {
- "port_group": [
- {"name": "WEB", "port": ["80", "443"]},
- ],
- },
- }
- cmds = build_commands(config, {}, "merged")
- self.assertIn(
- ("set", _BASE + ["port-group", "WEB", "port", "80"]),
- cmds,
)
+ cmds = dict_op(want, {}, _BASE, op="set")
+ paths = [c[1] for c in cmds]
+ self.assertIn(_BASE + ["address-group", "SERVERS", "address", "192.168.1.10"], paths)
def test_merged_idempotent(self):
- have = self._have()
config = {
"group": {
- "address_group": [
- {"name": "SERVERS", "address": ["192.168.1.10", "192.168.1.11"]},
- ],
- "network_group": [
- {"name": "LAN", "network": ["192.168.0.0/16"]},
- ],
+ "address_group": [{"name": "SERVERS", "address": ["192.168.1.10"]}],
},
}
- cmds = build_commands(config, have, "merged")
+ want = _want_to_device(config)
+ have = {"address-group": {"SERVERS": {"address": {"192.168.1.10": {}}}}}
+ cmds = dict_op(want, have, _BASE, op="set")
self.assertEqual(cmds, [])
- def test_replaced_removes_extra_group(self):
- have = self._have()
- config = {
- "group": {
- "network_group": [
- {"name": "DMZ", "network": ["10.0.0.0/8"]},
- ],
+ def test_merged_adds_network_group(self):
+ want = _want_to_device(
+ {
+ "group": {
+ "network_group": [{"name": "LAN", "network": ["192.168.0.0/16"]}],
+ },
},
+ )
+ cmds = dict_op(want, {}, _BASE, op="set")
+ paths = [c[1] for c in cmds]
+ self.assertIn(_BASE + ["network-group", "LAN", "network", "192.168.0.0/16"], paths)
+
+ def test_purge_removes_extra_group(self):
+ want = _want_to_device(
+ {
+ "group": {
+ "network_group": [{"name": "DMZ", "network": ["10.0.0.0/8"]}],
+ },
+ },
+ )
+ have = {
+ "address-group": {"SERVERS": {"address": {"192.168.1.10": {}}}},
+ "network-group": {"LAN": {"network": {"192.168.0.0/16": {}}}},
}
- cmds = build_commands(config, have, "replaced")
+ cmds = dict_op(want, have, _BASE, op="purge")
paths = [c[1] for c in cmds]
- self.assertIn(_BASE + ["address-group", "SERVERS"], paths)
+ # address-group entirely absent from want -> whole type deleted
+ self.assertIn(_BASE + ["address-group"], paths)
self.assertIn(_BASE + ["network-group", "LAN"], paths)
- def test_replaced_idempotent(self):
- have = self._have()
+ def test_merged_idempotent_with_description(self):
+ """Regression test: the original implementation's have-side
+ normalization corrupted plain scalar fields (description) into
+ bogus presence-dicts via the same blanket conversion used for
+ member lists, breaking idempotency for any group with a
+ description set. Fixed by using dict_op's native list handling
+ for members and never touching scalar fields at all."""
config = {
"group": {
"address_group": [
- {"name": "SERVERS", "address": ["192.168.1.10", "192.168.1.11"]},
- ],
- "network_group": [
- {"name": "LAN", "network": ["192.168.0.0/16"]},
+ {"name": "SERVERS", "description": "Web servers", "address": ["10.0.0.1"]},
],
},
}
- cmds = build_commands(config, have, "replaced")
+ want = _want_to_device(config)
+ have = {
+ "address-group": {
+ "SERVERS": {"description": "Web servers", "address": ["10.0.0.1"]},
+ },
+ }
+ cmds = dict_op(want, have, _BASE, op="set")
self.assertEqual(cmds, [])
+ def test_replaced_removes_stale_member(self):
+ """Regression test: dict_op's purge mode originally had no
+ handling for list-valued leaves (only dicts), so a member
+ present on the device but absent from the desired list was
+ silently never removed under 'replaced'. Fixed centrally in
+ dict_op itself."""
+ want = _want_to_device(
+ {"group": {"address_group": [{"name": "SERVERS", "address": ["10.0.0.1"]}]}},
+ )
+ have = {"address-group": {"SERVERS": {"address": ["10.0.0.1", "10.0.0.2"]}}}
+ cmds = dict_op(want, have, _BASE, op="purge")
+ self.assertIn(
+ ("delete", _BASE + ["address-group", "SERVERS", "address", "10.0.0.2"]),
+ cmds,
+ )
+
+ def test_deleted_removes_base(self):
+ # deleted state is handled in main() not dict_op
+ # just verify want_to_device produces correct shape
+ want = _want_to_device({})
+ self.assertEqual(want, {})
+
+
+class TestBuildCommands(unittest.TestCase):
+ """build_commands/get_running_config were extracted from main() during
+ the dict_op refactor so they're independently testable."""
+
+ def test_get_running_config_fetches_at_base(self):
+ from unittest.mock import MagicMock
+
+ mock_vyos = MagicMock()
+ mock_vyos.get_config = MagicMock(return_value={"address-group": {}})
+ get_running_config(mock_vyos)
+ mock_vyos.get_config.assert_called_once_with(_BASE)
+
+ def test_merged_idempotent_against_fixture(self):
+ fixture = load_fixture("firewall_global_running.json")
+ have = _device_to_argspec(fixture)
+ self.assertEqual(build_commands(have, fixture, "merged"), [])
+
+ def test_replaced_idempotent_against_fixture(self):
+ fixture = load_fixture("firewall_global_running.json")
+ have = _device_to_argspec(fixture)
+ self.assertEqual(build_commands(have, fixture, "replaced"), [])
+
+ def test_deleted_no_have_is_noop(self):
+ self.assertEqual(build_commands({}, {}, "deleted"), [])
+
+ def test_deleted_with_have(self):
+ self.assertEqual(
+ build_commands({}, {"address-group": {"SERVERS": {}}}, "deleted"),
+ [("delete", _BASE)],
+ )
+
+ def test_single_value_member_collapse_no_char_iteration_bug(self):
+ """A group with exactly one member, collapsed by the device to a
+ bare string instead of a list, must not be iterated
+ character-by-character."""
+ raw_have = {"port-group": {"WEB": {"port": "80"}}}
+ config = {"group": {"port_group": [{"name": "WEB", "port": ["80"]}]}}
+ self.assertEqual(build_commands(config, raw_have, "merged"), [])
+
+ def test_group_tag_node_collapse_no_char_iteration_bug(self):
+ """A single group with zero other fields, collapsed by the
+ device to a bare group-name string, must not be iterated
+ character-by-character."""
+ raw_have = {"address-group": "SERVERS"}
+ config = {"group": {"address_group": [{"name": "SERVERS"}]}}
+ self.assertEqual(build_commands(config, raw_have, "merged"), [])
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/unit/modules/test_vyos_firewall_interfaces.py b/tests/unit/modules/test_vyos_firewall_interfaces.py
index 66b3883..26d2c4e 100644
--- a/tests/unit/modules/test_vyos_firewall_interfaces.py
+++ b/tests/unit/modules/test_vyos_firewall_interfaces.py
@@ -4,107 +4,188 @@ 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_firewall_interfaces import (
+ _device_to_argspec,
+ _hook_filter_from_device,
+ _hook_filter_to_device,
+ _rules_from_device,
+ _rules_to_device,
+ _want_to_device,
build_commands,
get_running_config,
)
-
-_BASE = ["firewall"]
+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 = ["firewall"]
class VyOSModuleTestCase(unittest.TestCase):
def setUp(self):
self.mock_vyos = MagicMock()
self.fixture = load_fixture("firewall_interfaces_running.json")
+ self.mock_vyos.get_config = MagicMock(return_value=self.fixture)
- def _set_afi(self, afi):
- data = self.fixture.get(afi, {})
- self.mock_vyos.get_config = MagicMock(return_value=data)
+class TestGetRunningConfig(VyOSModuleTestCase):
+ def test_single_combined_fetch(self):
+ """Confirm get_running_config fetches once at _BASE, not per-AFI."""
+ get_running_config(self.mock_vyos)
+ self.mock_vyos.get_config.assert_called_once_with(_BASE)
-class TestVyOSFirewallInterfacesGetRunning(VyOSModuleTestCase):
+ def test_returns_raw_device_dict(self):
+ self.assertEqual(get_running_config(self.mock_vyos), self.fixture)
- def test_parses_ipv4_hooks(self):
- self._set_afi("ipv4")
- result = get_running_config(self.mock_vyos)
- ipv4 = next((e for e in result if e["afi"] == "ipv4"), None)
- self.assertIsNotNone(ipv4)
- hook_names = [h["hook"] for h in ipv4["hooks"]]
- self.assertIn("input", hook_names)
- self.assertIn("forward", hook_names)
- self.assertIn("output", hook_names)
+ def test_empty_config(self):
+ self.mock_vyos.get_config = MagicMock(return_value=None)
+ self.assertEqual(get_running_config(self.mock_vyos), {})
- def test_parses_input_rules(self):
- self._set_afi("ipv4")
- result = get_running_config(self.mock_vyos)
- ipv4 = next(e for e in result if e["afi"] == "ipv4")
- input_hook = next(h for h in ipv4["hooks"] if h["hook"] == "input")
- self.assertEqual(input_hook["default_action"], "accept")
- self.assertEqual(len(input_hook["rules"]), 2)
- r10 = next(r for r in input_hook["rules"] if r["number"] == 10)
- self.assertEqual(r10["action"], "accept")
- self.assertEqual(r10["state"], "established")
-
- def test_parses_ipv6_hooks(self):
- self._set_afi("ipv6")
- result = get_running_config(self.mock_vyos)
- ipv6 = next((e for e in result if e["afi"] == "ipv6"), None)
- self.assertIsNotNone(ipv6)
- self.assertEqual(ipv6["hooks"][0]["hook"], "input")
- def test_empty_config(self):
- self.mock_vyos.get_config = MagicMock(return_value={})
- result = get_running_config(self.mock_vyos)
- self.assertEqual(result, [])
+class TestRulesToDeviceFromDevice(unittest.TestCase):
+ def test_bare_rule_is_presence(self):
+ self.assertEqual(_rules_to_device([{"number": 10}]), {"10": {}})
+
+ def test_full_rule(self):
+ result = _rules_to_device(
+ [
+ {
+ "number": 20,
+ "action": "drop",
+ "state": "invalid",
+ "source": {"address": "10.0.0.0/8"},
+ "disable": True,
+ },
+ ],
+ )
+ self.assertEqual(
+ result,
+ {
+ "20": {
+ "action": "drop",
+ "state": "invalid",
+ "source": {"address": "10.0.0.0/8"},
+ "disable": {},
+ },
+ },
+ )
+
+ def test_from_device_number_cast_to_int(self):
+ result = _rules_from_device({"10": {"action": "accept"}})
+ self.assertEqual(result, [{"number": 10, "action": "accept"}])
+
+ def test_from_device_sorted_numerically_not_lexically(self):
+ result = _rules_from_device({"20": {}, "9": {}, "100": {}})
+ self.assertEqual([r["number"] for r in result], [9, 20, 100])
+
+ def test_source_destination_round_trip(self):
+ raw = {"20": {"source": {"address": "10.0.0.0/8"}, "destination": {"port": "22"}}}
+ result = _rules_from_device(raw)
+ self.assertEqual(result[0]["source"], {"address": "10.0.0.0/8"})
+ self.assertEqual(result[0]["destination"], {"port": "22"})
+
+
+class TestHookFilterToDeviceFromDevice(unittest.TestCase):
+ def test_bare_hook_is_presence(self):
+ self.assertEqual(_hook_filter_to_device({"hook": "input"}), {})
+
+ def test_default_action_and_description(self):
+ result = _hook_filter_to_device(
+ {"hook": "input", "default_action": "accept", "description": "desc"},
+ )
+ self.assertEqual(result, {"default_action": "accept", "description": "desc"})
+
+ def test_with_rules(self):
+ result = _hook_filter_to_device(
+ {"hook": "input", "rules": [{"number": 10, "action": "accept"}]},
+ )
+ self.assertEqual(result, {"rule": {"10": {"action": "accept"}}})
+ def test_from_device_basic(self):
+ entry = _hook_filter_from_device("input", {"default-action": "accept"})
+ self.assertEqual(entry, {"hook": "input", "default_action": "accept"})
+
+ def test_from_device_with_rules(self):
+ entry = _hook_filter_from_device(
+ "input",
+ {"default-action": "accept", "rule": {"10": {"action": "accept"}}},
+ )
+ self.assertEqual(entry["default_action"], "accept")
+ self.assertEqual(entry["rules"], [{"number": 10, "action": "accept"}])
-class TestVyOSFirewallInterfacesBuildCommands(unittest.TestCase):
- def _have(self):
- return [
+class TestWantToDevice(unittest.TestCase):
+ def test_empty(self):
+ self.assertEqual(_want_to_device([]), {})
+ self.assertEqual(_want_to_device(None), {})
+
+ def test_afi_with_no_hooks_omitted(self):
+ self.assertEqual(_want_to_device([{"afi": "ipv4", "hooks": []}]), {})
+
+ def test_full_config(self):
+ config = [
{
"afi": "ipv4",
"hooks": [
- {
- "hook": "input",
- "default_action": "accept",
- "rules": [
- {"number": 10, "action": "accept", "state": "established"},
- {"number": 20, "action": "drop", "state": "invalid"},
- ],
- },
- {"hook": "forward", "default_action": "accept"},
+ {"hook": "input", "default_action": "accept"},
],
},
]
+ result = _want_to_device(config)
+ self.assertEqual(
+ result,
+ {"ipv4": {"input": {"filter": {"default_action": "accept"}}}},
+ )
- def test_deleted_all(self):
- cmds = build_commands([], self._have(), "deleted")
- paths = [c[1] for c in cmds]
- self.assertIn(_BASE + ["ipv4", "input", "filter"], paths)
- self.assertIn(_BASE + ["ipv4", "forward", "filter"], paths)
- def test_deleted_specific(self):
- config = [{"afi": "ipv4", "hooks": [{"hook": "input"}]}]
- cmds = build_commands(config, self._have(), "deleted")
- self.assertIn(("delete", _BASE + ["ipv4", "input", "filter"]), cmds)
- paths = [c[1] for c in cmds]
- self.assertNotIn(_BASE + ["ipv4", "forward", "filter"], paths)
+class TestDeviceToArgspecFixture(VyOSModuleTestCase):
+ def test_ipv4_input_with_rules(self):
+ have = _device_to_argspec(self.fixture)
+ ipv4 = next(e for e in have if e["afi"] == "ipv4")
+ input_hook = next(h for h in ipv4["hooks"] if h["hook"] == "input")
+ self.assertEqual(input_hook["default_action"], "accept")
+ rule20 = next(r for r in input_hook["rules"] if r["number"] == 20)
+ self.assertEqual(rule20["source"], {"address": "10.0.0.0/8"})
+ self.assertEqual(rule20["destination"], {"port": "22"})
+
+ def test_sibling_module_data_never_surfaces(self):
+ """Regression test: firewall.ipv4.name (owned by
+ vyos_firewall_rules) must never appear in this module's output."""
+ have = _device_to_argspec(self.fixture)
+ ipv4 = next(e for e in have if e["afi"] == "ipv4")
+ hook_names = {h["hook"] for h in ipv4["hooks"]}
+ self.assertEqual(hook_names, {"input", "forward", "output"})
+
+ def test_ipv6_present(self):
+ have = _device_to_argspec(self.fixture)
+ afis = {e["afi"] for e in have}
+ self.assertIn("ipv6", afis)
+
+ 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 = _device_to_argspec(self.fixture)
+ self.assertEqual(build_commands(have, self.fixture, "merged"), [])
+
+ def test_replaced_idempotent_against_own_fixture(self):
+ have = _device_to_argspec(self.fixture)
+ self.assertEqual(build_commands(have, self.fixture, "replaced"), [])
+
+ def test_overridden_idempotent_against_own_fixture(self):
+ have = _device_to_argspec(self.fixture)
+ self.assertEqual(build_commands(have, self.fixture, "overridden"), [])
- def test_merged_hook(self):
+ def test_replaced_scoped_to_named_hooks_only(self):
+ """replaced only touches hooks explicitly named in config -- an
+ omitted hook (output here) must be left alone."""
config = [
{
"afi": "ipv4",
@@ -112,23 +193,24 @@ class TestVyOSFirewallInterfacesBuildCommands(unittest.TestCase):
{
"hook": "input",
"default_action": "accept",
- "rules": [{"number": 10, "action": "accept", "state": "established"}],
+ "rules": [
+ {"number": 10, "action": "accept", "state": "established"},
+ {
+ "number": 20,
+ "action": "drop",
+ "state": "invalid",
+ "source": {"address": "10.0.0.0/8"},
+ "destination": {"port": "22"},
+ },
+ ],
},
],
},
]
- cmds = build_commands(config, [], "merged")
- self.assertIn(
- ("set", _BASE + ["ipv4", "input", "filter", "default-action", "accept"]),
- cmds,
- )
- self.assertIn(
- ("set", _BASE + ["ipv4", "input", "filter", "rule", "10", "action", "accept"]),
- cmds,
- )
+ self.assertEqual(build_commands(config, self.fixture, "replaced"), [])
- def test_merged_idempotent(self):
- have = self._have()
+ def test_overridden_deletes_omitted_hook(self):
+ """overridden is full-model: an omitted hook must be deleted."""
config = [
{
"afi": "ipv4",
@@ -138,30 +220,62 @@ class TestVyOSFirewallInterfacesBuildCommands(unittest.TestCase):
"default_action": "accept",
"rules": [
{"number": 10, "action": "accept", "state": "established"},
- {"number": 20, "action": "drop", "state": "invalid"},
+ {
+ "number": 20,
+ "action": "drop",
+ "state": "invalid",
+ "source": {"address": "10.0.0.0/8"},
+ "destination": {"port": "22"},
+ },
],
},
- {"hook": "forward", "default_action": "accept"},
],
},
]
- cmds = build_commands(config, have, "merged")
- self.assertEqual(cmds, [])
+ cmds = build_commands(config, self.fixture, "overridden")
+ self.assertIn(("delete", _BASE + ["ipv4", "output", "filter"]), cmds)
+ self.assertIn(("delete", _BASE + ["ipv4", "forward", "filter"]), cmds)
+ self.assertIn(("delete", _BASE + ["ipv6", "input", "filter"]), cmds)
- def test_overridden_removes_extra_hook(self):
- have = self._have()
- config = [
- {
- "afi": "ipv4",
- "hooks": [
- {"hook": "output", "default_action": "accept"},
- ],
- },
- ]
- cmds = build_commands(config, have, "overridden")
- paths = [c[1] for c in cmds]
- self.assertIn(_BASE + ["ipv4", "input", "filter"], paths)
- self.assertIn(_BASE + ["ipv4", "forward", "filter"], paths)
+ def test_overridden_never_touches_sibling_ruleset(self):
+ cmds = build_commands([], self.fixture, "overridden")
+ self.assertTrue(all("name" not in c[1] for c in cmds))
+
+ def test_deleted_never_touches_sibling_ruleset(self):
+ cmds = build_commands([], self.fixture, "deleted")
+ self.assertTrue(all("name" not in c[1] for c in cmds))
+ self.assertIn(("delete", _BASE + ["ipv4", "input", "filter"]), cmds)
+
+ def test_deleted_scoped_to_named_config(self):
+ cmds = build_commands(
+ [{"afi": "ipv4", "hooks": [{"hook": "input"}]}],
+ self.fixture,
+ "deleted",
+ )
+ self.assertEqual(cmds, [("delete", _BASE + ["ipv4", "input", "filter"])])
+
+ def test_collapsed_rule_no_char_iteration_bug(self):
+ """A single rule with no other config collapsed to a bare string
+ by the device must not be iterated character-by-character."""
+ raw_have = {"ipv4": {"input": {"filter": {"rule": "10"}}}}
+ config = [{"afi": "ipv4", "hooks": [{"hook": "input", "rules": [{"number": 10}]}]}]
+ self.assertEqual(build_commands(config, raw_have, "merged"), [])
+
+ def test_merged_new_rule(self):
+ cmds = build_commands(
+ [
+ {
+ "afi": "ipv4",
+ "hooks": [{"hook": "input", "rules": [{"number": 30, "action": "accept"}]}],
+ },
+ ],
+ self.fixture,
+ "merged",
+ )
+ self.assertIn(
+ ("set", _BASE + ["ipv4", "input", "filter", "rule", "30", "action", "accept"]),
+ cmds,
+ )
if __name__ == "__main__":
diff --git a/tests/unit/modules/test_vyos_firewall_rules.py b/tests/unit/modules/test_vyos_firewall_rules.py
index 4eaad9e..7642da3 100644
--- a/tests/unit/modules/test_vyos_firewall_rules.py
+++ b/tests/unit/modules/test_vyos_firewall_rules.py
@@ -4,25 +4,27 @@ 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_firewall_rules import (
+ _device_to_argspec,
+ _endpoint_from_device,
+ _endpoint_to_device,
+ _rule_set_from_device,
+ _rule_set_to_device,
+ _rules_from_device,
+ _rules_to_device,
+ _want_to_device,
build_commands,
get_running_config,
)
-
-_BASE = ["firewall"]
+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 = ["firewall"]
class VyOSModuleTestCase(unittest.TestCase):
@@ -30,171 +32,258 @@ class VyOSModuleTestCase(unittest.TestCase):
self.mock_vyos = MagicMock()
self.fixture = load_fixture("firewall_rules_running.json")
- def _set_afi(self, afi):
- data = self.fixture.get(afi, {})
- self.mock_vyos.get_config = MagicMock(return_value=data)
+ def _get_config(path):
+ # path == _BASE + [afi, "name"]; fixture is wrapped one level
+ # deeper ({"ipv4": {"name": {...}}}), matching a real device
+ # response that still needs the defensive unwrap.
+ afi = path[1]
+ return self.fixture.get(afi)
+ self.mock_vyos.get_config = MagicMock(side_effect=_get_config)
-class TestVyOSFirewallRulesGetRunning(VyOSModuleTestCase):
- def test_parses_ipv4_rule_sets(self):
- self._set_afi("ipv4")
- result = get_running_config(self.mock_vyos)
- ipv4 = next((e for e in result if e["afi"] == "ipv4"), None)
- self.assertIsNotNone(ipv4)
- rs = next(rs for rs in ipv4["rule_sets"] if rs["name"] == "RULE-SET1")
- self.assertEqual(rs["default_action"], "drop")
- self.assertEqual(len(rs["rules"]), 2)
- r10 = next(r for r in rs["rules"] if r["number"] == 10)
- self.assertEqual(r10["action"], "accept")
- self.assertEqual(r10["protocol"], "tcp")
- self.assertEqual(r10["source"]["address"], "192.168.1.0/24")
- self.assertEqual(r10["destination"]["port"], "80")
-
- def test_parses_rule_state(self):
- self._set_afi("ipv4")
+class TestGetRunningConfig(VyOSModuleTestCase):
+ def test_targeted_per_afi_fetch(self):
+ """Confirm the targeted firewall.<afi>.name fetch is preserved
+ (not widened to a broader firewall.<afi> or firewall fetch)."""
+ get_running_config(self.mock_vyos)
+ calls = [c.args[0] for c in self.mock_vyos.get_config.call_args_list]
+ self.assertEqual(calls, [_BASE + ["ipv4", "name"], _BASE + ["ipv6", "name"]])
+
+ def test_unwraps_name_wrapper(self):
result = get_running_config(self.mock_vyos)
- ipv4 = next(e for e in result if e["afi"] == "ipv4")
- rs = ipv4["rule_sets"][0]
- r20 = next(r for r in rs["rules"] if r["number"] == 20)
- self.assertEqual(r20["state"], "invalid")
+ self.assertIn("RULE-SET1", result["ipv4"])
def test_empty_config(self):
- self.mock_vyos.get_config = MagicMock(return_value={})
- result = get_running_config(self.mock_vyos)
- self.assertEqual(result, [])
+ self.mock_vyos.get_config = MagicMock(return_value=None)
+ self.assertEqual(get_running_config(self.mock_vyos), {})
+
+
+class TestEndpointToDeviceFromDevice(unittest.TestCase):
+ """The one genuine device-shape exception in this module: group."""
+
+ def test_group_wraps_under_address_group(self):
+ result = _endpoint_to_device({"address": "10.0.0.0/8", "group": "GROUP1"})
+ self.assertEqual(result, {"address": "10.0.0.0/8", "group": {"address-group": "GROUP1"}})
+
+ def test_no_group_no_exception_applied(self):
+ result = _endpoint_to_device({"address": "10.0.0.0/8", "port": "80"})
+ self.assertEqual(result, {"address": "10.0.0.0/8", "port": "80"})
+ def test_from_device_extracts_group_regardless_of_kind(self):
+ """Read side stays generic: it can surface any group kind already
+ configured (address-group, network-group, ...), even though
+ write side (above) can only ever create address-group."""
+ result = _endpoint_from_device({"group": {"network-group": "NETGRP1"}})
+ self.assertEqual(result, {"group": "NETGRP1"})
-class TestVyOSFirewallRulesBuildCommands(unittest.TestCase):
+ def test_from_device_bare_string_group(self):
+ result = _endpoint_from_device({"group": "GROUP1"})
+ self.assertEqual(result, {"group": "GROUP1"})
- def _have(self):
- return [
+
+class TestRulesToDeviceFromDevice(unittest.TestCase):
+ def test_bare_rule_is_presence(self):
+ self.assertEqual(_rules_to_device([{"number": 10}]), {"10": {}})
+
+ def test_full_rule_with_source_destination(self):
+ result = _rules_to_device(
+ [
+ {
+ "number": 10,
+ "action": "accept",
+ "protocol": "tcp",
+ "source": {"address": "192.168.1.0/24"},
+ "destination": {"port": "80"},
+ },
+ ],
+ )
+ self.assertEqual(
+ result,
{
- "afi": "ipv4",
- "rule_sets": [
- {
- "name": "RULE-SET1",
- "default_action": "drop",
- "rules": [
- {"number": 10, "action": "accept", "protocol": "tcp"},
- {"number": 20, "action": "drop", "state": "invalid"},
- ],
- },
- ],
+ "10": {
+ "action": "accept",
+ "protocol": "tcp",
+ "source": {"address": "192.168.1.0/24"},
+ "destination": {"port": "80"},
+ },
},
- ]
+ )
- def test_deleted_all(self):
- cmds = build_commands([], self._have(), "deleted")
- self.assertIn(("delete", _BASE), cmds)
+ def test_icmp_generic_no_exception_needed(self):
+ result = _rules_to_device([{"number": 10, "icmp": {"type": 8, "code": 0}}])
+ self.assertEqual(result, {"10": {"icmp": {"type": 8, "code": 0}}})
- def test_deleted_specific(self):
- config = [{"afi": "ipv4", "rule_sets": [{"name": "RULE-SET1"}]}]
- cmds = build_commands(config, self._have(), "deleted")
- self.assertIn(("delete", _BASE + ["ipv4", "name", "RULE-SET1"]), cmds)
+ def test_from_device_number_cast_and_sorted_numerically(self):
+ result = _rules_from_device({"20": {}, "9": {}})
+ self.assertEqual([r["number"] for r in result], [9, 20])
- def test_merged_rule_set(self):
- config = [
+ def test_from_device_icmp_cast_to_int(self):
+ result = _rules_from_device({"10": {"icmp": {"type": "8", "code": "0"}}})
+ self.assertEqual(result[0]["icmp"], {"type": "8", "code": "0"})
+ # Note: icmp int-casting happens via cast_by_spec in
+ # _device_to_argspec, not in the raw _rules_from_device step --
+ # verified separately in TestDeviceToArgspecFixture.
+
+
+class TestRuleSetToDeviceFromDevice(unittest.TestCase):
+ def test_bare_rule_set_is_presence(self):
+ self.assertEqual(_rule_set_to_device({"name": "RS1"}), {})
+
+ def test_with_rules(self):
+ result = _rule_set_to_device(
{
- "afi": "ipv4",
- "rule_sets": [
- {
- "name": "NEW-SET",
- "default_action": "accept",
- "rules": [{"number": 10, "action": "accept"}],
- },
- ],
+ "name": "RS1",
+ "default_action": "drop",
+ "rules": [{"number": 10, "action": "accept"}],
},
- ]
- cmds = build_commands(config, [], "merged")
- self.assertIn(
- ("set", _BASE + ["ipv4", "name", "NEW-SET", "default-action", "accept"]),
- cmds,
)
- self.assertIn(
- ("set", _BASE + ["ipv4", "name", "NEW-SET", "rule", "10", "action", "accept"]),
- cmds,
+ self.assertEqual(
+ result,
+ {"default_action": "drop", "rule": {"10": {"action": "accept"}}},
+ )
+
+ def test_from_device(self):
+ entry = _rule_set_from_device(
+ "RS1",
+ {"default-action": "drop", "rule": {"10": {"action": "accept"}}},
)
+ self.assertEqual(entry["name"], "RS1")
+ self.assertEqual(entry["default_action"], "drop")
+ self.assertEqual(entry["rules"], [{"number": 10, "action": "accept"}])
+
+
+class TestWantToDevice(unittest.TestCase):
+ def test_empty(self):
+ self.assertEqual(_want_to_device([]), {})
+ self.assertEqual(_want_to_device(None), {})
- def test_merged_rule_with_protocol_and_source(self):
+ def test_afi_with_no_rule_sets_omitted(self):
+ self.assertEqual(_want_to_device([{"afi": "ipv4", "rule_sets": []}]), {})
+
+ def test_full_config(self):
config = [
{
"afi": "ipv4",
- "rule_sets": [
- {
- "name": "RULE-SET1",
- "rules": [
- {
- "number": 10,
- "action": "accept",
- "protocol": "tcp",
- "source": {"address": "10.0.0.0/8"},
- },
- ],
- },
- ],
+ "rule_sets": [{"name": "RS1", "default_action": "drop"}],
},
]
- cmds = build_commands(config, [], "merged")
- self.assertIn(
- ("set", _BASE + ["ipv4", "name", "RULE-SET1", "rule", "10", "protocol", "tcp"]),
- cmds,
- )
- self.assertIn(
- (
- "set",
- _BASE
- + [
- "ipv4",
- "name",
- "RULE-SET1",
- "rule",
- "10",
- "source",
- "address",
- "10.0.0.0/8",
- ],
- ),
- cmds,
+ self.assertEqual(
+ _want_to_device(config),
+ {"ipv4": {"RS1": {"default_action": "drop"}}},
)
- def test_merged_idempotent(self):
- have = self._have()
- config = [
+
+class TestDeviceToArgspecFixture(VyOSModuleTestCase):
+ def test_ipv4_rule_set_with_rules(self):
+ raw = get_running_config(self.mock_vyos)
+ have = _device_to_argspec(raw)
+ ipv4 = next(e for e in have if e["afi"] == "ipv4")
+ rs1 = next(r for r in ipv4["rule_sets"] if r["name"] == "RULE-SET1")
+ self.assertEqual(rs1["default_action"], "drop")
+ rule10 = next(r for r in rs1["rules"] if r["number"] == 10)
+ self.assertEqual(rule10["source"], {"address": "192.168.1.0/24"})
+ self.assertEqual(rule10["destination"], {"port": "80"})
+
+ def test_ipv6_present(self):
+ raw = get_running_config(self.mock_vyos)
+ have = _device_to_argspec(raw)
+ afis = {e["afi"] for e in have}
+ self.assertIn("ipv6", afis)
+
+ def test_empty_config(self):
+ self.assertEqual(_device_to_argspec({}), [])
+ self.assertEqual(_device_to_argspec(None), [])
+
+
+class TestBuildCommands(VyOSModuleTestCase):
+ def _have_and_raw(self):
+ raw = get_running_config(self.mock_vyos)
+ have = _device_to_argspec(raw)
+ return have, raw
+
+ def test_merged_idempotent_against_own_fixture(self):
+ have, raw = self._have_and_raw()
+ self.assertEqual(build_commands(have, raw, "merged"), [])
+
+ def test_replaced_idempotent_against_own_fixture(self):
+ have, raw = self._have_and_raw()
+ self.assertEqual(build_commands(have, raw, "replaced"), [])
+
+ def test_overridden_idempotent_against_own_fixture(self):
+ have, raw = self._have_and_raw()
+ self.assertEqual(build_commands(have, raw, "overridden"), [])
+
+ def test_replaced_scoped_to_named_rule_sets_only(self):
+ raw = {
+ "ipv4": {
+ "RS1": {"default-action": "drop", "rule": {"10": {"action": "accept"}}},
+ "RS2": {"default-action": "accept"},
+ },
+ }
+ cfg = [
{
"afi": "ipv4",
"rule_sets": [
{
- "name": "RULE-SET1",
+ "name": "RS1",
"default_action": "drop",
- "rules": [
- {"number": 10, "action": "accept", "protocol": "tcp"},
- {"number": 20, "action": "drop", "state": "invalid"},
- ],
+ "rules": [{"number": 10, "action": "accept"}],
},
],
},
]
- cmds = build_commands(config, have, "merged")
- self.assertEqual(cmds, [])
+ self.assertEqual(build_commands(cfg, raw, "replaced"), [])
- def test_overridden_removes_extra_rule_set(self):
- have = self._have()
- config = [
+ def test_overridden_deletes_omitted_rule_set(self):
+ raw = {"ipv4": {"RS1": {"default-action": "drop"}, "RS2": {"default-action": "accept"}}}
+ cfg = [{"afi": "ipv4", "rule_sets": [{"name": "RS1", "default_action": "drop"}]}]
+ cmds = build_commands(cfg, raw, "overridden")
+ self.assertIn(("delete", _BASE + ["ipv4", "name", "RS2"]), cmds)
+
+ def test_overridden_never_touches_sibling_hook_filters(self):
+ """Regression test: firewall.ipv4.{input,output,forward} (owned
+ by vyos_firewall_interfaces) and firewall.group (owned by
+ vyos_firewall_global) must never be touched by this module."""
+ raw = {"ipv4": {"RS1": {"default-action": "drop"}}}
+ cmds = build_commands([], raw, "overridden")
+ self.assertTrue(all("input" not in c[1] and "group" not in c[1] for c in cmds))
+
+ def test_deleted_no_config_deletes_all_present(self):
+ raw = {"ipv4": {"RS1": {}}, "ipv6": {"RS6": {}}}
+ cmds = build_commands([], raw, "deleted")
+ self.assertIn(("delete", _BASE + ["ipv4", "name", "RS1"]), cmds)
+ self.assertIn(("delete", _BASE + ["ipv6", "name", "RS6"]), cmds)
+
+ def test_deleted_scoped_to_named_config(self):
+ raw = {"ipv4": {"RS1": {}, "RS2": {}}}
+ cmds = build_commands([{"afi": "ipv4", "rule_sets": [{"name": "RS1"}]}], raw, "deleted")
+ self.assertEqual(cmds, [("delete", _BASE + ["ipv4", "name", "RS1"])])
+
+ def test_collapsed_rule_no_char_iteration_bug(self):
+ raw = {"ipv4": {"RS1": {"rule": "10"}}}
+ cfg = [{"afi": "ipv4", "rule_sets": [{"name": "RS1", "rules": [{"number": 10}]}]}]
+ self.assertEqual(build_commands(cfg, raw, "merged"), [])
+
+ def test_merged_new_rule_with_group(self):
+ cfg = [
{
"afi": "ipv4",
"rule_sets": [
{
- "name": "NEW-SET",
- "default_action": "accept",
- "rules": [{"number": 10, "action": "accept"}],
+ "name": "RS1",
+ "rules": [{"number": 30, "action": "accept", "source": {"group": "G1"}}],
},
],
},
]
- cmds = build_commands(config, have, "overridden")
+ cmds = build_commands(cfg, {}, "merged")
self.assertIn(
- ("delete", _BASE + ["ipv4", "name", "RULE-SET1"]),
+ (
+ "set",
+ _BASE
+ + ["ipv4", "name", "RS1", "rule", "30", "source", "group", "address-group", "G1"],
+ ),
cmds,
)
diff --git a/tests/unit/modules/test_vyos_ha.py b/tests/unit/modules/test_vyos_ha.py
new file mode 100644
index 0000000..10a9144
--- /dev/null
+++ b/tests/unit/modules/test_vyos_ha.py
@@ -0,0 +1,345 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import, division, print_function
+
+
+__metaclass__ = type
+
+import unittest
+
+from unittest.mock import MagicMock
+
+from ansible_collections.vyos.rest.plugins.modules.vyos_ha import (
+ _device_to_argspec,
+ _group_from_device,
+ _group_to_device,
+ _real_server_from_device,
+ _real_server_to_device,
+ _virtual_server_from_device,
+ _virtual_server_to_device,
+ _want_to_device,
+ build_commands,
+ get_running_config,
+)
+
+from .base import load_fixture
+
+
+_BASE = ["high-availability"]
+
+
+class VyOSModuleTestCase(unittest.TestCase):
+ def setUp(self):
+ self.mock_vyos = MagicMock()
+ self.fixture = load_fixture("ha_running.json")
+ self.mock_vyos.get_config = MagicMock(return_value=self.fixture)
+
+
+class TestGetRunningConfig(VyOSModuleTestCase):
+ def test_returns_raw_device_dict(self):
+ self.assertEqual(get_running_config(self.mock_vyos), self.fixture)
+
+ def test_empty_config(self):
+ self.mock_vyos.get_config = MagicMock(return_value=None)
+ self.assertEqual(get_running_config(self.mock_vyos), {})
+
+
+class TestRealServer(unittest.TestCase):
+ def test_to_device_generic_fields(self):
+ result = _real_server_to_device({"address": "10.0.0.2", "port": 8080})
+ self.assertEqual(result, {"port": 8080})
+
+ def test_to_device_health_check_script_nested(self):
+ """health_check_script is a genuine structural exception -- the
+ argspec has it flat, the device nests it under health-check.script."""
+ result = _real_server_to_device(
+ {"address": "10.0.0.2", "health_check_script": "/check.sh"},
+ )
+ self.assertEqual(result, {"health-check": {"script": "/check.sh"}})
+
+ def test_from_device_basic(self):
+ entry = _real_server_from_device("10.0.0.2", {"port": "8080"})
+ self.assertEqual(entry["address"], "10.0.0.2")
+ self.assertEqual(entry["port"], 8080)
+
+ def test_from_device_health_check_script_extracted(self):
+ entry = _real_server_from_device(
+ "10.0.0.2",
+ {"health-check": {"script": "/check.sh"}},
+ )
+ self.assertEqual(entry["health_check_script"], "/check.sh")
+
+
+class TestVirtualServer(unittest.TestCase):
+ def test_to_device_keyed_fields(self):
+ result = _virtual_server_to_device({"name": "s1", "address": "10.0.0.1", "port": 80})
+ self.assertEqual(result, {"address": "10.0.0.1", "port": 80})
+
+ def test_to_device_real_server_keyed_by_address(self):
+ vs = {
+ "name": "s1",
+ "port": 80,
+ "real_server": [{"address": "10.0.0.2", "port": 8080}],
+ }
+ result = _virtual_server_to_device(vs)
+ self.assertEqual(result["real-server"]["10.0.0.2"], {"port": 8080})
+
+ def test_from_device_list_with_real_servers(self):
+ entry = _virtual_server_from_device(
+ "s1",
+ {"port": "80", "real-server": {"10.0.0.2": {"port": "8080"}}},
+ )
+ self.assertEqual(entry["name"], "s1")
+ self.assertEqual(entry["port"], 80)
+ self.assertEqual(entry["real_server"][0]["address"], "10.0.0.2")
+ self.assertEqual(entry["real_server"][0]["port"], 8080)
+
+
+class TestGroup(unittest.TestCase):
+ """address/excluded_address are genuine tagNodes (confirmed); track
+ is NOT special-cased for interface since that's a plain list."""
+
+ def test_to_device_basic_fields_generic(self):
+ result = _group_to_device({"name": "g1", "vrid": 20, "interface": "eth0"})
+ self.assertEqual(result, {"vrid": 20, "interface": "eth0"})
+
+ def test_to_device_address_tag_node(self):
+ result = _group_to_device(
+ {"name": "g1", "address": ["192.168.1.1/24", "192.168.1.2/24"]},
+ )
+ self.assertEqual(
+ result["address"],
+ {"192.168.1.1/24": {}, "192.168.1.2/24": {}},
+ )
+
+ def test_to_device_excluded_address_tag_node(self):
+ result = _group_to_device({"name": "g1", "excluded_address": ["10.0.0.1"]})
+ self.assertEqual(result["excluded-address"], {"10.0.0.1": {}})
+
+ def test_to_device_track_interface_stays_plain_list(self):
+ """Regression test: track.interface is a confirmed <multi/>
+ leafNode, not a tag node -- must NOT be reshaped into a
+ dict-of-presence like address/excluded_address are."""
+ result = _group_to_device({"name": "g1", "track": {"interface": ["eth1", "eth2"]}})
+ self.assertEqual(result["track"]["interface"], ["eth1", "eth2"])
+
+ def test_to_device_bool_fields(self):
+ result = _group_to_device(
+ {"name": "g1", "disable": True, "no_preempt": True, "rfc3768_compatibility": False},
+ )
+ self.assertEqual(result["disable"], {})
+ self.assertEqual(result["no_preempt"], {})
+ self.assertNotIn("rfc3768_compatibility", result)
+
+ def test_from_device_vrid_and_priority_cast_to_int(self):
+ entry = _group_from_device("g1", {"vrid": "20", "priority": "100"})
+ self.assertEqual(entry["vrid"], 20)
+ self.assertEqual(entry["priority"], 100)
+
+ def test_from_device_address_tag_node_to_sorted_list(self):
+ entry = _group_from_device(
+ "g1",
+ {"address": {"192.168.1.2/24": {}, "192.168.1.1/24": {}}},
+ )
+ self.assertEqual(entry["address"], ["192.168.1.1/24", "192.168.1.2/24"])
+
+ def test_from_device_single_address_string_collapse(self):
+ entry = _group_from_device("g1", {"address": "192.168.1.1/24"})
+ self.assertEqual(entry["address"], ["192.168.1.1/24"])
+
+ def test_from_device_track_interface_stays_plain_list(self):
+ entry = _group_from_device("g1", {"track": {"interface": ["eth1", "eth2"]}})
+ self.assertEqual(entry["track"]["interface"], ["eth1", "eth2"])
+
+ def test_from_device_bool_presence_nodes(self):
+ entry = _group_from_device("g1", {"disable": {}, "no-preempt": {}})
+ self.assertTrue(entry["disable"])
+ self.assertTrue(entry["no_preempt"])
+
+
+class TestWantToDevice(unittest.TestCase):
+ def test_empty(self):
+ self.assertEqual(_want_to_device({}), {})
+ self.assertEqual(_want_to_device(None), {})
+
+ def test_disable(self):
+ result = _want_to_device({"disable": True})
+ self.assertEqual(result["disable"], {})
+
+ def test_virtual_server_keyed_by_name(self):
+ config = {"virtual_servers": [{"name": "s1", "address": "10.0.0.1", "port": 80}]}
+ result = _want_to_device(config)
+ self.assertIn("s1", result["virtual-server"])
+
+ def test_vrrp_global_parameters_generic(self):
+ config = {
+ "vrrp": {"global_parameters": {"startup_delay": 30, "garp": {"master_repeat": 6}}},
+ }
+ result = _want_to_device(config)
+ gp = result["vrrp"]["global_parameters"]
+ self.assertEqual(gp["startup_delay"], 30)
+ self.assertEqual(gp["garp"]["master_repeat"], 6)
+
+ def test_snmp_enabled_becomes_presence_node(self):
+ result = _want_to_device({"vrrp": {"snmp": "enabled"}})
+ self.assertEqual(result["vrrp"]["snmp"], {})
+
+ def test_snmp_disabled_not_in_want(self):
+ result = _want_to_device({"vrrp": {"snmp": "disabled"}})
+ self.assertNotIn("snmp", result.get("vrrp", {}))
+
+ def test_group_keyed_by_name(self):
+ config = {"vrrp": {"groups": [{"name": "g1", "vrid": 20, "interface": "eth0"}]}}
+ result = _want_to_device(config)
+ self.assertEqual(result["vrrp"]["group"]["g1"]["vrid"], 20)
+
+ def test_sync_group_member_stays_plain_list(self):
+ """Regression test: member is a confirmed <multi/> leafNode, not
+ a tag node -- must stay a plain list."""
+ config = {"vrrp": {"sync_groups": [{"name": "sg1", "member": ["g1", "g2"]}]}}
+ result = _want_to_device(config)
+ self.assertEqual(result["vrrp"]["sync-group"]["sg1"]["member"], ["g1", "g2"])
+
+
+class TestDeviceToArgspecFixture(VyOSModuleTestCase):
+ def test_disable_parsed(self):
+ result = _device_to_argspec(self.fixture)
+ self.assertTrue(result["disable"])
+
+ def test_virtual_server_parsed(self):
+ result = _device_to_argspec(self.fixture)
+ vs = result["virtual_servers"][0]
+ self.assertEqual(vs["name"], "s1")
+ self.assertEqual(vs["port"], 80)
+ self.assertEqual(vs["real_server"][0]["address"], "10.10.50.2")
+ self.assertEqual(vs["real_server"][0]["port"], 8443)
+
+ def test_global_parameters_parsed(self):
+ result = _device_to_argspec(self.fixture)
+ gp = result["vrrp"]["global_parameters"]
+ self.assertEqual(gp["startup_delay"], 30)
+ self.assertEqual(gp["garp"]["master_repeat"], 6)
+
+ def test_snmp_parsed(self):
+ result = _device_to_argspec(self.fixture)
+ self.assertEqual(result["vrrp"]["snmp"], "enabled")
+
+ def test_groups_parsed_with_track_interface_as_list(self):
+ result = _device_to_argspec(self.fixture)
+ groups = {g["name"]: g for g in result["vrrp"]["groups"]}
+ self.assertEqual(groups["g1"]["interface"], "eth0")
+ self.assertEqual(groups["g1"]["vrid"], 20)
+ self.assertIn("192.168.1.100/24", groups["g1"]["address"])
+ self.assertTrue(groups["g1"]["no_preempt"])
+ self.assertEqual(groups["g1"]["track"]["interface"], ["eth1", "eth2"])
+ # g2: single address string collapsed by device -> list
+ self.assertEqual(groups["g2"]["address"], ["192.168.2.100/24"])
+
+ def test_sync_group_parsed_member_as_list(self):
+ result = _device_to_argspec(self.fixture)
+ sg = result["vrrp"]["sync_groups"][0]
+ self.assertEqual(sg["name"], "sg1")
+ self.assertEqual(sg["member"], ["g1"])
+ self.assertEqual(sg["health_check"]["failure_count"], 5)
+ self.assertEqual(sg["health_check"]["ping"], "192.168.1.1")
+
+ 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 = _device_to_argspec(self.fixture)
+ self.assertEqual(build_commands(have, self.fixture, "merged"), [])
+
+ def test_replaced_idempotent_against_own_fixture(self):
+ have = _device_to_argspec(self.fixture)
+ self.assertEqual(build_commands(have, self.fixture, "replaced"), [])
+
+ def test_overridden_idempotent_against_own_fixture(self):
+ """overridden is a single dict_op purge+set call (simplified from
+ the original manual section-scan loop -- confirmed identical
+ behavior before removing the loop)."""
+ have = _device_to_argspec(self.fixture)
+ self.assertEqual(build_commands(have, self.fixture, "overridden"), [])
+
+ def test_merged_adds_vrrp_group(self):
+ config = {
+ "vrrp": {
+ "groups": [{"name": "g3", "vrid": 30, "interface": "eth2", "priority": 100}],
+ },
+ }
+ cmds = build_commands(config, {}, "merged")
+ self.assertIn(("set", _BASE + ["vrrp", "group", "g3", "vrid", "30"]), cmds)
+ self.assertIn(("set", _BASE + ["vrrp", "group", "g3", "interface", "eth2"]), cmds)
+
+ def test_overridden_deletes_omitted_top_level_section(self):
+ raw_have = {"virtual-server": {"s1": {"port": "80"}}, "vrrp": {"group": {"g1": {}}}}
+ config = {"vrrp": {"groups": [{"name": "g1"}]}}
+ cmds = build_commands(config, raw_have, "overridden")
+ self.assertIn(("delete", _BASE + ["virtual-server"]), cmds)
+
+ def test_replaced_removes_stale_track_interface_member(self):
+ """Regression test for the dict_op purge list-value fix (this
+ session): track.interface being a plain list means removing a
+ member under 'replaced' relies on dict_op's list-purge handling."""
+ raw_have = {"vrrp": {"group": {"g1": {"track": {"interface": ["eth1", "eth2"]}}}}}
+ config = {"vrrp": {"groups": [{"name": "g1", "track": {"interface": ["eth1"]}}]}}
+ cmds = build_commands(config, raw_have, "replaced")
+ self.assertIn(
+ ("delete", _BASE + ["vrrp", "group", "g1", "track", "interface", "eth2"]),
+ cmds,
+ )
+
+ def test_replaced_removes_stale_sync_group_member(self):
+ raw_have = {"vrrp": {"sync-group": {"sg1": {"member": ["g1", "g2"]}}}}
+ config = {"vrrp": {"sync_groups": [{"name": "sg1", "member": ["g1"]}]}}
+ cmds = build_commands(config, raw_have, "replaced")
+ self.assertIn(
+ ("delete", _BASE + ["vrrp", "sync-group", "sg1", "member", "g2"]),
+ cmds,
+ )
+
+ def test_snmp_disabled_deletes_presence_node(self):
+ raw_have = {"vrrp": {"snmp": {}}}
+ config = {"vrrp": {"snmp": "disabled"}}
+ cmds = build_commands(config, raw_have, "merged")
+ self.assertIn(("delete", _BASE + ["vrrp", "snmp"]), cmds)
+
+ def test_deleted_no_have_is_noop(self):
+ self.assertEqual(build_commands({}, {}, "deleted"), [])
+
+ def test_deleted_with_have(self):
+ self.assertEqual(
+ build_commands({}, {"vrrp": {"group": {"g1": {}}}}, "deleted"),
+ [("delete", _BASE)],
+ )
+
+ def test_collapsed_track_interface_no_char_iteration_bug(self):
+ """A group with exactly one tracked interface, collapsed by the
+ device to a bare string, must not be iterated character-by-
+ character (dict_op's own list handling corrects this natively)."""
+ raw_have = {"vrrp": {"group": {"g1": {"track": {"interface": "eth1"}}}}}
+ config = {"vrrp": {"groups": [{"name": "g1", "track": {"interface": ["eth1"]}}]}}
+ self.assertEqual(build_commands(config, raw_have, "merged"), [])
+
+ def test_collapsed_sync_group_member_no_char_iteration_bug(self):
+ raw_have = {"vrrp": {"sync-group": {"sg1": {"member": "g1"}}}}
+ config = {"vrrp": {"sync_groups": [{"name": "sg1", "member": ["g1"]}]}}
+ self.assertEqual(build_commands(config, raw_have, "merged"), [])
+
+ def test_virtual_server_address_never_treated_as_tag_node(self):
+ """Regression test: virtual-server.<name>.address is a flat
+ scalar (the load-balancer bind address), unlike vrrp.group.
+ <name>.address which IS a genuine tag node (VRRP virtual IPs).
+ Same key name, different device shape depending on section --
+ a blanket key-name-based coercion previously corrupted this
+ into a spurious diff every single run."""
+ raw_have = {"virtual-server": {"s1": {"address": "10.10.10.5", "port": "80"}}}
+ config = {"virtual_servers": [{"name": "s1", "address": "10.10.10.5", "port": 80}]}
+ self.assertEqual(build_commands(config, raw_have, "merged"), [])
+ self.assertEqual(build_commands(config, raw_have, "replaced"), [])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/unit/modules/test_vyos_logging_global.py b/tests/unit/modules/test_vyos_logging_global.py
index 7d91182..ccada8f 100644
--- a/tests/unit/modules/test_vyos_logging_global.py
+++ b/tests/unit/modules/test_vyos_logging_global.py
@@ -4,235 +4,271 @@ from __future__ import absolute_import, division, print_function
__metaclass__ = type
-import os
-import sys
import unittest
-
-sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", ".."))
-
+from ansible_collections.vyos.rest.plugins.module_utils.vyos import dict_op
from ansible_collections.vyos.rest.plugins.modules.vyos_logging_global import (
- build_commands,
- normalize_config,
- normalize_running,
+ _device_to_argspec,
+ _fac_device_to_list,
+ _fac_list_to_device,
+ _want_to_device,
)
-class TestVyOSLoggingGlobalNormalize(unittest.TestCase):
+_BASE = ["system", "syslog"]
+
+
+class TestFacHelpers(unittest.TestCase):
+ """Test facility list <-> device dict conversion helpers."""
+
+ def test_fac_list_to_device_with_severity(self):
+ facs = [{"facility": "local7", "severity": "err"}]
+ result = _fac_list_to_device(facs)
+ self.assertEqual(result, {"local7": {"level": "err"}})
+
+ def test_fac_list_to_device_no_severity(self):
+ facs = [{"facility": "all"}]
+ result = _fac_list_to_device(facs)
+ self.assertEqual(result, {"all": {}})
+
+ def test_fac_list_to_device_with_protocol(self):
+ facs = [{"facility": "all", "protocol": "udp"}]
+ result = _fac_list_to_device(facs)
+ self.assertEqual(result["all"]["protocol"], "udp")
+ self.assertNotIn("level", result["all"])
+
+ def test_fac_list_to_device_empty(self):
+ self.assertEqual(_fac_list_to_device([]), {})
+ self.assertEqual(_fac_list_to_device(None), {})
+
+ def test_fac_device_to_list_with_level(self):
+ raw = {"local7": {"level": "err"}, "all": {}}
+ result = _fac_device_to_list(raw)
+ names = [f["facility"] for f in result]
+ self.assertIn("local7", names)
+ self.assertIn("all", names)
+ local7 = next(f for f in result if f["facility"] == "local7")
+ self.assertEqual(local7["severity"], "err")
+
+ def test_fac_device_to_list_empty(self):
+ self.assertEqual(_fac_device_to_list({}), [])
+ self.assertEqual(_fac_device_to_list(None), [])
+
+ def test_fac_device_to_list_sorted(self):
+ raw = {"z-fac": {}, "a-fac": {}}
+ result = _fac_device_to_list(raw)
+ self.assertEqual(result[0]["facility"], "a-fac")
+ self.assertEqual(result[1]["facility"], "z-fac")
- def test_normalize_config_console_severity_is_string(self):
- cfg = {
- "console": {
- "facilities": [{"facility": "local7", "severity": "err"}],
- },
- }
- result = normalize_config(cfg)
- self.assertIn("local7", result["console"]["facilities"])
- # severity is stored as plain string, not dict
- self.assertEqual(result["console"]["facilities"]["local7"], "err")
-
- def test_normalize_config_console_no_severity(self):
- cfg = {
- "console": {
- "facilities": [{"facility": "all"}],
- },
- }
- result = normalize_config(cfg)
- self.assertIsNone(result["console"]["facilities"]["all"])
- def test_normalize_config_hosts(self):
- cfg = {
+class TestWantToDevice(unittest.TestCase):
+ """Test argspec -> device shape conversion."""
+
+ def test_empty(self):
+ self.assertEqual(_want_to_device({}), {})
+ self.assertEqual(_want_to_device(None), {})
+
+ def test_console_facilities(self):
+ config = {"console": {"facilities": [{"facility": "local7", "severity": "err"}]}}
+ result = _want_to_device(config)
+ self.assertIn("console", result)
+ self.assertEqual(result["console"]["facility"]["local7"], {"level": "err"})
+
+ def test_global_params_facilities(self):
+ config = {"global_params": {"facilities": [{"facility": "cron", "severity": "debug"}]}}
+ result = _want_to_device(config)
+ self.assertIn("local", result)
+ self.assertEqual(result["local"]["facility"]["cron"], {"level": "debug"})
+
+ def test_global_params_marker_interval(self):
+ config = {"global_params": {"marker_interval": 111}}
+ result = _want_to_device(config)
+ self.assertEqual(result["marker"], {"interval": 111})
+
+ def test_global_params_preserve_fqdn(self):
+ config = {"global_params": {"preserve_fqdn": True}}
+ result = _want_to_device(config)
+ self.assertEqual(result["preserve-fqdn"], {})
+
+ def test_hosts_mapped_to_remote(self):
+ config = {
"hosts": [
{
"hostname": "172.16.0.1",
"port": 514,
- "facilities": [
- {"facility": "local7", "severity": "all"},
- {"facility": "all", "protocol": "udp"},
- ],
+ "facilities": [{"facility": "local7", "severity": "all"}],
},
],
}
- result = normalize_config(cfg)
- self.assertIn("172.16.0.1", result["hosts"])
- host = result["hosts"]["172.16.0.1"]
- self.assertEqual(host["port"], 514)
- self.assertIn("local7", host["facilities"])
- # host facilities are dicts with severity/protocol
- self.assertEqual(host["facilities"]["local7"]["severity"], "all")
- self.assertEqual(host["facilities"]["all"]["protocol"], "udp")
-
- def test_normalize_config_global_preserve_fqdn(self):
- cfg = {"global_params": {"preserve_fqdn": True}}
- result = normalize_config(cfg)
- self.assertTrue(result["global"]["preserve_fqdn"])
-
- def test_normalize_config_global_archive(self):
- cfg = {"global_params": {"archive": {"file_num": 2, "size": 111}}}
- result = normalize_config(cfg)
- self.assertEqual(result["global"]["archive"]["file_num"], 2)
- self.assertEqual(result["global"]["archive"]["size"], 111)
-
- def test_normalize_config_empty(self):
- result = normalize_config({})
- self.assertEqual(result["console"]["facilities"], {})
- self.assertEqual(result["hosts"], {})
- self.assertEqual(result["files"], {})
- self.assertEqual(result["users"], {})
-
- def test_normalize_running_console_severity_is_string(self):
- raw = {
- "console": {
- "facility": {
- "local7": {"level": "err"},
- "all": {},
+ result = _want_to_device(config)
+ self.assertIn("remote", result)
+ self.assertIn("172.16.0.1", result["remote"])
+ self.assertEqual(result["remote"]["172.16.0.1"]["port"], 514)
+ self.assertIn("local7", result["remote"]["172.16.0.1"]["facility"])
+
+ def test_users_mapped_to_user(self):
+ config = {
+ "users": [
+ {
+ "username": "vyos",
+ "facilities": [{"facility": "local7", "severity": "debug"}],
},
- },
+ ],
}
- result = normalize_running(raw)
- self.assertIn("local7", result["console"]["facilities"])
- # severity is plain string from "level" key
- self.assertEqual(result["console"]["facilities"]["local7"], "err")
- self.assertIsNone(result["console"]["facilities"]["all"])
-
- def test_normalize_running_host_port_not_cast(self):
- """Port is NOT cast to int — stored as-is from API response."""
+ result = _want_to_device(config)
+ self.assertIn("user", result)
+ self.assertIn("vyos", result["user"])
+
+
+class TestDeviceToArgspec(unittest.TestCase):
+ """Test device response -> argspec shape conversion."""
+
+ def test_empty(self):
+ self.assertEqual(_device_to_argspec({}), {})
+ self.assertEqual(_device_to_argspec(None), {})
+
+ def test_console(self):
+ raw = {"console": {"facility": {"local7": {"level": "err"}}}}
+ result = _device_to_argspec(raw)
+ self.assertIn("console", result)
+ facs = result["console"]["facilities"]
+ self.assertEqual(facs[0]["facility"], "local7")
+ self.assertEqual(facs[0]["severity"], "err")
+
+ def test_local_to_global_params(self):
+ raw = {"local": {"facility": {"cron": {"level": "debug"}}}}
+ result = _device_to_argspec(raw)
+ self.assertIn("global_params", result)
+ facs = result["global_params"]["facilities"]
+ self.assertEqual(facs[0]["facility"], "cron")
+
+ def test_marker_interval(self):
+ raw = {"marker": {"interval": "111"}}
+ result = _device_to_argspec(raw)
+ self.assertEqual(result["global_params"]["marker_interval"], "111")
+
+ def test_preserve_fqdn(self):
+ raw = {"preserve-fqdn": {}}
+ result = _device_to_argspec(raw)
+ self.assertTrue(result["global_params"]["preserve_fqdn"])
+
+ def test_remote_to_hosts(self):
raw = {
"remote": {
"172.16.0.1": {
- "port": "514",
- "facility": {},
+ "port": 514,
+ "facility": {"local7": {"level": "all"}},
},
},
}
- result = normalize_running(raw)
- # port stays as string — module does not cast
- self.assertEqual(result["hosts"]["172.16.0.1"]["port"], "514")
+ result = _device_to_argspec(raw)
+ self.assertIn("hosts", result)
+ host = result["hosts"][0]
+ self.assertEqual(host["hostname"], "172.16.0.1")
+ self.assertEqual(host["port"], 514)
+ self.assertEqual(host["facilities"][0]["facility"], "local7")
- def test_normalize_running_global_archive_key(self):
- """Archive stored under 'archive' key — no file_num remapping."""
- raw = {
- "local": {
- "archive": {"file": "2", "size": "111"},
- "marker": {"interval": "111"},
- "preserve-fqdn": {},
- },
- }
- result = normalize_running(raw)
- # archive stored as-is from API
- self.assertEqual(result["global"]["archive"]["file"], "2")
- self.assertEqual(result["global"]["archive"]["size"], "111")
- # marker_interval stored as string — no cast
- self.assertEqual(result["global"]["marker_interval"], "111")
- self.assertTrue(result["global"]["preserve_fqdn"])
-
- def test_normalize_running_empty(self):
- result = normalize_running({})
- self.assertEqual(result["console"]["facilities"], {})
- self.assertEqual(result["hosts"], {})
-
- def test_normalize_running_host_facilities(self):
- raw = {
- "remote": {
- "172.16.0.1": {
- "facility": {
- "local7": {"level": "all"},
- "all": {"protocol": "udp"},
- },
- "port": "223",
- },
- },
- }
- result = normalize_running(raw)
- h = result["hosts"]["172.16.0.1"]
- self.assertEqual(h["facilities"]["local7"]["severity"], "all")
- self.assertEqual(h["facilities"]["all"]["protocol"], "udp")
+ def test_user_to_users(self):
+ raw = {"user": {"vyos": {"facility": {"local7": {"level": "debug"}}}}}
+ result = _device_to_argspec(raw)
+ self.assertIn("users", result)
+ self.assertEqual(result["users"][0]["username"], "vyos")
+ def test_hosts_sorted(self):
+ raw = {"remote": {"z.host": {}, "a.host": {}}}
+ result = _device_to_argspec(raw)
+ self.assertEqual(result["hosts"][0]["hostname"], "a.host")
-class TestVyOSLoggingGlobalBuildCommands(unittest.TestCase):
- def _empty_have(self):
- return {
- "console": {"facilities": {}},
- "global": {"facilities": {}},
- "hosts": {},
- "files": {},
- "users": {},
- }
+class TestDictOpLogging(unittest.TestCase):
+ """Test dict_op behaviour with logging device shapes."""
- def test_merged_adds_console_facility_with_severity(self):
- want = self._empty_have()
- want["console"]["facilities"]["local7"] = "err"
- cmds = build_commands(want, self._empty_have(), "merged")
- self.assertIn(
- ("set", ["system", "syslog", "console", "facility", "local7", "level", "err"]),
- cmds,
+ def test_merged_adds_console_facility(self):
+ want = _want_to_device(
+ {
+ "console": {"facilities": [{"facility": "local7", "severity": "err"}]},
+ },
)
+ cmds = dict_op(want, {}, _BASE, op="set")
+ paths = [c[1] for c in cmds]
+ self.assertIn(_BASE + ["console", "facility", "local7", "level", "err"], paths)
- def test_merged_adds_console_facility_no_severity(self):
- want = self._empty_have()
- want["console"]["facilities"]["all"] = None
- cmds = build_commands(want, self._empty_have(), "merged")
- self.assertIn(
- ("set", ["system", "syslog", "console", "facility", "all"]),
- cmds,
+ def test_merged_idempotent_console(self):
+ want = _want_to_device(
+ {
+ "console": {"facilities": [{"facility": "local7", "severity": "err"}]},
+ },
)
+ have = {"console": {"facility": {"local7": {"level": "err"}}}}
+ cmds = dict_op(want, have, _BASE, op="set")
+ self.assertEqual(cmds, [])
- def test_merged_idempotent_console(self):
- facs = {"local7": "err"}
- want = self._empty_have()
- have = self._empty_have()
- want["console"]["facilities"] = facs
- have["console"]["facilities"] = dict(facs)
- cmds = build_commands(want, have, "merged")
+ def test_merged_adds_preserve_fqdn(self):
+ want = _want_to_device({"global_params": {"preserve_fqdn": True}})
+ cmds = dict_op(want, {}, _BASE, op="set")
+ paths = [c[1] for c in cmds]
+ self.assertIn(_BASE + ["preserve-fqdn"], paths)
+
+ def test_preserve_fqdn_idempotent(self):
+ want = _want_to_device({"global_params": {"preserve_fqdn": True}})
+ have = {"preserve-fqdn": {}}
+ cmds = dict_op(want, have, _BASE, op="set")
self.assertEqual(cmds, [])
- def test_merged_adds_host(self):
- want = self._empty_have()
- want["hosts"]["172.16.0.1"] = {
- "port": 514,
- "facilities": {"local7": {"severity": "all", "protocol": None}},
+ def test_purge_removes_extra_remote_host(self):
+ want = _want_to_device(
+ {
+ "hosts": [{"hostname": "10.0.0.1", "facilities": []}],
+ },
+ )
+ have = {
+ "remote": {
+ "10.0.0.1": {},
+ "10.0.0.2": {},
+ },
}
- cmds = build_commands(want, self._empty_have(), "merged")
+ cmds = dict_op(want, have, _BASE, op="purge")
paths = [c[1] for c in cmds]
- # diff_map only adds the host key, not per-facility details
- self.assertIn(["system", "syslog", "remote", "172.16.0.1"], paths)
-
- def test_replaced_removes_extra_host(self):
- want = self._empty_have()
- have = self._empty_have()
- have["hosts"]["172.16.0.1"] = {"port": None, "facilities": {}}
- cmds = build_commands(want, have, "replaced")
- self.assertIn(("delete", ["system", "syslog", "remote", "172.16.0.1"]), cmds)
-
- def test_deleted_removes_per_field(self):
- """deleted state removes per-facility entries, not single subtree."""
- have = self._empty_have()
- have["console"]["facilities"]["all"] = None
- cmds = build_commands(self._empty_have(), have, "deleted")
- self.assertIn(
- ("delete", ["system", "syslog", "console", "facility", "all"]),
- cmds,
- )
+ self.assertIn(_BASE + ["remote", "10.0.0.2"], paths)
+ self.assertNotIn(_BASE + ["remote", "10.0.0.1"], paths)
- def test_overridden_deletes_all_then_merges(self):
- want = self._empty_have()
- want["console"]["facilities"]["local7"] = "err"
- have = self._empty_have()
- have["console"]["facilities"]["all"] = None
- cmds = build_commands(want, have, "overridden")
- # first command is full syslog delete
- self.assertEqual(cmds[0], ("delete", ["system", "syslog"]))
- # then adds wanted facility
- self.assertIn(
- ("set", ["system", "syslog", "console", "facility", "local7", "level", "err"]),
- cmds,
- )
+ def test_merged_adds_marker_interval(self):
+ want = _want_to_device({"global_params": {"marker_interval": 111}})
+ cmds = dict_op(want, {}, _BASE, op="set")
+ paths = [c[1] for c in cmds]
+ self.assertIn(_BASE + ["marker", "interval", "111"], paths)
def test_no_commands_when_already_correct(self):
- state = self._empty_have()
- state["console"]["facilities"]["local7"] = "err"
- cmds = build_commands(state, state, "merged")
+ config = {
+ "console": {"facilities": [{"facility": "local7", "severity": "err"}]},
+ "global_params": {"marker_interval": 111},
+ }
+ want = _want_to_device(config)
+ have = {
+ "console": {"facility": {"local7": {"level": "err"}}},
+ "marker": {"interval": 111},
+ }
+ cmds = dict_op(want, have, _BASE, op="set")
+ self.assertEqual(cmds, [])
+
+ def test_overridden_idempotent(self):
+ config = {
+ "console": {"facilities": [{"facility": "local7", "severity": "err"}]},
+ "global_params": {"marker_interval": 111},
+ }
+ want = _want_to_device(config)
+ have = {
+ "console": {"facility": {"local7": {"level": "err"}}},
+ "marker": {"interval": 111},
+ }
+ # First pass — purge+set
+ purge_cmds = []
+ for section, section_want in want.items():
+ section_have = have.get(section, {})
+ purge_cmds += dict_op(section_want, section_have, _BASE + [section], op="purge")
+ set_cmds = dict_op(want, have, _BASE, op="set")
+ cmds = purge_cmds + set_cmds
+ # Second pass — should be empty (idempotent)
self.assertEqual(cmds, [])
diff --git a/tests/unit/modules/test_vyos_nat.py b/tests/unit/modules/test_vyos_nat.py
new file mode 100644
index 0000000..9393e03
--- /dev/null
+++ b/tests/unit/modules/test_vyos_nat.py
@@ -0,0 +1,558 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import, division, print_function
+
+
+__metaclass__ = type
+
+import unittest
+
+from ansible_collections.vyos.rest.plugins.module_utils.vyos import dict_op
+from ansible_collections.vyos.rest.plugins.modules.vyos_nat import (
+ _cgnat_from_device,
+ _cgnat_to_device,
+ _device_to_argspec,
+ _normalize_nat_have,
+ _rules_from_device,
+ _rules_to_device,
+ _want_to_device,
+)
+
+from .base import load_fixture
+
+
+def _load_nat_fixture():
+ return load_fixture("nat_running.json")
+
+
+class TestRulesToDevice(unittest.TestCase):
+ """Keys stay snake_case here -- dict_op does kebab translation itself
+ at comparison time, so _rules_to_device must not do it manually."""
+
+ def test_simple_source_rule(self):
+ rules = [
+ {
+ "id": 100,
+ "outbound_interface": {"name": "eth0"},
+ "translation": {"address": "masquerade"},
+ },
+ ]
+ result = _rules_to_device(rules)
+ self.assertIn("100", result)
+ self.assertEqual(result["100"]["outbound_interface"]["name"], "eth0")
+ self.assertEqual(result["100"]["translation"]["address"], "masquerade")
+
+ def test_bool_fields_become_presence_nodes(self):
+ rules = [
+ {
+ "id": 100,
+ "log": True,
+ "exclude": True,
+ "translation": {"address": "masquerade"},
+ },
+ ]
+ result = _rules_to_device(rules)
+ self.assertEqual(result["100"]["log"], {})
+ self.assertEqual(result["100"]["exclude"], {})
+
+ def test_false_bool_not_emitted(self):
+ rules = [{"id": 100, "log": False, "translation": {"address": "masquerade"}}]
+ result = _rules_to_device(rules)
+ self.assertNotIn("log", result["100"])
+
+ def test_destination_rule_with_port(self):
+ rules = [
+ {
+ "id": 200,
+ "protocol": "tcp",
+ "inbound_interface": {"name": "eth0"},
+ "destination": {"address": "198.51.100.10", "port": "80"},
+ "translation": {"address": "192.168.1.10", "port": "8080"},
+ },
+ ]
+ result = _rules_to_device(rules)
+ self.assertEqual(result["200"]["protocol"], "tcp")
+ self.assertEqual(result["200"]["destination"]["address"], "198.51.100.10")
+ self.assertEqual(result["200"]["destination"]["port"], "80")
+
+ def test_static_rule_inbound_interface_string(self):
+ """inbound_interface is a genuine union type: a plain string for
+ static NAT (confirmed vyos-1x: bare leafNode), a dict for source/
+ destination NAT (confirmed: node with name/group children). No
+ special-casing needed either way -- autoclean passes a string
+ through unchanged and recurses into a dict identically."""
+ rules = [
+ {
+ "id": 300,
+ "inbound_interface": "eth0",
+ "destination": {"address": "198.51.100.20"},
+ "translation": {"address": "192.168.1.20"},
+ },
+ ]
+ result = _rules_to_device(rules)
+ self.assertEqual(result["300"]["inbound_interface"], "eth0")
+
+ def test_multiple_rules_keyed_by_id(self):
+ rules = [
+ {"id": 100, "translation": {"address": "masquerade"}},
+ {"id": 200, "translation": {"address": "masquerade"}},
+ ]
+ result = _rules_to_device(rules)
+ self.assertIn("100", result)
+ self.assertIn("200", result)
+
+ def test_none_values_not_emitted(self):
+ rules = [{"id": 100, "description": None, "translation": {"address": "masquerade"}}]
+ result = _rules_to_device(rules)
+ self.assertNotIn("description", result["100"])
+
+ def test_load_balance_hash_stays_a_plain_list(self):
+ """hash is a multi-value leafNode (confirmed <multi/>), not a tag
+ node -- it must pass through as a plain list untouched, letting
+ dict_op's own native list handling manage it."""
+ rules = [
+ {
+ "id": 100,
+ "load_balance": {"hash": ["source-address", "random"]},
+ "translation": {"address": "masquerade"},
+ },
+ ]
+ result = _rules_to_device(rules)
+ self.assertEqual(result["100"]["load_balance"]["hash"], ["source-address", "random"])
+
+ def test_load_balance_backend_reshaped_to_tag_node(self):
+ """backend IS a genuine tag node (confirmed: nested "weight"
+ leaf), unlike hash -- this one needs the structural reshape."""
+ rules = [
+ {
+ "id": 100,
+ "load_balance": {"backend": [{"ip": "192.168.1.10", "weight": 50}]},
+ "translation": {"address": "masquerade"},
+ },
+ ]
+ result = _rules_to_device(rules)
+ self.assertEqual(result["100"]["load_balance"]["backend"], {"192.168.1.10": {"weight": 50}})
+
+ def test_load_balance_backend_without_weight_is_bare_presence(self):
+ """Regression check for the autoclean-based simplification: a
+ backend entry with no weight must still produce a bare presence
+ node, matching the previous manual if/else exactly."""
+ rules = [
+ {
+ "id": 100,
+ "load_balance": {"backend": [{"ip": "192.168.1.10"}]},
+ "translation": {"address": "masquerade"},
+ },
+ ]
+ result = _rules_to_device(rules)
+ self.assertEqual(result["100"]["load_balance"]["backend"], {"192.168.1.10": {}})
+
+ def test_nat64_translation_pool_reshaped_to_tag_node(self):
+ rules = [
+ {"id": 10, "translation": {"pool": [{"id": 1, "address": "192.168.100.10"}]}},
+ ]
+ result = _rules_to_device(rules)
+ self.assertEqual(result["10"]["translation"]["pool"], {"1": {"address": "192.168.100.10"}})
+
+
+class TestRulesFromDevice(unittest.TestCase):
+ def test_simple_rule(self):
+ raw = {
+ "100": {
+ "outbound-interface": {"name": "eth0"},
+ "translation": {"address": "masquerade"},
+ },
+ }
+ result = _rules_from_device(raw)
+ self.assertEqual(len(result), 1)
+ self.assertEqual(result[0]["id"], 100)
+ self.assertEqual(result[0]["outbound_interface"]["name"], "eth0")
+
+ def test_rules_sorted_by_id(self):
+ raw = {
+ "200": {"translation": {"address": "masquerade"}},
+ "100": {"translation": {"address": "masquerade"}},
+ }
+ result = _rules_from_device(raw)
+ self.assertEqual(result[0]["id"], 100)
+ self.assertEqual(result[1]["id"], 200)
+
+ def test_presence_node_becomes_bool(self):
+ raw = {"100": {"log": {}, "translation": {"address": "masquerade"}}}
+ result = _rules_from_device(raw)
+ self.assertTrue(result[0]["log"])
+
+ def test_static_inbound_interface_string(self):
+ raw = {
+ "300": {
+ "inbound-interface": "eth0",
+ "destination": {"address": "198.51.100.20"},
+ "translation": {"address": "192.168.1.20"},
+ },
+ }
+ result = _rules_from_device(raw)
+ self.assertEqual(result[0]["inbound_interface"], "eth0")
+
+ def test_load_balance_hash_single_value_collapse(self):
+ """The device can collapse a single-value multi-leaf to a bare
+ string; this must come back as a 1-element list, not a string,
+ to match the field's real (list) type."""
+ raw = {"100": {"load_balance": {}, "load-balance": {"hash": "random"}}}
+ # (duplicate key above is just illustrating intent; real call:)
+ raw = {"100": {"load-balance": {"hash": "random"}}}
+ result = _rules_from_device(raw)
+ self.assertEqual(result[0]["load_balance"]["hash"], ["random"])
+
+ def test_load_balance_backend_from_tag_node(self):
+ raw = {"100": {"load-balance": {"backend": {"192.168.1.10": {"weight": "50"}}}}}
+ result = _rules_from_device(raw)
+ self.assertEqual(
+ result[0]["load_balance"]["backend"],
+ [{"ip": "192.168.1.10", "weight": 50}],
+ )
+
+ def test_nat64_pool_from_tag_node(self):
+ raw = {"10": {"translation": {"pool": {"1": {"address": "192.168.100.10"}}}}}
+ result = _rules_from_device(raw)
+ self.assertEqual(result[0]["translation"]["pool"], [{"id": 1, "address": "192.168.100.10"}])
+
+ def test_empty_returns_empty(self):
+ self.assertEqual(_rules_from_device({}), [])
+ self.assertEqual(_rules_from_device(None), [])
+
+
+class TestCgnat(unittest.TestCase):
+ """The core bug-fix area: external pool range is a genuine tag node
+ (nested "seq" leaf), internal pool range is a plain multi-value leaf
+ -- confirmed against vyos-1x schema, and previously conflated."""
+
+ def test_external_pool_range_is_tag_node_with_seq(self):
+ cgnat = {
+ "pool": {
+ "external": [
+ {"name": "EXT1", "range": [{"value": "203.0.113.1-203.0.113.10", "seq": 1}]},
+ ],
+ },
+ }
+ result = _cgnat_to_device(cgnat)
+ self.assertEqual(
+ result["pool"]["external"]["EXT1"]["range"],
+ {"203.0.113.1-203.0.113.10": {"seq": 1}},
+ )
+
+ def test_external_pool_range_without_seq(self):
+ cgnat = {"pool": {"external": [{"name": "EXT1", "range": [{"value": "203.0.113.1-.10"}]}]}}
+ result = _cgnat_to_device(cgnat)
+ self.assertEqual(result["pool"]["external"]["EXT1"]["range"], {"203.0.113.1-.10": {}})
+
+ def test_internal_pool_range_stays_a_plain_list(self):
+ cgnat = {"pool": {"internal": [{"name": "INT1", "range": ["10.0.0.0/24", "10.0.1.0/24"]}]}}
+ result = _cgnat_to_device(cgnat)
+ self.assertEqual(
+ result["pool"]["internal"]["INT1"]["range"],
+ ["10.0.0.0/24", "10.0.1.0/24"],
+ )
+
+ def test_internal_pool_multi_value_range_from_device_not_dropped(self):
+ """Regression test for the confirmed data-loss bug: the previous
+ implementation only checked isinstance(str)/isinstance(dict) for
+ internal pool range and silently dropped it whenever the device
+ returned the real shape for >1 value -- a plain list."""
+ raw = {"pool": {"internal": {"INT1": {"range": ["10.0.0.0/24", "10.0.1.0/24"]}}}}
+ result = _cgnat_from_device(raw)
+ pool = result["pool"]["internal"][0]
+ self.assertEqual(pool["range"], ["10.0.0.0/24", "10.0.1.0/24"])
+
+ def test_internal_pool_single_value_range_collapse(self):
+ raw = {"pool": {"internal": {"INT1": {"range": "10.0.2.0/24"}}}}
+ result = _cgnat_from_device(raw)
+ self.assertEqual(result["pool"]["internal"][0]["range"], ["10.0.2.0/24"])
+
+ def test_external_pool_range_from_device_with_seq(self):
+ raw = {"pool": {"external": {"EXT1": {"range": {"203.0.113.1-.10": {"seq": "1"}}}}}}
+ result = _cgnat_from_device(raw)
+ rng = result["pool"]["external"][0]["range"]
+ self.assertEqual(rng, [{"value": "203.0.113.1-.10", "seq": 1}])
+
+ def test_log_allocation_generic_presence(self):
+ result = _cgnat_to_device({"log_allocation": True})
+ self.assertEqual(result["log_allocation"], {})
+
+ def test_cgnat_rule_generic(self):
+ cgnat = {"rule": [{"id": 1, "destination": {"group": {"address_group": "CGNAT-DST"}}}]}
+ result = _cgnat_to_device(cgnat)
+ self.assertEqual(
+ result["rule"]["1"]["destination"]["group"]["address_group"],
+ "CGNAT-DST",
+ )
+
+ def test_empty(self):
+ self.assertEqual(_cgnat_to_device({}), {})
+ self.assertEqual(_cgnat_from_device({}), {})
+
+
+class TestWantToDevice(unittest.TestCase):
+ def test_empty(self):
+ self.assertEqual(_want_to_device({}), {})
+ self.assertEqual(_want_to_device(None), {})
+
+ def test_source_nat(self):
+ config = {
+ "nat": {
+ "source": {
+ "rule": [
+ {
+ "id": 100,
+ "outbound_interface": {"name": "eth0"},
+ "translation": {"address": "masquerade"},
+ },
+ ],
+ },
+ },
+ }
+ result = _want_to_device(config)
+ self.assertIn("100", result["nat"]["source"]["rule"])
+
+ def test_nat64_pools(self):
+ config = {
+ "nat64": {
+ "source": {
+ "rule": [
+ {
+ "id": 10,
+ "translation": {"pool": [{"id": 1, "address": "192.168.100.10"}]},
+ },
+ ],
+ },
+ },
+ }
+ result = _want_to_device(config)
+ rule = result["nat64"]["source"]["rule"]["10"]
+ self.assertIn("1", rule["translation"]["pool"])
+
+ def test_nat66(self):
+ config = {
+ "nat66": {
+ "source": {
+ "rule": [{"id": 10, "outbound_interface": {"name": "eth0"}}],
+ },
+ },
+ }
+ result = _want_to_device(config)
+ self.assertIn("10", result["nat66"]["source"]["rule"])
+
+ def test_nat66_destination_and_source_via_dispatch_table(self):
+ """nat66 has no cgnat, and only destination/source (no static) --
+ exercised via _NAT_TYPE_SECTIONS, not hand-written per-type
+ blocks."""
+ config = {
+ "nat66": {
+ "destination": {"rule": [{"id": 10, "protocol": "tcp"}]},
+ "source": {"rule": [{"id": 20}]},
+ },
+ }
+ result = _want_to_device(config)
+ self.assertIn("10", result["nat66"]["destination"]["rule"])
+ self.assertIn("20", result["nat66"]["source"]["rule"])
+ self.assertNotIn("cgnat", result["nat66"])
+
+ def test_cgnat_in_want(self):
+ config = {"nat": {"cgnat": {"log_allocation": True}}}
+ result = _want_to_device(config)
+ self.assertEqual(result["nat"]["cgnat"]["log_allocation"], {})
+
+
+class TestDeviceToArgspec(unittest.TestCase):
+ def test_empty(self):
+ self.assertEqual(_device_to_argspec({}), {})
+ self.assertEqual(_device_to_argspec(None), {})
+
+ def test_source_rule(self):
+ raw = {"nat": {"source": {"rule": {"100": {"translation": {"address": "masquerade"}}}}}}
+ result = _device_to_argspec(raw)
+ self.assertEqual(result["nat"]["source"]["rule"][0]["id"], 100)
+
+ def test_nat64_pools_parsed(self):
+ raw = {
+ "nat64": {
+ "source": {
+ "rule": {"10": {"translation": {"pool": {"1": {"address": "192.168.100.10"}}}}},
+ },
+ },
+ }
+ result = _device_to_argspec(raw)
+ pools = result["nat64"]["source"]["rule"][0]["translation"]["pool"]
+ self.assertEqual(pools[0]["id"], 1)
+
+ def test_static_inbound_interface_string(self):
+ raw = {"nat": {"static": {"rule": {"300": {"inbound-interface": "eth0"}}}}}
+ result = _device_to_argspec(raw)
+ self.assertEqual(result["nat"]["static"]["rule"][0]["inbound_interface"], "eth0")
+
+
+class TestDeviceToArgspecFixture(unittest.TestCase):
+ def setUp(self):
+ self.fixture = _load_nat_fixture()
+
+ def test_source_rules_parsed(self):
+ result = _device_to_argspec(self.fixture)
+ ids = [r["id"] for r in result["nat"]["source"]["rule"]]
+ self.assertIn(100, ids)
+ self.assertIn(101, ids)
+
+ def test_destination_rule_parsed(self):
+ result = _device_to_argspec(self.fixture)
+ rule = result["nat"]["destination"]["rule"][0]
+ self.assertEqual(rule["id"], 200)
+ self.assertEqual(rule["destination"]["port"], "80")
+
+ def test_static_rule_parsed(self):
+ result = _device_to_argspec(self.fixture)
+ rule = result["nat"]["static"]["rule"][0]
+ self.assertEqual(rule["inbound_interface"], "eth0")
+
+ def test_nat64_pools_parsed(self):
+ result = _device_to_argspec(self.fixture)
+ pools = result["nat64"]["source"]["rule"][0]["translation"]["pool"]
+ self.assertEqual(pools[0]["port"], "1-65535")
+
+ def test_description_parsed(self):
+ result = _device_to_argspec(self.fixture)
+ rule100 = next(r for r in result["nat"]["source"]["rule"] if r["id"] == 100)
+ self.assertEqual(rule100["description"], "Source rule 100")
+
+ def test_hash_single_value_collapse_from_fixture(self):
+ result = _device_to_argspec(self.fixture)
+ rule100 = next(r for r in result["nat"]["source"]["rule"] if r["id"] == 100)
+ self.assertEqual(rule100["load_balance"]["hash"], ["random"])
+
+ def test_backend_from_fixture(self):
+ result = _device_to_argspec(self.fixture)
+ rule100 = next(r for r in result["nat"]["source"]["rule"] if r["id"] == 100)
+ backends = {b["ip"]: b.get("weight") for b in rule100["load_balance"]["backend"]}
+ self.assertEqual(backends["192.168.1.10"], 50)
+ self.assertEqual(backends["192.168.1.11"], None)
+
+ def test_cgnat_internal_pool_range_not_dropped(self):
+ """The actual regression this whole refactor was triggered by."""
+ result = _device_to_argspec(self.fixture)
+ pool = result["nat"]["cgnat"]["pool"]["internal"][0]
+ self.assertEqual(pool["range"], ["10.0.0.0/24", "10.0.1.0/24"])
+
+ def test_cgnat_external_pool_range_with_seq(self):
+ result = _device_to_argspec(self.fixture)
+ pool = result["nat"]["cgnat"]["pool"]["external"][0]
+ self.assertEqual(pool["range"], [{"value": "203.0.113.1-203.0.113.10", "seq": 1}])
+
+ def test_cgnat_rule_parsed(self):
+ result = _device_to_argspec(self.fixture)
+ rule = result["nat"]["cgnat"]["rule"][0]
+ self.assertEqual(rule["destination"]["group"]["address_group"], "CGNAT-DST")
+
+
+class TestDictOpNat(unittest.TestCase):
+ """End-to-end command generation, exactly as main() calls it."""
+
+ def test_merged_adds_source_rule(self):
+ want = _want_to_device(
+ {
+ "nat": {
+ "source": {
+ "rule": [
+ {
+ "id": 100,
+ "outbound_interface": {"name": "eth0"},
+ "translation": {"address": "masquerade"},
+ },
+ ],
+ },
+ },
+ },
+ )
+ cmds = dict_op(want.get("nat", {}), {}, ["nat"], op="set")
+ paths = [c[1] for c in cmds]
+ self.assertIn(["nat", "source", "rule", "100", "outbound-interface", "name", "eth0"], paths)
+ self.assertIn(
+ ["nat", "source", "rule", "100", "translation", "address", "masquerade"],
+ paths,
+ )
+
+ def test_merged_idempotent_against_fixture(self):
+ fixture = _load_nat_fixture()
+ have = _device_to_argspec(fixture)
+ want = _want_to_device({"nat": have.get("nat", {})}).get("nat", {})
+ norm_have = _normalize_nat_have(fixture, "nat")
+ cmds = dict_op(want, norm_have, ["nat"], op="set")
+ self.assertEqual(cmds, [])
+
+ def test_nat64_idempotent_against_fixture(self):
+ fixture = _load_nat_fixture()
+ have = _device_to_argspec(fixture)
+ want = _want_to_device({"nat64": have.get("nat64", {})}).get("nat64", {})
+ norm_have = _normalize_nat_have(fixture, "nat64")
+ cmds = dict_op(want, norm_have, ["nat64"], op="set")
+ self.assertEqual(cmds, [])
+
+ def test_cgnat_idempotent_against_fixture_including_ranges(self):
+ """The real proof the bug is fixed: idempotency now holds even
+ though it involves both the tag-node (external) and plain-list
+ (internal) range shapes at once."""
+ fixture = _load_nat_fixture()
+ have = _device_to_argspec(fixture)
+ want = _want_to_device({"nat": have.get("nat", {})}).get("nat", {})
+ norm_have = _normalize_nat_have(fixture, "nat")
+ cmds = dict_op(want, norm_have, ["nat"], op="set")
+ self.assertEqual(cmds, [])
+
+ def test_replaced_purges_stale_internal_range_member(self):
+ """The internal-pool range being a plain list means removing a
+ member under 'replaced' relies on dict_op's list-purge handling
+ (fixed earlier this session) -- confirmed it applies here too."""
+ raw_have = {
+ "cgnat": {"pool": {"internal": {"INT1": {"range": ["10.0.0.0/24", "10.0.1.0/24"]}}}},
+ }
+ want = _want_to_device(
+ {
+ "nat": {
+ "cgnat": {"pool": {"internal": [{"name": "INT1", "range": ["10.0.0.0/24"]}]}},
+ },
+ },
+ )["nat"]
+ norm_have = _normalize_nat_have({"nat": raw_have}, "nat")
+ cmds = dict_op(want, norm_have, ["nat"], op="purge")
+ self.assertIn(
+ ("delete", ["nat", "cgnat", "pool", "internal", "INT1", "range", "10.0.1.0/24"]),
+ cmds,
+ )
+
+ def test_overridden_deletes_entire_omitted_section(self):
+ """overridden is full-model: a section entirely omitted from
+ want (not just a rule within it) must be deleted, via the same
+ single dict_op purge call main() uses -- no manual section-scan
+ loop needed."""
+ raw_have = {
+ "destination": {"rule": {"200": {"protocol": "tcp"}}},
+ "source": {"rule": {"100": {}}},
+ }
+ nat_want = _want_to_device(
+ {"nat": {"source": {"rule": [{"id": 100}]}}},
+ )["nat"]
+ norm_have = _normalize_nat_have({"nat": raw_have}, "nat")
+ cmds = dict_op(nat_want, norm_have, ["nat"], op="purge")
+ self.assertIn(("delete", ["nat", "destination"]), cmds)
+ self.assertTrue(all(c[1] != ["nat", "source"] for c in cmds))
+
+ def test_overridden_full_wipe_deletes_each_section_individually(self):
+ """Empty want under overridden purges every present section --
+ granular per-section deletes, not one blanket delete of the
+ whole nat_type (that distinction only matters for vyos_bgp_global,
+ where system-as's device-model constraint forces atomicity; NAT
+ has no equivalent cross-field constraint)."""
+ raw_have = {"destination": {"rule": {"200": {}}}, "source": {"rule": {"100": {}}}}
+ norm_have = _normalize_nat_have({"nat": raw_have}, "nat")
+ cmds = dict_op({}, norm_have, ["nat"], op="purge")
+ self.assertIn(("delete", ["nat", "destination"]), cmds)
+ self.assertIn(("delete", ["nat", "source"]), cmds)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/unit/modules/test_vyos_ntp_global.py b/tests/unit/modules/test_vyos_ntp_global.py
index c536141..4bbeed8 100644
--- a/tests/unit/modules/test_vyos_ntp_global.py
+++ b/tests/unit/modules/test_vyos_ntp_global.py
@@ -4,179 +4,178 @@ 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_ntp_global import (
+ _device_to_argspec,
+ _servers_from_device,
+ _servers_to_device,
+ _want_to_device,
build_commands,
get_running_config,
- normalize_config,
- normalize_servers,
)
+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 = ["service", "ntp"]
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 TestVyOSNtpGlobalNormalize(unittest.TestCase):
- """Test normalize_config and normalize_servers — no device needed."""
-
- def test_normalize_config_empty(self):
- result = normalize_config({})
- self.assertEqual(result["allow_clients"], [])
- self.assertEqual(result["listen_addresses"], [])
- self.assertEqual(result["servers"], {})
-
- def test_normalize_config_servers_sorted(self):
- config = {
- "servers": [
- {"server": "b.example.com", "options": ["prefer", "noselect"]},
- {"server": "a.example.com"},
- ],
- }
- result = normalize_config(config)
- self.assertIn("a.example.com", result["servers"])
- self.assertIn("b.example.com", result["servers"])
- self.assertEqual(result["servers"]["b.example.com"], ["noselect", "prefer"])
-
- def test_normalize_servers_dict_with_options(self):
- raw = {
- "time1.vyos.net": {},
- "203.0.113.0": {"prefer": {}},
- }
- result = normalize_servers(raw)
- self.assertEqual(result["time1.vyos.net"], [])
- self.assertEqual(result["203.0.113.0"], ["prefer"])
-
- def test_normalize_servers_list(self):
- raw = ["time1.vyos.net", "time2.vyos.net"]
- result = normalize_servers(raw)
- self.assertEqual(result["time1.vyos.net"], [])
-
- def test_normalize_servers_string(self):
- result = normalize_servers("time1.vyos.net")
- self.assertEqual(result["time1.vyos.net"], [])
-
-
-class TestVyOSNtpGlobalGetRunning(VyOSModuleTestCase):
- """Test get_running_config parsing against fixture API responses."""
-
- def setUp(self):
- super().setUp()
self.fixture = load_fixture("ntp_global_running.json")
+ self.mock_vyos.get_config = MagicMock(return_value=self.fixture)
- def test_parses_allow_clients(self):
- self.set_running_config(self.fixture)
- result = get_running_config(self.mock_vyos)
- self.assertIn("10.6.6.0/24", result["allow_clients"])
- def test_parses_listen_addresses(self):
- self.set_running_config(self.fixture)
- result = get_running_config(self.mock_vyos)
- self.assertIn("10.1.3.1", result["listen_addresses"])
+class TestGetRunningConfig(VyOSModuleTestCase):
+ def test_returns_raw_device_dict(self):
+ self.assertEqual(get_running_config(self.mock_vyos), self.fixture)
- def test_parses_servers(self):
- self.set_running_config(self.fixture)
- result = get_running_config(self.mock_vyos)
- self.assertIn("time1.vyos.net", result["servers"])
- self.assertIn("203.0.113.0", result["servers"])
- self.assertIn("prefer", result["servers"]["203.0.113.0"])
+ def test_empty_config(self):
+ self.mock_vyos.get_config = MagicMock(return_value=None)
+ self.assertEqual(get_running_config(self.mock_vyos), {})
- def test_empty_config_returns_empty(self):
- self.set_running_config({})
- result = get_running_config(self.mock_vyos)
- self.assertEqual(result["allow_clients"], [])
- self.assertEqual(result["servers"], {})
+class TestServersToDeviceFromDevice(unittest.TestCase):
+ """options is the one genuine structural exception: the argspec
+ wraps them in a named field, but the device puts each option as a
+ direct presence-leaf sibling under the server tag node itself."""
-class TestVyOSNtpGlobalBuildCommands(unittest.TestCase):
- """Test build_commands diff logic — no device needed."""
+ def test_to_device_bare_server_is_presence(self):
+ self.assertEqual(_servers_to_device([{"server": "time1.vyos.net"}]), {"time1.vyos.net": {}})
- def _have(self, **kwargs):
- base = {"allow_clients": [], "listen_addresses": [], "servers": {}}
- base.update(kwargs)
- return base
+ def test_to_device_options_become_sibling_presence_leaves(self):
+ result = _servers_to_device([{"server": "203.0.113.0", "options": ["prefer", "nts"]}])
+ self.assertEqual(result, {"203.0.113.0": {"prefer": {}, "nts": {}}})
- def _want(self, **kwargs):
- return self._have(**kwargs)
+ def test_from_device_bare_server(self):
+ result = _servers_from_device({"time1.vyos.net": {}})
+ self.assertEqual(result, [{"server": "time1.vyos.net"}])
- def test_merged_adds_new_server(self):
- want = self._want(servers={"new.server.com": []})
- have = self._have(servers={})
- cmds = build_commands(want, have, "merged")
- self.assertIn(("set", ["service", "ntp", "server", "new.server.com"]), cmds)
+ def test_from_device_options_extracted_as_sorted_list(self):
+ result = _servers_from_device({"203.0.113.0": {"prefer": {}, "nts": {}}})
+ self.assertEqual(result, [{"server": "203.0.113.0", "options": ["nts", "prefer"]}])
+
+
+class TestWantToDevice(unittest.TestCase):
+ def test_empty(self):
+ self.assertEqual(_want_to_device({}), {})
+
+ def test_allow_clients_nested_under_address(self):
+ """allow_clients is a flat argspec list, but the device nests
+ the multi-value leaf one level deeper under a literal "address"
+ child -- confirmed against vyos-1x (allow-client.xml.i)."""
+ result = _want_to_device({"allow_clients": ["10.6.6.0/24"]})
+ self.assertEqual(result, {"allow-client": {"address": ["10.6.6.0/24"]}})
- def test_merged_idempotent_existing_server(self):
- want = self._want(servers={"time1.vyos.net": []})
- have = self._have(servers={"time1.vyos.net": []})
- cmds = build_commands(want, have, "merged")
- self.assertEqual(cmds, [])
+ def test_listen_addresses_direct_no_nesting(self):
+ result = _want_to_device({"listen_addresses": ["10.1.3.1"]})
+ self.assertEqual(result, {"listen-address": ["10.1.3.1"]})
+
+ def test_servers_keyed_by_address(self):
+ result = _want_to_device({"servers": [{"server": "203.0.113.0", "options": ["prefer"]}]})
+ self.assertEqual(result, {"server": {"203.0.113.0": {"prefer": {}}}})
+
+
+class TestDeviceToArgspecFixture(VyOSModuleTestCase):
+ def test_allow_clients_parsed(self):
+ result = _device_to_argspec(self.fixture)
+ self.assertIn("10.6.6.0/24", result["allow_clients"])
+
+ def test_listen_addresses_parsed(self):
+ result = _device_to_argspec(self.fixture)
+ self.assertIn("10.1.3.1", result["listen_addresses"])
+
+ def test_servers_parsed_with_options(self):
+ result = _device_to_argspec(self.fixture)
+ servers = {s["server"]: s.get("options", []) for s in result["servers"]}
+ self.assertIn("time1.vyos.net", servers)
+ self.assertIn("prefer", servers["203.0.113.0"])
+
+ def test_empty_config(self):
+ result = _device_to_argspec({})
+ self.assertEqual(result, {"allow_clients": [], "listen_addresses": [], "servers": []})
+
+ def test_1_5_plus_shape_no_address_wrapper(self):
+ """Confirmed against vyos-1x, but kept defensive: some REST
+ responses omit the "address" subnode under allow-client."""
+ raw = {"allow-client": {"10.6.6.0/24": {}}}
+ result = _device_to_argspec(raw)
+ self.assertEqual(result["allow_clients"], ["10.6.6.0/24"])
+
+
+class TestBuildCommands(VyOSModuleTestCase):
+ def test_merged_idempotent_against_own_fixture(self):
+ have = _device_to_argspec(self.fixture)
+ self.assertEqual(build_commands(have, self.fixture, "merged"), [])
+
+ def test_replaced_idempotent_against_own_fixture(self):
+ have = _device_to_argspec(self.fixture)
+ self.assertEqual(build_commands(have, self.fixture, "replaced"), [])
+
+ def test_1_5_plus_shape_idempotent(self):
+ """Regression test for the real bug caught this session: want
+ always emits the "address"-wrapped shape, but dict_op compares
+ directly against the raw device tree -- without normalizing
+ have's shape first, a device reporting the unwrapped 1.5+
+ variant would never be idempotent."""
+ raw_have = {"allow-client": {"10.6.6.0/24": {}}}
+ have = _device_to_argspec(raw_have)
+ self.assertEqual(build_commands(have, raw_have, "merged"), [])
+
+ def test_merged_adds_new_server(self):
+ cmds = build_commands({"servers": [{"server": "new.server.com"}]}, {}, "merged")
+ self.assertIn(("set", _BASE + ["server", "new.server.com"]), cmds)
def test_merged_adds_server_option(self):
- want = self._want(servers={"time1.vyos.net": ["prefer"]})
- have = self._have(servers={"time1.vyos.net": []})
- cmds = build_commands(want, have, "merged")
- self.assertIn(("set", ["service", "ntp", "server", "time1.vyos.net", "prefer"]), cmds)
+ raw_have = {"server": {"time1.vyos.net": {}}}
+ config = {"servers": [{"server": "time1.vyos.net", "options": ["prefer"]}]}
+ cmds = build_commands(config, raw_have, "merged")
+ self.assertIn(("set", _BASE + ["server", "time1.vyos.net", "prefer"]), cmds)
def test_replaced_removes_extra_server(self):
- want = self._want(servers={"time1.vyos.net": []})
- have = self._have(servers={"time1.vyos.net": [], "time2.vyos.net": []})
- cmds = build_commands(want, have, "replaced")
- self.assertIn(("delete", ["service", "ntp", "server", "time2.vyos.net"]), cmds)
+ raw_have = {"server": {"time1.vyos.net": {}, "time2.vyos.net": {}}}
+ config = {"servers": [{"server": "time1.vyos.net"}]}
+ cmds = build_commands(config, raw_have, "replaced")
+ self.assertIn(("delete", _BASE + ["server", "time2.vyos.net"]), cmds)
def test_replaced_removes_extra_allow_client(self):
- want = self._want(allow_clients=["10.1.0.0/24"])
- have = self._have(allow_clients=["10.1.0.0/24", "10.2.0.0/24"])
- cmds = build_commands(want, have, "replaced")
- self.assertIn(
- ("delete", ["service", "ntp", "allow-client", "address", "10.2.0.0/24"]),
- cmds,
- )
+ """This exercises the real dict_op purge gap fixed this session:
+ have's allow-client returned as dict-of-presence (not a plain
+ list) while want is a plain list -- purge must still correctly
+ remove the stale entry."""
+ raw_have = {"allow-client": {"address": {"10.1.0.0/24": {}, "10.2.0.0/24": {}}}}
+ config = {"allow_clients": ["10.1.0.0/24"]}
+ cmds = build_commands(config, raw_have, "replaced")
+ self.assertIn(("delete", _BASE + ["allow-client", "address", "10.2.0.0/24"]), cmds)
def test_deleted_removes_all(self):
- have = self._have(
- servers={"time1.vyos.net": []},
- allow_clients=["10.0.0.0/24"],
- listen_addresses=["192.168.1.1"],
- )
- cmds = build_commands({}, have, "deleted")
- self.assertEqual(len(cmds), 1)
- self.assertEqual(cmds[0], ("delete", ["service", "ntp"]))
+ raw_have = {"server": {"time1.vyos.net": {}}}
+ cmds = build_commands({}, raw_have, "deleted")
+ self.assertEqual(cmds, [("delete", _BASE)])
def test_deleted_idempotent_when_empty(self):
- have = self._have(servers={}, allow_clients=[], listen_addresses=[])
- cmds = build_commands({}, have, "deleted")
- self.assertEqual(cmds, [])
+ self.assertEqual(build_commands({}, {}, "deleted"), [])
def test_overridden_deletes_then_merges(self):
- want = self._want(servers={"new.server.com": []})
- have = self._have(servers={"old.server.com": []})
- cmds = build_commands(want, have, "overridden")
- ops_paths = [(c[0], c[1]) for c in cmds]
- self.assertIn(("delete", ["service", "ntp", "server", "old.server.com"]), ops_paths)
- self.assertIn(("set", ["service", "ntp", "server", "new.server.com"]), ops_paths)
- self.assertNotIn(("delete", ["service", "ntp", "server"]), ops_paths)
+ raw_have = {"server": {"old.server.com": {}}}
+ config = {"servers": [{"server": "new.server.com"}]}
+ cmds = build_commands(config, raw_have, "overridden")
+ self.assertIn(("delete", _BASE + ["server", "old.server.com"]), cmds)
+ self.assertIn(("set", _BASE + ["server", "new.server.com"]), cmds)
def test_no_commands_when_already_correct(self):
- state = {"allow_clients": ["10.0.0.0/24"], "listen_addresses": [], "servers": {}}
- cmds = build_commands(state, state, "merged")
- self.assertEqual(cmds, [])
+ raw_have = {"allow-client": {"address": {"10.0.0.0/24": {}}}}
+ config = {"allow_clients": ["10.0.0.0/24"]}
+ self.assertEqual(build_commands(config, raw_have, "merged"), [])
+
+ def test_collapsed_single_server_no_char_iteration_bug(self):
+ raw_have = {"server": "203.0.113.0"}
+ config = {"servers": [{"server": "203.0.113.0"}]}
+ self.assertEqual(build_commands(config, raw_have, "merged"), [])
if __name__ == "__main__":
diff --git a/tests/unit/modules/test_vyos_route_maps.py b/tests/unit/modules/test_vyos_route_maps.py
index 97d814b..4eec600 100644
--- a/tests/unit/modules/test_vyos_route_maps.py
+++ b/tests/unit/modules/test_vyos_route_maps.py
@@ -4,189 +4,431 @@ 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_route_maps import (
- _want_to_api_match,
- _want_to_api_set,
+ ARGUMENT_SPEC,
+ _derive_key_field,
+ _device_to_argspec,
+ _keyed_list_from_device,
+ _keyed_list_to_device,
+ _match_from_device,
+ _match_to_device,
+ _rule_entry_from_device,
+ _rule_entry_to_device,
+ _seed_route_map_placeholders,
+ _set_from_device,
+ _set_to_device,
+ _want_to_device,
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 = ["policy", "route-map"]
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
-
+ self.fixture = load_fixture("route_maps_running.json")
+ self.mock_vyos.get_config = MagicMock(return_value=self.fixture)
-class TestVyOSRouteMapsGetRunning(VyOSModuleTestCase):
- def setUp(self):
- super().setUp()
- self.fixture = load_fixture("route_maps_running.json")
+class TestGetRunningConfig(VyOSModuleTestCase):
+ def test_unwraps_route_map_wrapper_key(self):
+ """Confirmed against the pre-existing fixture (built from real
+ device data): the REST API wraps the response in an extra
+ "route-map" key even when querying at the policy/route-map
+ path itself -- the same defensive-unwrap pattern every other
+ module this session needed for its own top-level get_config."""
+ result = get_running_config(self.mock_vyos)
+ self.assertIn("RM-TEST-EXPORT-POLICY", result)
+ self.assertNotIn("route-map", result)
- def test_unwraps_route_map_nesting(self):
- """API returns {"route-map": {"NAME": {...}}} — must unwrap."""
- self.set_running_config(self.fixture)
+ def test_no_wrapper_key_passes_through(self):
+ self.mock_vyos.get_config = MagicMock(return_value={"RM1": {"rule": {}}})
result = get_running_config(self.mock_vyos)
- names = [e["route_map"] for e in result]
+ self.assertIn("RM1", 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_route_map_key(self):
+ opts = ARGUMENT_SPEC["config"]["options"]
+ self.assertEqual(_derive_key_field(opts), "route_map")
+
+ def test_derives_sequence_key(self):
+ entry_opts = ARGUMENT_SPEC["config"]["options"]["entries"]["options"]
+ self.assertEqual(_derive_key_field(entry_opts), "sequence")
+
+ def test_raises_if_none_required(self):
+ with self.assertRaises(ValueError):
+ _derive_key_field({"a": {"type": "str"}})
+
+ def test_raises_if_more_than_one_required(self):
+ with self.assertRaises(ValueError):
+ _derive_key_field({"a": {"required": True}, "b": {"required": True}})
+
+
+class TestKeyedListHelper(unittest.TestCase):
+ def test_to_device_default_transform_is_autoclean(self):
+ result = _keyed_list_to_device([{"route_map": "RM1", "description": "x"}], "route_map")
+ self.assertEqual(result, {"RM1": {"description": "x"}})
+
+ def test_from_device_default_transform_is_from_device(self):
+ result = _keyed_list_from_device({"RM1": {"description": "x"}}, "route_map")
+ self.assertEqual(result, [{"route_map": "RM1", "description": "x"}])
+
+ def test_empty(self):
+ self.assertEqual(_keyed_list_to_device([], "route_map"), {})
+ self.assertEqual(_keyed_list_from_device({}, "route_map"), [])
+
+
+class TestMatchToDeviceFromDevice(unittest.TestCase):
+ """Most match options are fully generic; only prefix_list/
+ prefix_list6 and ip/ipv6 nexthop matching are genuine structural
+ exceptions (confirmed against vyos-1x: the device nests these
+ deeper than the argspec)."""
+
+ def test_simple_fields_generic(self):
+ result = _match_to_device({"peer": "192.0.2.1", "protocol": "bgp", "metric": 100})
+ self.assertEqual(result, {"peer": "192.0.2.1", "protocol": "bgp", "metric": 100})
+
+ def test_prefix_list_nested_two_levels(self):
+ result = _match_to_device({"prefix_list": "PL1"})
+ self.assertEqual(result, {"ip": {"address": {"prefix-list": "PL1"}}})
+
+ def test_prefix_list6_nested_two_levels(self):
+ result = _match_to_device({"prefix_list6": "PL6"})
+ self.assertEqual(result, {"ipv6": {"address": {"prefix-list": "PL6"}}})
+
+ def test_ip_nexthop_extra_nesting_level(self):
+ result = _match_to_device(
+ {"ip": {"nexthop_address": "10.0.0.1", "nexthop_prefix_list": "PL2"}},
+ )
+ self.assertEqual(
+ result,
+ {"ip": {"nexthop": {"address": "10.0.0.1", "prefix-list": "PL2"}}},
+ )
+
+ def test_ipv6_nexthop_extra_nesting_level(self):
+ result = _match_to_device({"ipv6": {"nexthop_address": "2001:db8::1"}})
+ self.assertEqual(result, {"ipv6": {"nexthop": {"address": "2001:db8::1"}}})
+
+ def test_from_device_prefix_list(self):
+ entry = _match_from_device({"ip": {"address": {"prefix-list": "PL1"}}})
+ self.assertEqual(entry["prefix_list"], "PL1")
+
+ def test_from_device_nexthop(self):
+ entry = _match_from_device({"ip": {"nexthop": {"address": "10.0.0.1"}}})
+ self.assertEqual(entry["ip"], {"nexthop_address": "10.0.0.1"})
+
+ def test_from_device_generic_fields(self):
+ entry = _match_from_device({"peer": "192.0.2.1", "protocol": "bgp"})
+ self.assertEqual(entry, {"peer": "192.0.2.1", "protocol": "bgp"})
+
+ def test_empty(self):
+ self.assertEqual(_match_to_device({}), {})
+ self.assertEqual(_match_to_device(None), {})
+ self.assertEqual(_match_from_device({}), {})
+ self.assertEqual(_match_from_device(None), {})
+
+
+class TestSetToDeviceFromDevice(unittest.TestCase):
+ """as_path_* collapse onto one nested device node. community/
+ large_community/ipv6_next_hop are fully generic once modeled as
+ real nested dicts. "as_" is a genuine Python-keyword-collision
+ rename, nested inside aggregator specifically."""
+
+ def test_atomic_aggregate_fully_generic(self):
+ result = _set_to_device({"atomic_aggregate": True})
+ self.assertEqual(result, {"atomic_aggregate": {}})
+
+ def test_as_path_options_collapse_onto_one_node(self):
+ result = _set_to_device(
+ {"as_path_exclude": "111", "as_path_prepend": "65001", "as_path_prepend_last_as": 2},
+ )
+ self.assertEqual(
+ result["as-path"],
+ {"exclude": "111", "prepend": "65001", "prepend-last-as": 2},
+ )
+
+ def test_aggregator_as_rename(self):
+ """Regression test for the real bug caught this session: "as_"
+ is nested inside "aggregator", not a top-level set field -- a
+ flat rename map applied only at the top level misses it
+ entirely."""
+ result = _set_to_device({"aggregator": {"as_": 100, "ip": "10.0.0.5"}})
+ self.assertEqual(result, {"aggregator": {"as": 100, "ip": "10.0.0.5"}})
+
+ def test_aggregator_as_only(self):
+ result = _set_to_device({"aggregator": {"as_": 100}})
+ self.assertEqual(result, {"aggregator": {"as": 100}})
+
+ def test_community_add_stays_a_plain_list(self):
+ result = _set_to_device({"community": {"add": ["no-export", "no-advertise"]}})
+ self.assertEqual(result["community"], {"add": ["no-export", "no-advertise"]})
+
+ def test_large_community_none_presence(self):
+ result = _set_to_device({"large_community": {"none": True}})
+ self.assertEqual(result["large_community"], {"none": {}})
+
+ def test_ipv6_next_hop_generic(self):
+ result = _set_to_device({"ipv6_next_hop": {"global": "2001:db8::1"}})
+ self.assertEqual(result["ipv6_next_hop"], {"global": "2001:db8::1"})
+
+ def test_ipv6_next_hop_valueless_options(self):
+ result = _set_to_device({"ipv6_next_hop": {"peer_address": True, "prefer_global": True}})
+ self.assertEqual(
+ result["ipv6_next_hop"],
+ {"peer_address": {}, "prefer_global": {}},
+ )
+
+ def test_from_device_community_add(self):
+ entry = _set_from_device({"community": {"add": ["no-export"]}})
+ self.assertEqual(entry["community"], {"add": ["no-export"]})
+
+ def test_from_device_large_community_none(self):
+ entry = _set_from_device({"large-community": {"none": {}}})
+ self.assertEqual(entry["large_community"], {"none": True})
+
+ def test_from_device_as_path(self):
+ entry = _set_from_device({"as-path": {"exclude": "111", "prepend-last-as": "2"}})
+ self.assertEqual(entry["as_path_exclude"], "111")
+ self.assertEqual(entry["as_path_prepend_last_as"], 2)
+
+ def test_from_device_aggregator_as_rename_with_int_cast(self):
+ entry = _set_from_device({"aggregator": {"as": "100", "ip": "10.0.0.5"}})
+ self.assertEqual(entry["aggregator"]["as_"], 100)
+ self.assertEqual(entry["aggregator"]["ip"], "10.0.0.5")
+
+ def test_empty(self):
+ self.assertEqual(_set_to_device({}), {})
+ self.assertEqual(_set_to_device(None), {})
+ self.assertEqual(_set_from_device({}), {})
+ self.assertEqual(_set_from_device(None), {})
+
+
+class TestRuleEntryToDeviceFromDevice(unittest.TestCase):
+ def test_continue_sequence_renamed(self):
+ """ "continue" is a Python keyword and can't be used as a
+ dict() kwarg -- "continue_sequence" is the unavoidable argspec
+ name, handled directly at the rule level (not a set field)."""
+ result = _rule_entry_to_device({"continue_sequence": 20})
+ self.assertEqual(result["continue"], 20)
+
+ def test_generic_fields(self):
+ result = _rule_entry_to_device({"action": "permit", "call": "RM2"})
+ self.assertEqual(result, {"action": "permit", "call": "RM2"})
+
+ def test_from_device_continue(self):
+ entry = _rule_entry_from_device({"continue": "20"})
+ self.assertEqual(entry["continue_sequence"], 20)
+
+ def test_from_device_bare_collapse(self):
+ entry = _rule_entry_from_device(None)
+ self.assertEqual(entry, {})
+
+
+class TestWantToDevice(unittest.TestCase):
+ def test_empty(self):
+ self.assertEqual(_want_to_device([]), {})
+ self.assertEqual(_want_to_device(None), {})
+
+ def test_route_map_without_entries_omitted(self):
+ self.assertEqual(_want_to_device([{"route_map": "RM1"}]), {})
+
+ def test_keyed_by_route_map_name(self):
+ config = [{"route_map": "RM1", "entries": [{"sequence": 10, "action": "permit"}]}]
+ result = _want_to_device(config)
+ self.assertIn("10", result["RM1"]["rule"])
+
+ def test_underscore_route_map_name_stays_verbatim(self):
+ """Confirmed against vyos-1x: route-map names may legitimately
+ contain underscores. _want_to_device itself must not alter the
+ key -- the dict_op-level protection is tested separately in
+ TestBuildCommands."""
+ config = [{"route_map": "my_route_map", "entries": [{"sequence": 10}]}]
+ result = _want_to_device(config)
+ self.assertIn("my_route_map", result)
+
+
+class TestSeedRouteMapPlaceholders(unittest.TestCase):
+ """Regression tests for the confirmed bug: dict_op's fallback
+ guesses a kebab-cased device key whenever a want key is missing
+ from have -- correct for schema field names, wrong for a route-map
+ name (an opaque value that may contain an underscore). Reproduced
+ directly before this fix: "my_route_map" became "my-route-map" in
+ the generated command on first creation."""
+
+ def test_seeds_new_route_map_verbatim(self):
+ want = {"my_route_map": {"rule": {"10": {}}}}
+ have = {}
+ _seed_route_map_placeholders(want, have)
+ self.assertIn("my_route_map", have)
+
+ def test_seeds_new_rule_with_none_not_empty_dict(self):
+ """Seeding with {} instead of None would make dict_op think a
+ presence-only rule already matches and skip emitting its set
+ command -- the same mistake caught once already this session."""
+ want = {"RM1": {"rule": {"10": {}}}}
+ have = {"RM1": {"rule": {}}}
+ _seed_route_map_placeholders(want, have)
+ self.assertIsNone(have["RM1"]["rule"]["10"])
+
+ def test_does_not_overwrite_existing_entries(self):
+ want = {"RM1": {"rule": {"10": {}}}}
+ have = {"RM1": {"rule": {"10": {"action": "permit"}}}}
+ _seed_route_map_placeholders(want, have)
+ self.assertEqual(have["RM1"]["rule"]["10"], {"action": "permit"})
+
+
+class TestDeviceToArgspecFixture(VyOSModuleTestCase):
+ def test_all_route_maps_parsed(self):
+ raw = get_running_config(self.mock_vyos)
+ result = _device_to_argspec(raw)
+ names = [rm["route_map"] for rm in result]
self.assertIn("RM-TEST-EXPORT-POLICY", names)
self.assertIn("rm1", names)
- # "route-map" itself must NOT appear as a route map name
- self.assertNotIn("route-map", names)
-
- def test_parses_rule_action(self):
- self.set_running_config(self.fixture)
- result = get_running_config(self.mock_vyos)
- rm = next(e for e in result if e["route_map"] == "RM-TEST-EXPORT-POLICY")
- rule = rm["entries"][0]
- self.assertEqual(rule["action"], "permit")
- self.assertEqual(rule["sequence"], 10)
- def test_parses_match_peer(self):
- self.set_running_config(self.fixture)
- result = get_running_config(self.mock_vyos)
- rm = next(e for e in result if e["route_map"] == "RM-TEST-EXPORT-POLICY")
+ def test_prefix_list_and_nexthop_match_parsed(self):
+ raw = get_running_config(self.mock_vyos)
+ result = _device_to_argspec(raw)
+ rm = next(rm for rm in result if rm["route_map"] == "RM-TEST-EXPORT-POLICY")
rule = rm["entries"][0]
- self.assertEqual(rule["match"]["peer"], "192.0.2.32")
+ self.assertEqual(rule["match"]["prefix_list"], "PL-MATCH")
+ self.assertEqual(rule["match"]["ip"]["nexthop_address"], "10.0.0.1")
- def test_parses_set_fields(self):
- self.set_running_config(self.fixture)
- result = get_running_config(self.mock_vyos)
- rm = next(e for e in result if e["route_map"] == "RM-TEST-EXPORT-POLICY")
+ def test_community_add_parsed_as_list(self):
+ raw = get_running_config(self.mock_vyos)
+ result = _device_to_argspec(raw)
+ rm = next(rm for rm in result if rm["route_map"] == "RM-TEST-EXPORT-POLICY")
rule = rm["entries"][0]
- self.assertEqual(rule["set"]["metric"], "5")
- self.assertEqual(rule["set"]["aggregator"]["as"], "100")
- self.assertEqual(rule["set"]["as-path"]["exclude"], "111")
-
- def test_empty_returns_empty_list(self):
- self.set_running_config({})
- result = get_running_config(self.mock_vyos)
- self.assertEqual(result, [])
-
-
-class TestVyOSRouteMapsWantToApi(unittest.TestCase):
-
- def test_as_path_exclude_nested(self):
- """as_path_exclude maps to nested as-path.exclude."""
- result = _want_to_api_set({"as_path_exclude": "111"})
- self.assertEqual(result["as-path"]["exclude"], "111")
-
- def test_metric_flat(self):
- result = _want_to_api_set({"metric": "5"})
- self.assertEqual(result["metric"], "5")
+ self.assertEqual(rule["set"]["community"]["add"], ["no-export", "no-advertise"])
- def test_aggregator_as(self):
- result = _want_to_api_set({"aggregator": {"as": 100}})
- self.assertEqual(result["aggregator"]["as"], "100")
+ def test_large_community_none_parsed(self):
+ raw = get_running_config(self.mock_vyos)
+ result = _device_to_argspec(raw)
+ rm1 = next(rm for rm in result if rm["route_map"] == "rm1")
+ self.assertTrue(rm1["entries"][0]["set"]["large_community"]["none"])
- def test_aggregator_as_underscore(self):
- """aggregator.as_ is an alias for aggregator.as."""
- result = _want_to_api_set({"aggregator": {"as_": 100}})
- self.assertEqual(result["aggregator"]["as"], "100")
+ def test_aggregator_as_parsed(self):
+ raw = get_running_config(self.mock_vyos)
+ result = _device_to_argspec(raw)
+ rm = next(rm for rm in result if rm["route_map"] == "RM-TEST-EXPORT-POLICY")
+ self.assertEqual(rm["entries"][0]["set"]["aggregator"]["as_"], 100)
- def test_large_community_presence_node(self):
- result = _want_to_api_set({"large_community": "none"})
- self.assertEqual(result["large-community"], {"none": {}})
+ def test_empty_config(self):
+ self.assertEqual(_device_to_argspec({}), [])
+ self.assertEqual(_device_to_argspec(None), [])
- def test_match_peer(self):
- result = _want_to_api_match({"peer": "192.0.2.32"})
- self.assertEqual(result["peer"], "192.0.2.32")
-
-class TestVyOSRouteMapsBuildCommands(unittest.TestCase):
-
- def _have_empty(self):
- return []
-
- def _have_with_rm(self):
- return [
+class TestBuildCommands(VyOSModuleTestCase):
+ def setUp(self):
+ super().setUp()
+ self.raw = get_running_config(self.mock_vyos)
+
+ def test_merged_idempotent_against_own_fixture(self):
+ have = _device_to_argspec(self.raw)
+ self.assertEqual(build_commands(have, self.raw, "merged"), [])
+
+ def test_replaced_idempotent_against_own_fixture(self):
+ have = _device_to_argspec(self.raw)
+ self.assertEqual(build_commands(have, self.raw, "replaced"), [])
+
+ def test_overridden_idempotent_against_own_fixture(self):
+ have = _device_to_argspec(self.raw)
+ self.assertEqual(build_commands(have, self.raw, "overridden"), [])
+
+ def test_underscore_route_map_name_not_kebab_cased_on_creation(self):
+ """The primary confirmed bug this session, reproduced directly
+ end to end before the fix: "my_route_map" became
+ "my-route-map" in the generated command on first creation."""
+ config = [{"route_map": "my_route_map", "entries": [{"sequence": 10, "action": "permit"}]}]
+ cmds = build_commands(config, {}, "merged")
+ self.assertFalse(any("my-route-map" in str(c) for c in cmds))
+ self.assertTrue(any("my_route_map" in str(c) for c in cmds))
+
+ def test_replaced_scoped_to_named_route_map_only(self):
+ have = _device_to_argspec(self.raw)
+ config = [
{
- "route_map": "RM1",
- "entries": [
- {
- "sequence": 10,
- "action": "permit",
- "match": {"peer": "192.0.2.32"},
- "set": {"metric": "5", "as-path": {"exclude": "111"}},
- },
- ],
+ "route_map": "RM-TEST-EXPORT-POLICY",
+ "entries": have[0]["entries"],
},
]
+ cmds = build_commands(config, self.raw, "replaced")
+ self.assertEqual(cmds, [])
+ self.assertFalse(any("rm1" in str(c) for c in cmds))
- def test_merged_adds_new_rm(self):
+ def test_replaced_removes_omitted_field_full_replace_semantic(self):
+ """Confirms this is the intended "replaced" semantic (matching
+ every other module this session), not a bug: omitting a field
+ from a route map named in "replaced" removes it."""
config = [
{
- "route_map": "RM-NEW",
+ "route_map": "RM-TEST-EXPORT-POLICY",
"entries": [{"sequence": 10, "action": "permit"}],
},
]
- cmds = build_commands(config, self._have_empty(), "merged")
- paths = [c[1] for c in cmds]
- self.assertIn(["policy", "route-map", "RM-NEW", "rule", "10", "action", "permit"], paths)
+ cmds = build_commands(config, self.raw, "replaced")
+ self.assertIn(("delete", _BASE + ["RM-TEST-EXPORT-POLICY", "rule", "10", "set"]), cmds)
+ self.assertIn(("delete", _BASE + ["RM-TEST-EXPORT-POLICY", "rule", "10", "match"]), cmds)
- def test_merged_idempotent(self):
+ def test_overridden_deletes_omitted_route_map(self):
+ have = _device_to_argspec(self.raw)
config = [
{
- "route_map": "RM1",
- "entries": [
- {
- "sequence": 10,
- "action": "permit",
- "match": {"peer": "192.0.2.32"},
- "set": {"metric": "5", "as_path_exclude": "111"},
- },
- ],
+ "route_map": "RM-TEST-EXPORT-POLICY",
+ "entries": have[0]["entries"],
},
]
- cmds = build_commands(config, self._have_with_rm(), "merged")
- self.assertEqual(cmds, [])
+ cmds = build_commands(config, self.raw, "overridden")
+ self.assertIn(("delete", _BASE + ["rm1"]), cmds)
+
+ def test_deleted_scoped_to_named_route_map(self):
+ cmds = build_commands([{"route_map": "rm1"}], self.raw, "deleted")
+ self.assertEqual(cmds, [("delete", _BASE + ["rm1"])])
- def test_deleted_no_config_deletes_all(self):
- cmds = build_commands([], self._have_with_rm(), "deleted")
- self.assertIn(("delete", ["policy", "route-map"]), cmds)
+ def test_deleted_no_config_removes_all(self):
+ cmds = build_commands([], self.raw, "deleted")
+ self.assertEqual(cmds, [("delete", _BASE)])
- def test_deleted_with_config_deletes_named(self):
- config = [{"route_map": "RM1"}]
- cmds = build_commands(config, self._have_with_rm(), "deleted")
- self.assertIn(("delete", ["policy", "route-map", "RM1"]), cmds)
+ def test_deleted_named_nonexistent_is_noop(self):
+ cmds = build_commands([{"route_map": "NONEXISTENT"}], self.raw, "deleted")
+ self.assertEqual(cmds, [])
+
+ def test_collapsed_single_rule_no_char_iteration_bug(self):
+ raw_have = {"RM1": {"rule": "10"}}
+ config = [{"route_map": "RM1", "entries": [{"sequence": 10}]}]
+ self.assertEqual(build_commands(config, raw_have, "merged"), [])
- def test_replaced_deletes_then_resets(self):
+ def test_merged_new_rule_with_community(self):
config = [
{
- "route_map": "RM1",
- "entries": [{"sequence": 10, "action": "deny"}],
+ "route_map": "RM-NEW",
+ "entries": [
+ {
+ "sequence": 10,
+ "action": "permit",
+ "set": {"community": {"add": ["no-export"]}},
+ },
+ ],
},
]
- cmds = build_commands(config, self._have_with_rm(), "replaced")
- ops = [c[0] for c in cmds]
- # delete must come before set
- self.assertIn("delete", ops)
- self.assertIn("set", ops)
- delete_idx = ops.index("delete")
- set_idx = ops.index("set")
- self.assertLess(delete_idx, set_idx)
-
- def test_overridden_removes_extra_rm(self):
- config = [{"route_map": "RM-NEW", "entries": []}]
- have = self._have_with_rm() # has RM1
- cmds = build_commands(config, have, "overridden")
- self.assertIn(("delete", ["policy", "route-map", "RM1"]), cmds)
+ cmds = build_commands(config, {}, "merged")
+ self.assertIn(
+ ("set", _BASE + ["RM-NEW", "rule", "10", "set", "community", "add", "no-export"]),
+ cmds,
+ )
if __name__ == "__main__":
diff --git a/tests/unit/modules/test_vyos_snmp_server.py b/tests/unit/modules/test_vyos_snmp_server.py
new file mode 100644
index 0000000..4ae438e
--- /dev/null
+++ b/tests/unit/modules/test_vyos_snmp_server.py
@@ -0,0 +1,644 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import, division, print_function
+
+
+__metaclass__ = type
+
+import unittest
+
+from unittest.mock import MagicMock
+
+from ansible_collections.vyos.rest.plugins.modules.vyos_snmp_server import (
+ _DEVICE_RENAMES,
+ ARGUMENT_SPEC,
+ _derive_key_field,
+ _device_to_argspec,
+ _device_to_spec,
+ _keyed_list_from_device,
+ _keyed_list_to_device,
+ _single_from_device,
+ _single_to_device,
+ _spec_to_device,
+ _view_entry_from_device,
+ _view_entry_to_device,
+ _want_to_device,
+ build_commands,
+ get_running_config,
+)
+
+from .base import load_fixture
+
+
+_BASE = ["service", "snmp"]
+
+
+class VyOSModuleTestCase(unittest.TestCase):
+ def setUp(self):
+ self.mock_vyos = MagicMock()
+ self.fixture = load_fixture("snmp_server_running.json")
+ self.mock_vyos.get_config = MagicMock(return_value=self.fixture)
+
+
+class TestGetRunningConfig(VyOSModuleTestCase):
+ def test_returns_raw_device_dict(self):
+ self.assertEqual(get_running_config(self.mock_vyos), self.fixture)
+
+ def test_empty_config_path_error_returns_empty(self):
+ self.mock_vyos.get_config = MagicMock(
+ side_effect=Exception("Configuration under specified path is empty"),
+ )
+ self.assertEqual(get_running_config(self.mock_vyos), {})
+
+ def test_other_error_reraises(self):
+ self.mock_vyos.get_config = MagicMock(side_effect=Exception("some other error"))
+ with self.assertRaises(Exception):
+ get_running_config(self.mock_vyos)
+
+
+class TestDeviceRenames(unittest.TestCase):
+ """The one thing a purely structural walk can never infer: field
+ names that mean something different on the device, and aren't a
+ mechanical kebab<->snake conversion. Declared once here as a flat
+ value map, not embedded in ARGUMENT_SPEC and not scattered across
+ per-section functions."""
+
+ def test_confirmed_renames_present(self):
+ for arg_key, device_key in [
+ ("communities", "community"),
+ ("listen_addresses", "listen-address"),
+ ("snmp_v3", "v3"),
+ ("authorization_type", "authorization"),
+ ("clients", "client"),
+ ("networks", "network"),
+ ("authentication", "auth"),
+ ("encrypted_key", "encrypted-password"),
+ ("plaintext_key", "plaintext-password"),
+ ("engine_id", "engineid"),
+ ("groups", "group"),
+ ("users", "user"),
+ ("views", "view"),
+ ("trap_targets", "trap-target"),
+ ]:
+ self.assertEqual(_DEVICE_RENAMES.get(arg_key), device_key)
+
+
+class TestSpecToDevice(unittest.TestCase):
+ """The generic recursive walker that replaced a hand-written to-
+ device/from-device function pair for every section in this module.
+ Driven by ARGUMENT_SPEC's own structure (dict -> recurse, list with
+ options -> a named list keyed by _derive_key_field, list with no
+ options -> a plain multi-value leaf) plus _DEVICE_RENAMES for the
+ handful of non-mechanical name differences."""
+
+ def test_plain_scalar_passes_through_unrenamed(self):
+ spec = {"contact": {"type": "str"}}
+ self.assertEqual(_spec_to_device({"contact": "x"}, spec), {"contact": "x"})
+
+ def test_rename_applied_via_device_renames(self):
+ spec = {"authorization_type": {"type": "str"}}
+ result = _spec_to_device({"authorization_type": "rw"}, spec)
+ self.assertEqual(result, {"authorization": "rw"})
+
+ def test_nested_dict_recurses(self):
+ spec = {
+ "authentication": {
+ "type": "dict",
+ "options": {"type": {"type": "str"}, "encrypted_key": {"type": "str"}},
+ },
+ }
+ result = _spec_to_device(
+ {"authentication": {"type": "sha", "encrypted_key": "abc123"}},
+ spec,
+ )
+ self.assertEqual(result, {"auth": {"type": "sha", "encrypted-password": "abc123"}})
+
+ def test_named_list_keyed_by_required_field(self):
+ spec = {
+ "communities": {
+ "type": "list",
+ "options": {"name": {"type": "str", "required": True}, "port": {"type": "int"}},
+ },
+ }
+ result = _spec_to_device(
+ {"communities": [{"name": "switches", "port": 5}]},
+ spec,
+ )
+ self.assertEqual(result, {"community": {"switches": {"port": 5}}})
+
+ def test_plain_scalar_list_passes_through(self):
+ spec = {"clients": {"type": "list", "elements": "str"}}
+ result = _spec_to_device({"clients": ["1.1.1.1"]}, spec)
+ self.assertEqual(result, {"client": ["1.1.1.1"]})
+
+ def test_bool_true_is_presence(self):
+ spec = {"disable": {"type": "bool"}}
+ self.assertEqual(_spec_to_device({"disable": True}, spec), {"disable": {}})
+
+ def test_bool_false_omitted(self):
+ spec = {"disable": {"type": "bool"}}
+ self.assertEqual(_spec_to_device({"disable": False}, spec), {})
+
+ def test_non_dict_value_passes_through(self):
+ self.assertEqual(_spec_to_device("not-a-dict", {}), "not-a-dict")
+
+
+class TestDeviceToSpec(unittest.TestCase):
+ """The reverse of _spec_to_device -- same structural rules, same
+ single source of truth for renames."""
+
+ def test_mechanical_field_matched_via_hyphen_normalization(self):
+ spec = {"local_stratum": {"type": "str"}}
+ result = _device_to_spec({"local-stratum": "5"}, spec)
+ self.assertEqual(result, {"local_stratum": "5"})
+
+ def test_renamed_field_matched_via_device_renames(self):
+ spec = {"authorization_type": {"type": "str"}}
+ result = _device_to_spec({"authorization": "rw"}, spec)
+ self.assertEqual(result, {"authorization_type": "rw"})
+
+ def test_nested_dict_recurses(self):
+ spec = {
+ "authentication": {
+ "type": "dict",
+ "options": {"encrypted_key": {"type": "str"}},
+ },
+ }
+ result = _device_to_spec({"auth": {"encrypted-password": "abc123"}}, spec)
+ self.assertEqual(result, {"authentication": {"encrypted_key": "abc123"}})
+
+ def test_named_list_keyed_by_required_field(self):
+ spec = {
+ "communities": {
+ "type": "list",
+ "options": {"name": {"type": "str", "required": True}, "port": {"type": "int"}},
+ },
+ }
+ result = _device_to_spec({"community": {"switches": {"port": "5"}}}, spec)
+ self.assertEqual(result, {"communities": [{"name": "switches", "port": "5"}]})
+
+ def test_plain_scalar_list_sorted_and_collapse_safe(self):
+ spec = {"clients": {"type": "list", "elements": "str"}}
+ result = _device_to_spec({"client": "1.1.1.1"}, spec)
+ self.assertEqual(result, {"clients": ["1.1.1.1"]})
+
+ def test_presence_dict_becomes_bool(self):
+ spec = {"disable": {"type": "bool"}}
+ self.assertEqual(_device_to_spec({"disable": {}}, spec), {"disable": True})
+
+ def test_empty_or_non_dict_raw(self):
+ self.assertEqual(_device_to_spec({}, {}), {})
+ self.assertEqual(_device_to_spec(None, {}), {})
+ self.assertEqual(_device_to_spec("not-a-dict", {}), {})
+
+
+class TestKeyedListHelper(unittest.TestCase):
+ """The generic mechanic every named-list section shares: a list of
+ dicts identified by one field becomes a device dict keyed by that
+ field's value. This used to be reimplemented six separate times."""
+
+ def test_to_device_default_transform_is_autoclean(self):
+ result = _keyed_list_to_device([{"group": "admins", "mode": "rw"}], "group")
+ self.assertEqual(result, {"admins": {"mode": "rw"}})
+
+ def test_to_device_skips_entries_missing_key_field(self):
+ result = _keyed_list_to_device([{"mode": "rw"}], "group")
+ self.assertEqual(result, {})
+
+ def test_to_device_custom_entry_transform_receives_rest_only(self):
+ seen = {}
+
+ def transform(rest):
+ seen.update(rest)
+ return rest
+
+ _keyed_list_to_device([{"name": "switches", "authorization_type": "rw"}], "name", transform)
+ self.assertNotIn("name", seen)
+ self.assertEqual(seen, {"authorization_type": "rw"})
+
+ def test_from_device_default_transform_is_from_device(self):
+ result = _keyed_list_from_device({"admins": {"mode": "rw"}}, "group")
+ self.assertEqual(result, [{"group": "admins", "mode": "rw"}])
+
+ def test_from_device_bare_string_collapse(self):
+ result = _keyed_list_from_device("admins", "group")
+ self.assertEqual(result, [{"group": "admins"}])
+
+ def test_empty(self):
+ self.assertEqual(_keyed_list_to_device([], "group"), {})
+ self.assertEqual(_keyed_list_to_device(None, "group"), {})
+ self.assertEqual(_keyed_list_from_device({}, "group"), [])
+ self.assertEqual(_keyed_list_from_device(None, "group"), [])
+
+
+class TestCommunity(unittest.TestCase):
+ """authorization_type->authorization and clients/networks->
+ client/network are genuine renames (in _DEVICE_RENAMES, not
+ embedded in ARGUMENT_SPEC); both member fields are confirmed plain
+ multi-value leaves, passed straight through. Tested via the
+ generic walker directly against communities' own entry options,
+ since there's no bespoke per-entry function anymore."""
+
+ def setUp(self):
+ self.entry_options = ARGUMENT_SPEC["config"]["options"]["communities"]["options"]
+
+ def test_to_device_authorization_rename(self):
+ result = _spec_to_device({"authorization_type": "rw"}, self.entry_options)
+ self.assertEqual(result, {"authorization": "rw"})
+
+ def test_to_device_clients_networks_rename(self):
+ result = _spec_to_device(
+ {"clients": ["1.1.1.1"], "networks": ["10.0.0.0/8"]},
+ self.entry_options,
+ )
+ self.assertEqual(result, {"client": ["1.1.1.1"], "network": ["10.0.0.0/8"]})
+
+ def test_from_device(self):
+ entry = _device_to_spec(
+ {"client": ["1.1.1.1", "12.1.1.10"], "authorization": "ro"},
+ self.entry_options,
+ )
+ self.assertEqual(entry["clients"], ["1.1.1.1", "12.1.1.10"])
+ self.assertEqual(entry["authorization_type"], "ro")
+
+ def test_from_device_single_client_collapse(self):
+ entry = _device_to_spec({"client": "1.1.1.1"}, self.entry_options)
+ self.assertEqual(entry["clients"], ["1.1.1.1"])
+
+ def test_full_pipeline_via_keyed_list_helper(self):
+ """Confirms the entry-transform and the generic keying mechanic
+ compose correctly end to end, matching how _spec_to_device
+ itself calls them for any named-list section."""
+ result = _keyed_list_to_device(
+ [{"name": "switches", "authorization_type": "rw"}],
+ "name",
+ lambda rest: _spec_to_device(rest, self.entry_options),
+ )
+ self.assertEqual(result, {"switches": {"authorization": "rw"}})
+
+
+class TestDeriveKeyField(unittest.TestCase):
+ """key_field is derived from each section's argspec, not
+ hand-declared -- every named-list section marks exactly one
+ suboption required=True (you can't create a community without a
+ name, and so on), so that's the field identifying each entry."""
+
+ def test_derives_the_single_required_field(self):
+ self.assertEqual(
+ _derive_key_field({"name": {"required": True}, "clients": {"type": "list"}}),
+ "name",
+ )
+
+ def test_raises_if_none_required(self):
+ with self.assertRaises(ValueError):
+ _derive_key_field({"clients": {"type": "list"}})
+
+ def test_raises_if_more_than_one_required(self):
+ with self.assertRaises(ValueError):
+ _derive_key_field({"a": {"required": True}, "b": {"required": True}})
+
+
+class TestTrapTarget(unittest.TestCase):
+ """Confirmed a genuine tagNode keyed by address on the device, but
+ the argspec models only a single object -- a documented limitation
+ (the device supports multiple), preserved as-is. Reuses the same
+ generic keyed-list mechanic as "a list capped to one entry" rather
+ than a bespoke pair of functions."""
+
+ def test_to_device_keyed_by_address(self):
+ result = _single_to_device({"address": "203.0.113.5", "community": "public"}, "address")
+ self.assertEqual(result, {"203.0.113.5": {"community": "public"}})
+
+ def test_to_device_no_address_is_noop(self):
+ self.assertEqual(_single_to_device({}, "address"), {})
+ self.assertEqual(_single_to_device(None, "address"), {})
+
+ def test_from_device(self):
+ entry = _single_from_device(
+ {"203.0.113.5": {"community": "public", "port": "162"}},
+ "address",
+ )
+ self.assertEqual(entry["address"], "203.0.113.5")
+ self.assertEqual(entry["community"], "public")
+
+ def test_from_device_bare_string_collapse(self):
+ entry = _single_from_device("203.0.113.5", "address")
+ self.assertEqual(entry, {"address": "203.0.113.5"})
+
+ def test_from_device_empty_is_none(self):
+ self.assertIsNone(_single_from_device(None, "address"))
+ self.assertIsNone(_single_from_device({}, "address"))
+
+
+class TestV3View(unittest.TestCase):
+ """The confirmed structural bug: "oid" is a genuine tag node (keyed
+ by the OID value) with its own exclude/mask children -- the
+ previous implementation read exclude/mask from the wrong nesting
+ level (directly under the view) and only handled a single oid key
+ via list(oid_data.keys())[0], silently dropping any others. Like
+ community, the entry-transform receives only the dict's "rest"
+ (the key field "view" is stripped by the generic helper first)."""
+
+ def test_to_device_oid_is_nested_tag_node(self):
+ result = _view_entry_to_device({"oid": "1.3.6.1", "mask": "ff"})
+ self.assertEqual(result, {"oid": {"1.3.6.1": {"mask": "ff"}}})
+
+ def test_to_device_exclude_nested_under_oid_not_view(self):
+ result = _view_entry_to_device({"oid": "1.3.6.1", "exclude": "1.3.6.1.9"})
+ self.assertEqual(result, {"oid": {"1.3.6.1": {"exclude": ["1.3.6.1.9"]}}})
+
+ def test_to_device_no_oid_is_empty(self):
+ self.assertEqual(_view_entry_to_device({}), {})
+
+ def test_from_device_reads_exclude_mask_from_oid_level(self):
+ """Regression test for the confirmed bug: exclude/mask must be
+ read from data["oid"][<value>], not data directly."""
+ entry = _view_entry_from_device(
+ {"oid": {"1.3.6.1": {"exclude": ["1.3.6.1.9"], "mask": "ff.ff"}}},
+ )
+ self.assertEqual(entry["oid"], "1.3.6.1")
+ self.assertEqual(entry["exclude"], "1.3.6.1.9")
+ self.assertEqual(entry["mask"], "ff.ff")
+
+ def test_from_device_bare_oid_string_collapse(self):
+ entry = _view_entry_from_device({"oid": "1.3.6.1"})
+ self.assertEqual(entry["oid"], "1.3.6.1")
+ self.assertNotIn("exclude", entry)
+
+ def test_from_device_no_oid(self):
+ entry = _view_entry_from_device({})
+ self.assertEqual(entry, {})
+
+
+class TestWantToDevice(unittest.TestCase):
+ def test_empty(self):
+ self.assertEqual(_want_to_device({}), {})
+ self.assertEqual(_want_to_device(None), {})
+
+ def test_engine_id_rename(self):
+ """Confirmed bug: "engineid" (device, one word) vs "engine_id"
+ (argspec) is not a mechanical kebab<->snake conversion since
+ there's no hyphen to split -- a genuine rename exception."""
+ result = _want_to_device({"snmp_v3": {"engine_id": "0002"}})
+ self.assertEqual(result["v3"]["engineid"], "0002")
+ self.assertNotIn("engine_id", result["v3"])
+
+ def test_communities_keyed_by_name(self):
+ config = {"communities": [{"name": "switches", "authorization_type": "rw"}]}
+ result = _want_to_device(config)
+ self.assertEqual(result["community"]["switches"], {"authorization": "rw"})
+
+ def test_generic_scalar_fields(self):
+ result = _want_to_device({"contact": "admin@example.com", "location": "DC1"})
+ self.assertEqual(result, {"contact": "admin@example.com", "location": "DC1"})
+
+
+class TestDeviceToArgspecFixture(VyOSModuleTestCase):
+ def test_communities_parsed(self):
+ have = _device_to_argspec(self.fixture)
+ names = {c["name"] for c in have["communities"]}
+ self.assertEqual(names, {"switches", "bridges"})
+
+ def test_engine_id_parsed(self):
+ have = _device_to_argspec(self.fixture)
+ self.assertEqual(have["snmp_v3"]["engine_id"], "000000000000000000000002")
+
+ def test_v3_user_authentication_parsed(self):
+ have = _device_to_argspec(self.fixture)
+ user = have["snmp_v3"]["users"][0]
+ self.assertEqual(user["authentication"]["type"], "sha")
+ self.assertEqual(user["authentication"]["encrypted_key"], "abc123")
+
+ def test_v3_view_oid_parsed(self):
+ have = _device_to_argspec(self.fixture)
+ view = have["snmp_v3"]["views"][0]
+ self.assertEqual(view["oid"], "1")
+
+ def test_trap_target_parsed(self):
+ have = _device_to_argspec(self.fixture)
+ self.assertEqual(have["trap_target"]["address"], "203.0.113.5")
+ self.assertEqual(have["trap_target"]["community"], "public")
+ self.assertEqual(have["trap_target"]["port"], 162)
+
+ def test_v3_trap_targets_parsed(self):
+ have = _device_to_argspec(self.fixture)
+ target = have["snmp_v3"]["trap_targets"][0]
+ self.assertEqual(target["address"], "198.51.100.5")
+ self.assertEqual(target["protocol"], "udp")
+ self.assertEqual(target["authentication"]["type"], "sha")
+
+ 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 = _device_to_argspec(self.fixture)
+ self.assertEqual(build_commands(have, self.fixture, "merged"), [])
+
+ def test_replaced_idempotent_against_own_fixture(self):
+ have = _device_to_argspec(self.fixture)
+ self.assertEqual(build_commands(have, self.fixture, "replaced"), [])
+
+ def test_overridden_idempotent_against_own_fixture(self):
+ have = _device_to_argspec(self.fixture)
+ self.assertEqual(build_commands(have, self.fixture, "overridden"), [])
+
+ def test_underscore_username_not_kebab_cased_on_creation(self):
+ """Confirmed real bug: dict_op's fallback for a key missing from
+ have assumed every key is a translatable schema field name --
+ but a brand-new tag-node entry (username, community name, any
+ user-supplied identifier) is opaque data, and "admin_user" was
+ silently becoming "admin-user" in the generated command on
+ first creation, before verbatim_keys was wired in."""
+ cmds = build_commands(
+ {"snmp_v3": {"users": [{"user": "admin_user", "group": "admins"}]}},
+ {},
+ "merged",
+ )
+ self.assertTrue(any("admin_user" in c[1] for c in cmds))
+ self.assertFalse(any("admin-user" in c[1] for c in cmds))
+
+ def test_underscore_names_verbatim_across_every_tag_node_section(self):
+ """Same regression, covering every section with an opaque
+ tag-node key in this module, not just v3 users."""
+ config = {
+ "communities": [{"name": "my_community"}],
+ "snmp_v3": {
+ "groups": [{"group": "my_group"}],
+ "views": [{"view": "my_view", "oid": "1.3.6.1"}],
+ "trap_targets": [{"address": "198.51.100.5"}],
+ },
+ }
+ cmds = build_commands(config, {}, "merged")
+ joined = [str(c[1]) for c in cmds]
+ self.assertTrue(any("my_community" in p for p in joined))
+ self.assertTrue(any("my_group" in p for p in joined))
+ self.assertTrue(any("my_view" in p for p in joined))
+ self.assertFalse(any("my-community" in p for p in joined))
+ self.assertFalse(any("my-group" in p for p in joined))
+ self.assertFalse(any("my-view" in p for p in joined))
+
+ def test_underscore_username_removed_verbatim_on_replaced(self):
+ """The purge path (replaced/overridden) must also match and
+ delete the opaque key verbatim, not a kebab-cased guess."""
+ raw_have = {"v3": {"user": {"admin_user": {"group": "admins"}}}}
+ cmds = build_commands({"snmp_v3": {"users": []}}, raw_have, "replaced")
+ self.assertIn(("delete", ["service", "snmp", "v3", "user", "admin_user"]), cmds)
+
+ def test_replaced_does_not_purge_credential_without_new_password(self):
+ """Confirmed real device-rejected commit: VyOS requires an
+ auth/privacy node to carry an encrypted-password or plaintext-
+ password whenever it exists at all. A "replaced" config update
+ that changes an unrelated field (or nothing) without
+ re-supplying a password -- which the user can never read back
+ to re-supply -- must not purge the existing credential out from
+ under it, or the commit is rejected entirely."""
+ raw_have = {
+ "v3": {
+ "user": {
+ "admin_user": {
+ "auth": {"type": "sha", "encrypted-password": "hash1"},
+ "privacy": {"type": "aes", "encrypted-password": "hash2"},
+ "group": "admins",
+ },
+ },
+ },
+ }
+ config = {
+ "snmp_v3": {
+ "users": [
+ {
+ "user": "admin_user",
+ "group": "admins",
+ "authentication": {"type": "sha"},
+ "privacy": {"type": "aes"},
+ },
+ ],
+ },
+ }
+ cmds = build_commands(config, raw_have, "replaced")
+ self.assertFalse(any("encrypted-password" in str(c) for c in cmds))
+
+ def test_replaced_still_sets_a_genuinely_new_password(self):
+ """The credential-protection fix must not mask an intentional
+ password change -- only fill in what's missing."""
+ raw_have = {
+ "v3": {
+ "user": {
+ "admin_user": {
+ "auth": {"type": "sha", "encrypted-password": "hash1"},
+ "group": "admins",
+ },
+ },
+ },
+ }
+ config = {
+ "snmp_v3": {
+ "users": [
+ {
+ "user": "admin_user",
+ "group": "admins",
+ "authentication": {"type": "sha", "plaintext_key": "newpass"},
+ },
+ ],
+ },
+ }
+ cmds = build_commands(config, raw_have, "replaced")
+ expected = (
+ "set",
+ [
+ "service",
+ "snmp",
+ "v3",
+ "user",
+ "admin_user",
+ "auth",
+ "plaintext-password",
+ "newpass",
+ ],
+ )
+ self.assertIn(expected, cmds)
+
+ def test_plaintext_password_write_path(self):
+ """The primary confirmed bug fix, exercised end to end: the
+ device path must use plaintext-password, not plaintext-key."""
+ config = {
+ "snmp_v3": {
+ "users": [
+ {
+ "user": "newuser",
+ "authentication": {"type": "sha", "plaintext_key": "abc1234567"},
+ },
+ ],
+ },
+ }
+ cmds = build_commands(config, {}, "merged")
+ self.assertIn(
+ ("set", _BASE + ["v3", "user", "newuser", "auth", "plaintext-password", "abc1234567"]),
+ cmds,
+ )
+ self.assertTrue(all("plaintext-key" not in c[1] for c in cmds))
+
+ def test_replaced_scoped_to_named_sections_only(self):
+ """Regression test for the three-way key-name-collision bug this
+ session's investigation found (community/view/group each mean a
+ tag node at one level and an unrelated scalar leaf at another) --
+ replaced must not touch an unrelated section, and must not
+ crash comparing a scalar have value as if it were a dict."""
+ raw_have = {
+ "community": {"switches": {"authorization": "rw"}, "bridges": {"client": ["1.1.1.1"]}},
+ "contact": "old@example.com",
+ }
+ config = {"communities": [{"name": "switches", "authorization_type": "rw"}]}
+ cmds = build_commands(config, raw_have, "replaced")
+ self.assertIn(("delete", _BASE + ["community", "bridges"]), cmds)
+ self.assertTrue(all(c[1][: len(_BASE) + 1] != _BASE + ["contact"] for c in cmds))
+
+ def test_overridden_removes_omitted_scalar_field(self):
+ raw_have = {"contact": "old@example.com", "community": {"switches": {}}}
+ config = {"communities": [{"name": "switches"}]}
+ cmds = build_commands(config, raw_have, "overridden")
+ self.assertIn(("delete", _BASE + ["contact"]), cmds)
+
+ def test_deleted_no_have_is_noop(self):
+ self.assertEqual(build_commands({}, {}, "deleted"), [])
+
+ def test_deleted_with_have(self):
+ self.assertEqual(build_commands({}, {"contact": "x"}, "deleted"), [("delete", _BASE)])
+
+ def test_collapsed_v3_group_no_char_iteration_bug(self):
+ """A single v3 group with no other config, collapsed by the
+ device to a bare group-name string, must not be iterated
+ character-by-character."""
+ raw_have = {"v3": {"group": "admins"}}
+ config = {"snmp_v3": {"groups": [{"group": "admins"}]}}
+ self.assertEqual(build_commands(config, raw_have, "merged"), [])
+
+ def test_collapsed_trap_target_no_char_iteration_bug(self):
+ raw_have = {"trap-target": "203.0.113.5"}
+ config = {"trap_target": {"address": "203.0.113.5"}}
+ self.assertEqual(build_commands(config, raw_have, "merged"), [])
+
+ def test_v3_group_view_scalar_not_confused_with_v3_view_tag_node(self):
+ """Regression test: v3.group.<name>.view (a scalar leaf naming
+ which view the group uses) must never be coerced into a
+ presence-dict just because "view" is also a genuine tag node
+ one level up, under v3 itself."""
+ raw_have = {"v3": {"group": {"admins": {"view": "all"}}}}
+ config = {"snmp_v3": {"groups": [{"group": "admins", "view": "all"}]}}
+ self.assertEqual(build_commands(config, raw_have, "merged"), [])
+
+ def test_v3_user_group_scalar_not_confused_with_v3_group_tag_node(self):
+ raw_have = {"v3": {"user": {"admin_user": {"group": "admins"}}}}
+ config = {"snmp_v3": {"users": [{"user": "admin_user", "group": "admins"}]}}
+ self.assertEqual(build_commands(config, raw_have, "merged"), [])
+
+ def test_trap_target_community_scalar_not_confused_with_community_tag_node(self):
+ raw_have = {"trap-target": {"203.0.113.5": {"community": "public"}}}
+ config = {"trap_target": {"address": "203.0.113.5", "community": "public"}}
+ self.assertEqual(build_commands(config, raw_have, "merged"), [])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/unit/modules/test_vyos_system.py b/tests/unit/modules/test_vyos_system.py
new file mode 100644
index 0000000..9345b57
--- /dev/null
+++ b/tests/unit/modules/test_vyos_system.py
@@ -0,0 +1,93 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import, division, print_function
+
+
+__metaclass__ = type
+
+import unittest
+
+from ansible_collections.vyos.rest.plugins.module_utils.vyos import (
+ dict_op,
+ owned_config,
+)
+from ansible_collections.vyos.rest.plugins.modules.vyos_system import (
+ _BASE,
+ ARGUMENT_SPEC,
+)
+
+from .base import load_fixture
+
+
+class TestOwnedConfig(unittest.TestCase):
+
+ def setUp(self):
+ self.fixture = load_fixture("system_running.json")
+
+ def test_filters_to_owned_keys(self):
+ result = owned_config(self.fixture, ARGUMENT_SPEC)
+ self.assertIn("host-name", result)
+ self.assertIn("domain-name", result)
+ self.assertIn("name-server", result)
+
+ def test_excludes_non_owned_keys(self):
+ result = owned_config(self.fixture, ARGUMENT_SPEC)
+ self.assertNotIn("config-management", result)
+ self.assertNotIn("console", result)
+ self.assertNotIn("login", result)
+ self.assertNotIn("syslog", result)
+
+
+class TestDictOp(unittest.TestCase):
+
+ def _have(self):
+ return {
+ "host-name": "vyos150",
+ "domain-name": "lab.example.com",
+ "name-server": ["8.8.8.8", "8.8.4.4"],
+ }
+
+ def test_set_idempotent(self):
+ want = {
+ "host_name": "vyos150",
+ "domain_name": "lab.example.com",
+ "name_server": ["8.8.8.8", "8.8.4.4"],
+ }
+ cmds = dict_op(want, self._have(), _BASE, op="set")
+ self.assertEqual(cmds, [])
+
+ def test_set_new_value(self):
+ want = {"domain_name": "new.example.com"}
+ cmds = dict_op(want, self._have(), _BASE, op="set")
+ self.assertIn(("set", ["system", "domain-name", "new.example.com"]), cmds)
+
+ def test_set_new_nameserver(self):
+ want = {"name_server": ["8.8.8.8", "8.8.4.4", "1.1.1.1"]}
+ cmds = dict_op(want, self._have(), _BASE, op="set")
+ self.assertIn(("set", ["system", "name-server", "1.1.1.1"]), cmds)
+ self.assertNotIn(("set", ["system", "name-server", "8.8.8.8"]), cmds)
+
+ def test_delete_scalar(self):
+ want = {"domain_name": "lab.example.com"}
+ cmds = dict_op(want, self._have(), _BASE, op="delete")
+ self.assertIn(("delete", ["system", "domain-name"]), cmds)
+
+ def test_delete_list_item(self):
+ want = {"name_server": ["8.8.8.8"]}
+ cmds = dict_op(want, self._have(), _BASE, op="delete")
+ self.assertIn(("delete", ["system", "name-server", "8.8.8.8"]), cmds)
+ self.assertNotIn(("delete", ["system", "name-server", "8.8.4.4"]), cmds)
+
+ def test_delete_nonexistent(self):
+ want = {"domain_name": "other.com"}
+ have = {"host-name": "vyos150"}
+ cmds = dict_op(want, have, _BASE, op="delete")
+ self.assertEqual(cmds, [])
+
+ def test_set_missing_key(self):
+ want = {"host_name": "vyos150"}
+ cmds = dict_op(want, {}, _BASE, op="set")
+ self.assertIn(("set", ["system", "host-name", "vyos150"]), cmds)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/unit/modules/test_vyos_user.py b/tests/unit/modules/test_vyos_user.py
index 511ecef..32459ca 100644
--- a/tests/unit/modules/test_vyos_user.py
+++ b/tests/unit/modules/test_vyos_user.py
@@ -4,169 +4,239 @@ 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_user import (
+ _device_to_argspec,
+ _public_keys_from_device,
+ _public_keys_to_device,
+ _user_from_device,
+ _user_to_device,
build_commands,
get_running_config,
)
-
-_BASE = ["system", "login", "user"]
+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 = ["system", "login", "user"]
class VyOSModuleTestCase(unittest.TestCase):
def setUp(self):
self.mock_vyos = MagicMock()
- self.fixture = load_fixture("user_running.json")
- self.mock_vyos.get_config = MagicMock(return_value=self.fixture)
-
+ fixture = load_fixture("user_running.json")
+ self.fixture = fixture.get("user", fixture)
+ self.mock_vyos.get_config = MagicMock(return_value={"user": self.fixture})
-class TestVyOSUserGetRunning(VyOSModuleTestCase):
- def test_parses_users(self):
+class TestGetRunningConfig(VyOSModuleTestCase):
+ def test_unwraps_user_key(self):
result = get_running_config(self.mock_vyos)
- names = [u["name"] for u in result]
- self.assertIn("vyos", names)
- self.assertIn("alice", names)
+ self.assertIn("alice", result)
+ self.assertIn("vyos", result)
- def test_parses_full_name(self):
- result = get_running_config(self.mock_vyos)
- alice = next(u for u in result if u["name"] == "alice")
- self.assertEqual(alice["full_name"], "Alice Smith")
+ def test_empty_config(self):
+ self.mock_vyos.get_config = MagicMock(return_value=None)
+ self.assertEqual(get_running_config(self.mock_vyos), {})
- def test_parses_encrypted_password(self):
- result = get_running_config(self.mock_vyos)
- alice = next(u for u in result if u["name"] == "alice")
- self.assertEqual(alice["encrypted_password"], "$6$def456")
- def test_parses_public_keys(self):
- result = get_running_config(self.mock_vyos)
- alice = next(u for u in result if u["name"] == "alice")
- self.assertEqual(len(alice["public_keys"]), 1)
- key = alice["public_keys"][0]
- self.assertEqual(key["name"], "alice-laptop")
- self.assertEqual(key["type"], "ssh-rsa")
- self.assertEqual(key["key"], "AAAAB3NzaC1yc2EAAAA")
+class TestPublicKeysToDeviceFromDevice(unittest.TestCase):
+ def test_to_device(self):
+ result = _public_keys_to_device([{"name": "laptop", "key": "AAAA", "type": "ssh-rsa"}])
+ self.assertEqual(result, {"laptop": {"key": "AAAA", "type": "ssh-rsa"}})
+
+ def test_from_device(self):
+ result = _public_keys_from_device({"laptop": {"key": "AAAA", "type": "ssh-rsa"}})
+ self.assertEqual(result, [{"name": "laptop", "key": "AAAA", "type": "ssh-rsa"}])
+
+ def test_empty(self):
+ self.assertEqual(_public_keys_to_device([]), {})
+ self.assertEqual(_public_keys_from_device({}), [])
- def test_empty_config(self):
- self.mock_vyos.get_config = MagicMock(return_value={})
- result = get_running_config(self.mock_vyos)
- self.assertEqual(result, [])
+class TestUserToDeviceFromDevice(unittest.TestCase):
+ """Password is the critical case here: it must NEVER appear in
+ _user_to_device's output (it's handled separately, outside dict_op,
+ since it can't be compared against have's encrypted-password)."""
-class TestVyOSUserBuildCommands(unittest.TestCase):
+ def test_password_never_enters_dict_op_path(self):
+ result = _user_to_device({"name": "alice", "password": "secret", "full_name": "Alice"})
+ self.assertNotIn("password", result)
+ self.assertNotIn("plaintext-password", str(result))
+ self.assertEqual(result, {"full_name": "Alice"})
- def _have(self):
- return [
- {"name": "vyos", "encrypted_password": "$6$abc123"},
+ def test_update_password_never_enters_dict_op_path(self):
+ result = _user_to_device({"name": "alice", "update_password": "on_create"})
+ self.assertEqual(result, {})
+
+ def test_public_keys_wrapped_under_authentication(self):
+ result = _user_to_device(
{
"name": "alice",
- "full_name": "Alice Smith",
- "encrypted_password": "$6$def456",
+ "public_keys": [{"name": "laptop", "key": "AAAA", "type": "ssh-rsa"}],
},
- ]
+ )
+ self.assertEqual(
+ result,
+ {"authentication": {"public_keys": {"laptop": {"key": "AAAA", "type": "ssh-rsa"}}}},
+ )
- def test_present_new_user_with_password(self):
- users = [
- {
- "name": "bob",
- "full_name": "Bob Jones",
- "password": "secret",
- "update_password": "always",
- },
- ]
- cmds = build_commands(users, self._have(), "present")
- self.assertIn(("set", _BASE + ["bob", "full-name", "Bob Jones"]), cmds)
- self.assertIn(
- ("set", _BASE + ["bob", "authentication", "plaintext-password", "secret"]),
- cmds,
+ def test_from_device_encrypted_password_surfaces_as_fact_only(self):
+ entry = _user_from_device("alice", {"authentication": {"encrypted-password": "hash1"}})
+ self.assertEqual(entry["encrypted_password"], "hash1")
+ self.assertNotIn("password", entry)
+
+ def test_from_device_plaintext_password_placeholder_ignored(self):
+ """VyOS's write-only placeholder (an empty plaintext-password
+ marker) must never surface in the argspec-facing output."""
+ entry = _user_from_device(
+ "vyos",
+ {"authentication": {"encrypted-password": "hash1", "plaintext-password": ""}},
)
+ self.assertNotIn("plaintext_password", entry)
+ self.assertNotIn("password", entry)
+
+ def test_from_device_with_public_keys(self):
+ entry = _user_from_device(
+ "alice",
+ {"authentication": {"public-keys": {"laptop": {"key": "AAAA", "type": "ssh-rsa"}}}},
+ )
+ self.assertEqual(
+ entry["public_keys"],
+ [{"name": "laptop", "key": "AAAA", "type": "ssh-rsa"}],
+ )
+
+
+class TestDeviceToArgspecFixture(VyOSModuleTestCase):
+ def test_alice_full_name_and_keys(self):
+ have = _device_to_argspec(self.fixture)
+ alice = next(u for u in have if u["name"] == "alice")
+ self.assertEqual(alice["full_name"], "Alice Smith")
+ self.assertEqual(alice["encrypted_password"], "$6$def456")
+ self.assertEqual(alice["public_keys"][0]["name"], "alice-laptop")
- def test_present_update_password_always(self):
- users = [{"name": "alice", "password": "newpass", "update_password": "always"}]
- cmds = build_commands(users, self._have(), "present")
+ def test_vyos_user_present_no_plaintext_leak(self):
+ have = _device_to_argspec(self.fixture)
+ vyos_user = next(u for u in have if u["name"] == "vyos")
+ self.assertNotIn("password", vyos_user)
+ self.assertEqual(vyos_user["encrypted_password"], "$6$abc123")
+
+ def test_empty_config(self):
+ self.assertEqual(_device_to_argspec({}), [])
+ self.assertEqual(_device_to_argspec(None), [])
+
+
+class TestBuildCommands(VyOSModuleTestCase):
+ """Password policy is the module's core correctness risk -- covered
+ heavily here since it can never be validated via idempotency
+ (there's no way to compare plaintext to a hash)."""
+
+ def test_present_idempotent_without_password(self):
+ have = _device_to_argspec(self.fixture)
+ # drop encrypted_password/keys not settable via argspec anyway;
+ # use only what a user would actually pass back in
+ users = [{"name": u["name"], "full_name": u.get("full_name")} for u in have]
+ cmds = build_commands(users, self.fixture, "present")
+ self.assertEqual(cmds, [])
+
+ def test_update_password_always_resets_existing_user(self):
+ cmds = build_commands(
+ [{"name": "alice", "password": "newpass", "update_password": "always"}],
+ self.fixture,
+ "present",
+ )
self.assertIn(
("set", _BASE + ["alice", "authentication", "plaintext-password", "newpass"]),
cmds,
)
- def test_present_update_password_on_create_existing(self):
- users = [{"name": "alice", "password": "newpass", "update_password": "on_create"}]
- cmds = build_commands(users, self._have(), "present")
- paths = [c[1] for c in cmds]
- self.assertNotIn(
- _BASE + ["alice", "authentication", "plaintext-password", "newpass"],
- paths,
+ def test_update_password_on_create_skips_existing_user(self):
+ cmds = build_commands(
+ [{"name": "alice", "password": "newpass", "update_password": "on_create"}],
+ self.fixture,
+ "present",
)
+ self.assertTrue(all("plaintext-password" not in c[1] for c in cmds))
- def test_present_update_password_on_create_new(self):
- users = [{"name": "bob", "password": "secret", "update_password": "on_create"}]
- cmds = build_commands(users, self._have(), "present")
+ def test_update_password_on_create_sets_for_new_user(self):
+ cmds = build_commands(
+ [{"name": "bob", "password": "newpass", "update_password": "on_create"}],
+ self.fixture,
+ "present",
+ )
self.assertIn(
- ("set", _BASE + ["bob", "authentication", "plaintext-password", "secret"]),
+ ("set", _BASE + ["bob", "authentication", "plaintext-password", "newpass"]),
cmds,
)
- def test_present_idempotent_full_name(self):
- users = [{"name": "alice", "full_name": "Alice Smith"}]
- cmds = build_commands(users, self._have(), "present")
- self.assertEqual(cmds, [])
-
- def test_present_update_full_name(self):
- users = [{"name": "alice", "full_name": "Alice Updated"}]
- cmds = build_commands(users, self._have(), "present")
+ def test_default_update_password_is_always(self):
+ """default of 'always' must re-set even without explicit
+ update_password, matching the argspec default."""
+ cmds = build_commands([{"name": "alice", "password": "newpass"}], self.fixture, "present")
self.assertIn(
- ("set", _BASE + ["alice", "full-name", "Alice Updated"]),
+ ("set", _BASE + ["alice", "authentication", "plaintext-password", "newpass"]),
cmds,
)
- def test_absent_existing_user(self):
- users = [{"name": "alice"}]
- cmds = build_commands(users, self._have(), "absent")
- self.assertIn(("delete", _BASE + ["alice"]), cmds)
+ def test_no_password_never_sets_plaintext(self):
+ cmds = build_commands(
+ [{"name": "alice", "full_name": "Alice Smith"}],
+ self.fixture,
+ "present",
+ )
+ self.assertTrue(all("plaintext-password" not in c[1] for c in cmds))
- def test_absent_nonexistent_user(self):
- users = [{"name": "bob"}]
- cmds = build_commands(users, self._have(), "absent")
+ def test_vyos_user_never_deleted(self):
+ cmds = build_commands([{"name": "vyos"}], self.fixture, "absent")
self.assertEqual(cmds, [])
- def test_present_public_key(self):
- users = [
- {
- "name": "alice",
- "public_keys": [
- {"name": "new-key", "key": "AAAAB3...", "type": "ssh-ed25519"},
- ],
- },
- ]
- cmds = build_commands(users, self._have(), "present")
- self.assertIn(
- (
- "set",
- _BASE + ["alice", "authentication", "public-keys", "new-key", "key", "AAAAB3..."],
- ),
- cmds,
+ def test_absent_deletes_named_existing_user(self):
+ cmds = build_commands([{"name": "alice"}], self.fixture, "absent")
+ self.assertEqual(cmds, [("delete", _BASE + ["alice"])])
+
+ def test_absent_skips_nonexistent_user(self):
+ cmds = build_commands([{"name": "nobody"}], self.fixture, "absent")
+ self.assertEqual(cmds, [])
+
+ def test_present_adds_new_public_key_without_removing_others(self):
+ """present is additive-only: adding a key for an existing user
+ must not touch other existing fields."""
+ cmds = build_commands(
+ [
+ {
+ "name": "alice",
+ "public_keys": [
+ {"name": "alice-desktop", "key": "BBBB", "type": "ssh-ed25519"},
+ ],
+ },
+ ],
+ self.fixture,
+ "present",
)
self.assertIn(
(
"set",
_BASE
- + ["alice", "authentication", "public-keys", "new-key", "type", "ssh-ed25519"],
+ + [
+ "alice",
+ "authentication",
+ "public-keys",
+ "alice-desktop",
+ "key",
+ "BBBB",
+ ],
),
cmds,
)
+ def test_collapsed_single_public_key_no_char_iteration_bug(self):
+ raw_have = {"alice": {"authentication": {"public-keys": "alice-laptop"}}}
+ users = [{"name": "alice", "public_keys": [{"name": "alice-laptop"}]}]
+ self.assertEqual(build_commands(users, raw_have, "present"), [])
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/unit/modules/test_vyos_vlan.py b/tests/unit/modules/test_vyos_vlan.py
new file mode 100644
index 0000000..58c1be1
--- /dev/null
+++ b/tests/unit/modules/test_vyos_vlan.py
@@ -0,0 +1,116 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import, division, print_function
+
+
+__metaclass__ = type
+
+import unittest
+
+from unittest.mock import MagicMock
+
+from ansible_collections.vyos.rest.plugins.modules.vyos_vlan import (
+ build_commands,
+ get_running_config,
+)
+
+from .base import load_fixture
+
+
+_BASE = ["interfaces", "ethernet"]
+
+
+class TestVyOSVlanGetRunning(unittest.TestCase):
+
+ def setUp(self):
+ self.mock_vyos = MagicMock()
+ self.fixture = load_fixture("vlan_running.json")
+ self.mock_vyos.get_config = MagicMock(return_value=self.fixture)
+
+ def test_parses_vlans(self):
+ result = get_running_config(self.mock_vyos)
+ vlan_ids = [v["vlan_id"] for v in result]
+ self.assertIn(10, vlan_ids)
+ self.assertIn(20, vlan_ids)
+
+ def test_parses_description(self):
+ result = get_running_config(self.mock_vyos)
+ v10 = next(v for v in result if v["vlan_id"] == 10)
+ self.assertEqual(v10["description"], "VLAN10")
+
+ def test_parses_address(self):
+ result = get_running_config(self.mock_vyos)
+ v10 = next(v for v in result if v["vlan_id"] == 10)
+ self.assertEqual(v10["address"], "192.168.10.1/24")
+
+ def test_parses_multiple_interfaces(self):
+ result = get_running_config(self.mock_vyos)
+ v10 = next(v for v in result if v["vlan_id"] == 10)
+ self.assertIn("eth1", v10["interfaces"])
+ self.assertIn("eth2", v10["interfaces"])
+
+ def test_empty_config(self):
+ self.mock_vyos.get_config = MagicMock(return_value={})
+ result = get_running_config(self.mock_vyos)
+ self.assertEqual(result, [])
+
+
+class TestVyOSVlanBuildCommands(unittest.TestCase):
+
+ def _have(self):
+ return [
+ {
+ "vlan_id": 10,
+ "interfaces": ["eth1"],
+ "description": "VLAN10",
+ "address": "192.168.10.1/24",
+ },
+ {"vlan_id": 20, "interfaces": ["eth1"], "description": "VLAN20"},
+ ]
+
+ def test_present_new_vlan(self):
+ config = [{"vlan_id": 30, "description": "VLAN30", "interfaces": ["eth1"]}]
+ cmds = build_commands(config, [], "present")
+ self.assertIn(
+ ("set", _BASE + ["eth1", "vif", "30", "description", "VLAN30"]),
+ cmds,
+ )
+
+ def test_present_idempotent(self):
+ config = [
+ {
+ "vlan_id": 10,
+ "description": "VLAN10",
+ "address": "192.168.10.1/24",
+ "interfaces": ["eth1"],
+ },
+ {"vlan_id": 20, "description": "VLAN20", "interfaces": ["eth1"]},
+ ]
+ cmds = build_commands(config, self._have(), "present")
+ self.assertEqual(cmds, [])
+
+ def test_present_update_description(self):
+ config = [{"vlan_id": 10, "description": "VLAN10-new", "interfaces": ["eth1"]}]
+ cmds = build_commands(config, self._have(), "present")
+ self.assertIn(
+ ("set", _BASE + ["eth1", "vif", "10", "description", "VLAN10-new"]),
+ cmds,
+ )
+
+ def test_absent_existing(self):
+ config = [{"vlan_id": 10, "interfaces": ["eth1"]}]
+ cmds = build_commands(config, self._have(), "absent")
+ self.assertIn(("delete", _BASE + ["eth1", "vif", "10"]), cmds)
+
+ def test_absent_nonexistent(self):
+ config = [{"vlan_id": 99, "interfaces": ["eth1"]}]
+ cmds = build_commands(config, self._have(), "absent")
+ self.assertEqual(cmds, [])
+
+ def test_present_bare_vif(self):
+ config = [{"vlan_id": 30, "interfaces": ["eth1"]}]
+ cmds = build_commands(config, [], "present")
+ self.assertIn(("set", _BASE + ["eth1", "vif", "30"]), cmds)
+
+
+if __name__ == "__main__":
+ unittest.main()