From 45dc95873fd906c582fbbd5e6ca3838caf867399 Mon Sep 17 00:00:00 2001 From: omnom62 Date: Mon, 6 Jul 2026 14:54:12 +1000 Subject: T8989: wave4 vyos_command, dict_op refactor * T8989: vyos_command module * T8989: vyos_command module UAT and SIT * T8989: vyos_command changelog * T8989: vyos_command linter * T8989: vyos_config module * T8989: vyos_config module changelog * T8989: Wave 4 vyos_config module with integration and unit tests * T8323: vyos_system module * T8332: vyos_system SIT and UAT * T8323: vyos_vlan module * T8323: vyos_vlan module * T8323: vyos_vlan module SIT and UAT * T8323: vyos_system module * T8989: Wave 4 vyos_vlan reworked with dict_op engine * T8989: Fix dict_op single-value string list handling, add vyos_system integration tests * T8989: logging_global refactor * T8989: migrate ntp_global, logging_global, firewall_global to dict_op engine * T8989: vyos_nat module for REST API collection * T8989: vyos_nat module for REST API collection, linter fixes * T8989: vyos_ha module for REST API collection * T8989: vyos_ha module for REST API collection * T8989: vyos_ha module sanity and linter fixes * T8989: vyos_ha module sanity and linter fixes * T8989: vyos_ha module linter fixes * T8989: vyos.rest AI comment fixes * T8323: vyos_nat AI comment fixes * T8989 ai fixes * T8989: vyos_bgp_address_family dict_op * T8989: vyos_bgp_address_family vyos_bgp_global dict_op * T8989: dict_op refactor for firewall_*, nat, user * T8989: dict_op refactor for firewall_*, nat, user * T8989: dict_op refactor for ntp_global, ha * T8989: snmp_server dict_op refactor * T8989: snmp_server dict_op refactor * T8989: route_map dict_op refactor --- README.md | 6 + changelogs/fragments/t8989_wave4.yml | 24 + docs/vyos.rest.vyos_bgp_address_family_module.rst | 4 +- docs/vyos.rest.vyos_bgp_global_module.rst | 4 +- docs/vyos.rest.vyos_command_module.rst | 252 +++ docs/vyos.rest.vyos_config_module.rst | 228 +++ docs/vyos.rest.vyos_firewall_global_module.rst | 4 +- docs/vyos.rest.vyos_firewall_interfaces_module.rst | 4 +- docs/vyos.rest.vyos_firewall_rules_module.rst | 5 +- docs/vyos.rest.vyos_ha_module.rst | 1668 ++++++++++++++++++++ docs/vyos.rest.vyos_logging_global_module.rst | 239 +-- docs/vyos.rest.vyos_nat_module.rst | 208 +++ docs/vyos.rest.vyos_ntp_global_module.rst | 50 +- docs/vyos.rest.vyos_route_maps_module.rst | 960 ++++++++++- docs/vyos.rest.vyos_snmp_server_module.rst | 12 +- docs/vyos.rest.vyos_system_module.rst | 254 +++ docs/vyos.rest.vyos_user_module.rst | 4 +- docs/vyos.rest.vyos_vlan_module.rst | 303 ++++ plugins/modules/vyos_bgp_address_family.py | 369 +++-- plugins/modules/vyos_bgp_global.py | 391 ++--- plugins/modules/vyos_command.py | 228 +++ plugins/modules/vyos_config.py | 238 +++ plugins/modules/vyos_firewall_global.py | 156 +- plugins/modules/vyos_firewall_interfaces.py | 288 ++-- plugins/modules/vyos_firewall_rules.py | 367 ++--- plugins/modules/vyos_ha.py | 783 +++++++++ plugins/modules/vyos_logging_global.py | 460 +++--- plugins/modules/vyos_nat.py | 492 ++++++ plugins/modules/vyos_ntp_global.py | 310 ++-- plugins/modules/vyos_route_maps.py | 842 +++++++--- plugins/modules/vyos_snmp_server.py | 829 +++++----- plugins/modules/vyos_system.py | 146 ++ plugins/modules/vyos_user.py | 174 +- plugins/modules/vyos_vlan.py | 241 +++ .../targets/vyos_banner/tasks/httpapi.yaml | 2 +- .../targets/vyos_banner/tests/httpapi/deleted.yaml | 4 +- .../vyos_banner/tests/httpapi/gathered.yaml | 4 +- .../targets/vyos_banner/tests/httpapi/merged.yaml | 4 +- .../vyos_bgp_address_family/tasks/httpapi.yaml | 2 +- .../tests/httpapi/deleted.yaml | 4 +- .../tests/httpapi/gathered.yaml | 4 +- .../tests/httpapi/overridden.yaml | 4 +- .../tests/httpapi/replaced.yaml | 4 +- .../targets/vyos_bgp_global/tasks/httpapi.yaml | 2 +- .../vyos_bgp_global/tests/httpapi/deleted.yaml | 4 +- .../vyos_bgp_global/tests/httpapi/gathered.yaml | 4 +- .../vyos_bgp_global/tests/httpapi/overridden.yaml | 4 +- .../vyos_bgp_global/tests/httpapi/replaced.yaml | 4 +- tests/integration/targets/vyos_command/aliases | 1 + .../targets/vyos_command/defaults/main.yaml | 3 + .../targets/vyos_command/tasks/httpapi.yaml | 21 + .../targets/vyos_command/tasks/main.yaml | 5 + .../targets/vyos_command/tests/httpapi/show.yaml | 45 + .../vyos_command/tests/httpapi/wait_for.yaml | 36 + .../targets/vyos_command/vars/main.yaml | 2 + tests/integration/targets/vyos_config/aliases | 1 + .../targets/vyos_config/defaults/main.yaml | 3 + .../targets/vyos_config/tasks/httpapi.yaml | 21 + .../targets/vyos_config/tasks/main.yaml | 5 + .../vyos_config/tests/httpapi/_remove_config.yaml | 6 + .../targets/vyos_config/tests/httpapi/delete.yaml | 37 + .../targets/vyos_config/tests/httpapi/lines.yaml | 44 + .../integration/targets/vyos_config/vars/main.yaml | 2 + .../targets/vyos_configure/tasks/httpapi.yaml | 2 +- .../targets/vyos_facts/tasks/httpapi.yaml | 2 +- .../vyos_facts/tests/httpapi/_populate_config.yaml | 16 + .../vyos_facts/tests/httpapi/_remove_config.yaml | 8 + .../targets/vyos_facts/tests/httpapi/all.yaml | 5 + .../targets/vyos_facts/tests/httpapi/default.yaml | 4 +- .../targets/vyos_facts/tests/httpapi/subsets.yaml | 25 +- .../vyos_firewall_global/tasks/httpapi.yaml | 2 +- .../tests/httpapi/deleted.yaml | 4 +- .../tests/httpapi/gathered.yaml | 4 +- .../tests/httpapi/overridden.yaml | 4 +- .../tests/httpapi/replaced.yaml | 4 +- .../vyos_firewall_interfaces/tasks/httpapi.yaml | 2 +- .../tests/httpapi/deleted.yaml | 4 +- .../tests/httpapi/gathered.yaml | 4 +- .../tests/httpapi/overridden.yaml | 4 +- .../tests/httpapi/replaced.yaml | 4 +- .../targets/vyos_firewall_rules/tasks/httpapi.yaml | 2 +- .../vyos_firewall_rules/tests/httpapi/deleted.yaml | 4 +- .../tests/httpapi/gathered.yaml | 4 +- .../tests/httpapi/overridden.yaml | 4 +- .../tests/httpapi/replaced.yaml | 4 +- tests/integration/targets/vyos_ha/aliases | 1 + .../integration/targets/vyos_ha/defaults/main.yaml | 3 + .../integration/targets/vyos_ha/tasks/httpapi.yaml | 18 + tests/integration/targets/vyos_ha/tasks/main.yaml | 3 + .../vyos_ha/tests/httpapi/_populate_config.yaml | 40 + .../vyos_ha/tests/httpapi/_remove_config.yaml | 4 + .../targets/vyos_ha/tests/httpapi/deleted.yaml | 36 + .../targets/vyos_ha/tests/httpapi/gathered.yaml | 29 + .../targets/vyos_ha/tests/httpapi/merged.yaml | 54 + .../targets/vyos_ha/tests/httpapi/overridden.yaml | 50 + .../targets/vyos_ha/tests/httpapi/replaced.yaml | 48 + tests/integration/targets/vyos_ha/vars/main.yaml | 2 + .../targets/vyos_hostname/tasks/httpapi.yaml | 2 +- .../vyos_hostname/tests/httpapi/gathered.yaml | 4 +- .../vyos_hostname/tests/httpapi/replaced.yaml | 4 +- .../targets/vyos_lldp_global/tasks/httpapi.yaml | 2 +- .../vyos_lldp_global/tests/httpapi/deleted.yaml | 4 +- .../vyos_lldp_global/tests/httpapi/gathered.yaml | 4 +- .../vyos_lldp_global/tests/httpapi/replaced.yaml | 4 +- .../targets/vyos_logging_global/tasks/httpapi.yaml | 2 +- .../vyos_logging_global/tests/httpapi/deleted.yaml | 4 +- .../tests/httpapi/gathered.yaml | 4 +- .../tests/httpapi/replaced.yaml | 4 +- tests/integration/targets/vyos_nat/aliases | 1 + .../targets/vyos_nat/defaults/main.yaml | 3 + .../targets/vyos_nat/tasks/httpapi.yaml | 18 + tests/integration/targets/vyos_nat/tasks/main.yaml | 5 + .../vyos_nat/tests/httpapi/_populate_config.yaml | 39 + .../vyos_nat/tests/httpapi/_remove_config.yaml | 5 + .../targets/vyos_nat/tests/httpapi/deleted.yaml | 36 + .../targets/vyos_nat/tests/httpapi/gathered.yaml | 30 + .../targets/vyos_nat/tests/httpapi/merged.yaml | 49 + .../targets/vyos_nat/tests/httpapi/overridden.yaml | 49 + .../targets/vyos_nat/tests/httpapi/replaced.yaml | 49 + tests/integration/targets/vyos_nat/vars/main.yaml | 2 + .../targets/vyos_ntp_global/tasks/httpapi.yaml | 2 +- .../vyos_ntp_global/tests/httpapi/deleted.yaml | 6 +- .../vyos_ntp_global/tests/httpapi/gathered.yaml | 8 +- .../vyos_ntp_global/tests/httpapi/overridden.yaml | 4 +- .../vyos_ntp_global/tests/httpapi/replaced.yaml | 8 +- .../targets/vyos_ntp_global/tests/httpapi/rtt.yaml | 54 +- .../vyos_ospf_interfaces/tasks/httpapi.yaml | 2 +- .../vyos_prefix_lists/tests/httpapi/deleted.yaml | 4 +- .../vyos_prefix_lists/tests/httpapi/gathered.yaml | 4 +- .../vyos_prefix_lists/tests/httpapi/replaced.yaml | 4 +- .../targets/vyos_route_maps/tasks/httpapi.yaml | 2 +- .../vyos_route_maps/tests/httpapi/deleted.yaml | 4 +- .../vyos_route_maps/tests/httpapi/gathered.yaml | 4 +- .../vyos_route_maps/tests/httpapi/replaced.yaml | 4 +- .../targets/vyos_route_maps/tests/httpapi/rtt.yaml | 128 +- .../targets/vyos_snmp_server/tasks/httpapi.yaml | 2 +- .../vyos_snmp_server/tests/httpapi/deleted.yaml | 4 +- .../vyos_snmp_server/tests/httpapi/gathered.yaml | 4 +- .../vyos_snmp_server/tests/httpapi/replaced.yaml | 4 +- .../vyos_snmp_server/tests/httpapi/rtt.yaml | 77 +- tests/integration/targets/vyos_system/aliases | 1 + .../targets/vyos_system/defaults/main.yaml | 3 + .../targets/vyos_system/tasks/httpapi.yaml | 21 + .../targets/vyos_system/tasks/main.yaml | 5 + .../vyos_system/tests/httpapi/_remove_config.yaml | 12 + .../targets/vyos_system/tests/httpapi/absent.yaml | 48 + .../vyos_system/tests/httpapi/gathered.yaml | 14 + .../targets/vyos_system/tests/httpapi/present.yaml | 63 + .../integration/targets/vyos_system/vars/main.yaml | 2 + .../targets/vyos_user/tasks/httpapi.yaml | 2 +- tests/integration/targets/vyos_vlan/aliases | 1 + .../targets/vyos_vlan/defaults/main.yaml | 3 + .../targets/vyos_vlan/tasks/httpapi.yaml | 21 + .../integration/targets/vyos_vlan/tasks/main.yaml | 5 + .../vyos_vlan/tests/httpapi/_remove_config.yaml | 9 + .../targets/vyos_vlan/tests/httpapi/absent.yaml | 45 + .../targets/vyos_vlan/tests/httpapi/gathered.yaml | 28 + .../targets/vyos_vlan/tests/httpapi/present.yaml | 44 + .../targets/vyos_vlan/tests/httpapi/rtt.yaml | 43 + tests/integration/targets/vyos_vlan/vars/main.yaml | 2 + tests/unit/fixtures/bgp_af_running.json | 17 +- tests/unit/fixtures/bgp_global_running.json | 14 +- .../unit/fixtures/firewall_interfaces_running.json | 14 +- tests/unit/fixtures/ha_running.json | 44 + tests/unit/fixtures/nat_running.json | 80 + tests/unit/fixtures/route_maps_running.json | 11 +- tests/unit/fixtures/snmp_server_running.json | 11 + tests/unit/fixtures/system_running.json | 11 + tests/unit/fixtures/vlan_running.json | 24 + tests/unit/modules/base.py | 15 +- tests/unit/modules/test_vyos_bgp_address_family.py | 358 +++-- tests/unit/modules/test_vyos_bgp_global.py | 341 ++-- tests/unit/modules/test_vyos_command.py | 111 ++ tests/unit/modules/test_vyos_config.py | 107 ++ tests/unit/modules/test_vyos_facts.py | 8 +- tests/unit/modules/test_vyos_firewall_global.py | 380 +++-- .../unit/modules/test_vyos_firewall_interfaces.py | 306 ++-- tests/unit/modules/test_vyos_firewall_rules.py | 345 ++-- tests/unit/modules/test_vyos_ha.py | 345 ++++ tests/unit/modules/test_vyos_logging_global.py | 416 ++--- tests/unit/modules/test_vyos_nat.py | 558 +++++++ tests/unit/modules/test_vyos_ntp_global.py | 265 ++-- tests/unit/modules/test_vyos_route_maps.py | 506 ++++-- tests/unit/modules/test_vyos_snmp_server.py | 644 ++++++++ tests/unit/modules/test_vyos_system.py | 93 ++ tests/unit/modules/test_vyos_user.py | 278 ++-- tests/unit/modules/test_vyos_vlan.py | 116 ++ 187 files changed, 13915 insertions(+), 3757 deletions(-) create mode 100644 changelogs/fragments/t8989_wave4.yml create mode 100644 docs/vyos.rest.vyos_command_module.rst create mode 100644 docs/vyos.rest.vyos_config_module.rst create mode 100644 docs/vyos.rest.vyos_ha_module.rst create mode 100644 docs/vyos.rest.vyos_nat_module.rst create mode 100644 docs/vyos.rest.vyos_system_module.rst create mode 100644 docs/vyos.rest.vyos_vlan_module.rst create mode 100644 plugins/modules/vyos_command.py create mode 100644 plugins/modules/vyos_config.py create mode 100644 plugins/modules/vyos_ha.py create mode 100644 plugins/modules/vyos_nat.py create mode 100644 plugins/modules/vyos_system.py create mode 100644 plugins/modules/vyos_vlan.py create mode 100644 tests/integration/targets/vyos_command/aliases create mode 100644 tests/integration/targets/vyos_command/defaults/main.yaml create mode 100644 tests/integration/targets/vyos_command/tasks/httpapi.yaml create mode 100644 tests/integration/targets/vyos_command/tasks/main.yaml create mode 100644 tests/integration/targets/vyos_command/tests/httpapi/show.yaml create mode 100644 tests/integration/targets/vyos_command/tests/httpapi/wait_for.yaml create mode 100644 tests/integration/targets/vyos_command/vars/main.yaml create mode 100644 tests/integration/targets/vyos_config/aliases create mode 100644 tests/integration/targets/vyos_config/defaults/main.yaml create mode 100644 tests/integration/targets/vyos_config/tasks/httpapi.yaml create mode 100644 tests/integration/targets/vyos_config/tasks/main.yaml create mode 100644 tests/integration/targets/vyos_config/tests/httpapi/_remove_config.yaml create mode 100644 tests/integration/targets/vyos_config/tests/httpapi/delete.yaml create mode 100644 tests/integration/targets/vyos_config/tests/httpapi/lines.yaml create mode 100644 tests/integration/targets/vyos_config/vars/main.yaml create mode 100644 tests/integration/targets/vyos_facts/tests/httpapi/_populate_config.yaml create mode 100644 tests/integration/targets/vyos_facts/tests/httpapi/_remove_config.yaml create mode 100644 tests/integration/targets/vyos_ha/aliases create mode 100644 tests/integration/targets/vyos_ha/defaults/main.yaml create mode 100644 tests/integration/targets/vyos_ha/tasks/httpapi.yaml create mode 100644 tests/integration/targets/vyos_ha/tasks/main.yaml create mode 100644 tests/integration/targets/vyos_ha/tests/httpapi/_populate_config.yaml create mode 100644 tests/integration/targets/vyos_ha/tests/httpapi/_remove_config.yaml create mode 100644 tests/integration/targets/vyos_ha/tests/httpapi/deleted.yaml create mode 100644 tests/integration/targets/vyos_ha/tests/httpapi/gathered.yaml create mode 100644 tests/integration/targets/vyos_ha/tests/httpapi/merged.yaml create mode 100644 tests/integration/targets/vyos_ha/tests/httpapi/overridden.yaml create mode 100644 tests/integration/targets/vyos_ha/tests/httpapi/replaced.yaml create mode 100644 tests/integration/targets/vyos_ha/vars/main.yaml create mode 100644 tests/integration/targets/vyos_nat/aliases create mode 100644 tests/integration/targets/vyos_nat/defaults/main.yaml create mode 100644 tests/integration/targets/vyos_nat/tasks/httpapi.yaml create mode 100644 tests/integration/targets/vyos_nat/tasks/main.yaml create mode 100644 tests/integration/targets/vyos_nat/tests/httpapi/_populate_config.yaml create mode 100644 tests/integration/targets/vyos_nat/tests/httpapi/_remove_config.yaml create mode 100644 tests/integration/targets/vyos_nat/tests/httpapi/deleted.yaml create mode 100644 tests/integration/targets/vyos_nat/tests/httpapi/gathered.yaml create mode 100644 tests/integration/targets/vyos_nat/tests/httpapi/merged.yaml create mode 100644 tests/integration/targets/vyos_nat/tests/httpapi/overridden.yaml create mode 100644 tests/integration/targets/vyos_nat/tests/httpapi/replaced.yaml create mode 100644 tests/integration/targets/vyos_nat/vars/main.yaml create mode 100644 tests/integration/targets/vyos_system/aliases create mode 100644 tests/integration/targets/vyos_system/defaults/main.yaml create mode 100644 tests/integration/targets/vyos_system/tasks/httpapi.yaml create mode 100644 tests/integration/targets/vyos_system/tasks/main.yaml create mode 100644 tests/integration/targets/vyos_system/tests/httpapi/_remove_config.yaml create mode 100644 tests/integration/targets/vyos_system/tests/httpapi/absent.yaml create mode 100644 tests/integration/targets/vyos_system/tests/httpapi/gathered.yaml create mode 100644 tests/integration/targets/vyos_system/tests/httpapi/present.yaml create mode 100644 tests/integration/targets/vyos_system/vars/main.yaml create mode 100644 tests/integration/targets/vyos_vlan/aliases create mode 100644 tests/integration/targets/vyos_vlan/defaults/main.yaml create mode 100644 tests/integration/targets/vyos_vlan/tasks/httpapi.yaml create mode 100644 tests/integration/targets/vyos_vlan/tasks/main.yaml create mode 100644 tests/integration/targets/vyos_vlan/tests/httpapi/_remove_config.yaml create mode 100644 tests/integration/targets/vyos_vlan/tests/httpapi/absent.yaml create mode 100644 tests/integration/targets/vyos_vlan/tests/httpapi/gathered.yaml create mode 100644 tests/integration/targets/vyos_vlan/tests/httpapi/present.yaml create mode 100644 tests/integration/targets/vyos_vlan/tests/httpapi/rtt.yaml create mode 100644 tests/integration/targets/vyos_vlan/vars/main.yaml create mode 100644 tests/unit/fixtures/ha_running.json create mode 100644 tests/unit/fixtures/nat_running.json create mode 100644 tests/unit/fixtures/system_running.json create mode 100644 tests/unit/fixtures/vlan_running.json create mode 100644 tests/unit/modules/test_vyos_command.py create mode 100644 tests/unit/modules/test_vyos_config.py create mode 100644 tests/unit/modules/test_vyos_ha.py create mode 100644 tests/unit/modules/test_vyos_nat.py create mode 100644 tests/unit/modules/test_vyos_snmp_server.py create mode 100644 tests/unit/modules/test_vyos_system.py create mode 100644 tests/unit/modules/test_vyos_vlan.py 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 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 dictionary - when changes are applied + always
Raw API response.

@@ -1004,7 +1004,7 @@ Common return values are documented `here boolean - when changes are applied + when changed
Whether the config was saved after changes.

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 dictionary - when changes are applied + always
Raw API response.

@@ -856,7 +856,7 @@ Common return values are documented `here boolean - when changes are applied + when changed
Whether the config was saved after changes.

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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterChoices/DefaultsComments
+
+ commands + +
+ list + / elements=raw + / required +
+
+ +
List of show commands to run on the device.
+
Each command is a list of path elements passed to the /show endpoint.
+
Commands may be specified as a string (space-separated) or a list.
+
+
+ interval + +
+ integer +
+
+ Default:
1
+
+
Configures the interval in seconds to wait between retries of the command.
+
+
+ match + +
+ string +
+
+
    Choices: +
  • any
  • +
  • all ←
  • +
+
+
The match argument is used in conjunction with the wait_for argument to specify the match policy.
+
+
+ retries + +
+ integer +
+
+ Default:
10
+
+
Specifies the number of retries a command should be run before it is considered failed.
+
+
+ wait_for + +
+ list + / elements=string +
+
+ +
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.
+

aliases: waitfor
+
+
+ + +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 `_, the following are the fields unique to this module: + +.. raw:: html + + + + + + + + + + + + + + + + + + + + + + +
KeyReturnedDescription
+
+ failed_conditions + +
+ list +
+
failed +
List of conditions that failed.
+
+
+
+ stdout + +
+ list +
+
always +
List of output from each command.
+
+
Sample:
+
['VyOS 1.5.0\n...', 'Interface IP Address\n...']
+
+
+ stdout_lines + +
+ list +
+
always +
List of output split into lines for each command.
+
+
+

+ + +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 + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterChoices/DefaultsComments
+
+ lines + +
+ list + / elements=string +
+
+ +
Ordered list of set or delete commands to apply.
+
Commands should be in standard VyOS CLI format, e.g. set system host-name router1 or delete protocols bgp.
+
+
+ match + +
+ string +
+
+
    Choices: +
  • line ←
  • +
  • none
  • +
+
+
Controls how commands are matched against the running configuration.
+
line checks each command against the running config and only applies commands that would change the configuration.
+
none applies all commands without checking the running config.
+
+
+ save + +
+ boolean +
+
+
    Choices: +
  • no ←
  • +
  • yes
  • +
+
+
Save the configuration to disk after applying changes.
+
+
+ src + +
+ path +
+
+ +
Path to a file containing set/delete commands, one per line.
+
Blank lines and lines starting with # are ignored.
+
Mutually exclusive with lines.
+
+
+ + +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 `_, the following are the fields unique to this module: + +.. raw:: html + + + + + + + + + + + + + + + + + + + + + + +
KeyReturnedDescription
+
+ commands + +
+ list +
+
always +
List of commands applied to the device.
+
+
+
+ response + +
+ dictionary +
+
always +
Raw API response from the device.
+
+
+
+ saved + +
+ boolean +
+
when save is true and changes were made +
Whether the configuration was saved to disk.
+
+
+

+ + +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 dictionary - when changes are applied + always
Raw API response.

@@ -622,7 +622,7 @@ Common return values are documented `here boolean - when changes are applied + when changed
Whether the config was saved after changes.

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 dictionary - when changes are applied + always
Raw API response.

@@ -612,7 +612,7 @@ Common return values are documented `here boolean - when changes are applied + when changed
Whether the config was saved after changes.

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 dictionary - when changes are applied + always
Raw API response.

@@ -705,7 +706,7 @@ Common return values are documented `here boolean - when changes are applied + when changed
Whether the config was saved after changes.

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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterChoices/DefaultsComments
+
+ config + +
+ dictionary +
+
+ +
High-availability configuration.
+
+
+ disable + +
+ boolean +
+
+
    Choices: +
  • no ←
  • +
  • yes
  • +
+
+
Disable all high-availability configuration.
+
+
+ virtual_servers + +
+ list + / elements=dictionary +
+
+ +
List of load balancer virtual server definitions.
+
+
+ address + +
+ string +
+
+ +
Address.
+
+
+ algorithm + +
+ string +
+
+ +
Algorithm.
+
+
+ delay_loop + +
+ integer +
+
+ +
Delay loop.
+
+
+ forward_method + +
+ string +
+
+
    Choices: +
  • direct
  • +
  • nat
  • +
+
+
Forward method.
+
+
+ fwmark + +
+ integer +
+
+ +
Fwmark.
+
+
+ name + +
+ string + / required +
+
+ +
Name.
+
+
+ persistence_timeout + +
+ integer +
+
+ +
Persistence timeout.
+
+
+ port + +
+ integer +
+
+ +
Port.
+
+
+ protocol + +
+ string +
+
+
    Choices: +
  • tcp
  • +
  • udp
  • +
+
+
Protocol.
+
+
+ real_server + +
+ list + / elements=dictionary +
+
+ +
Real server.
+
+
+ address + +
+ string + / required +
+
+ +
Address.
+
+
+ connection_timeout + +
+ integer +
+
+ +
Connection timeout.
+
+
+ health_check_script + +
+ string +
+
+ +
Health check script.
+
+
+ port + +
+ integer +
+
+ +
Port.
+
+
+ vrrp + +
+ dictionary +
+
+ +
VRRP configuration.
+
+
+ global_parameters + +
+ dictionary +
+
+ +
Global parameters.
+
+
+ garp + +
+ dictionary +
+
+ +
Garp.
+
+
+ interval + +
+ integer +
+
+ +
Interval.
+
+
+ master_delay + +
+ integer +
+
+ +
Master delay.
+
+
+ master_refresh + +
+ integer +
+
+ +
Master refresh.
+
+
+ master_refresh_repeat + +
+ integer +
+
+ +
Master refresh repeat.
+
+
+ master_repeat + +
+ integer +
+
+ +
Master repeat.
+
+
+ startup_delay + +
+ integer +
+
+ +
Startup delay.
+
+
+ version + +
+ string +
+
+ +
Version.
+
+
+ groups + +
+ list + / elements=dictionary +
+
+ +
Groups.
+
+
+ address + +
+ list + / elements=string +
+
+ +
Address.
+
+
+ advertise_interval + +
+ integer +
+
+ +
Advertise interval.
+
+
+ authentication + +
+ dictionary +
+
+ +
Authentication.
+
+
+ password + +
+ string +
+
+ +
Password.
+
+
+ type + +
+ string +
+
+ +
Type.
+
+
+ description + +
+ string +
+
+ +
Description.
+
+
+ disable + +
+ boolean +
+
+
    Choices: +
  • no ←
  • +
  • yes
  • +
+
+
Disable.
+
+
+ excluded_address + +
+ list + / elements=string +
+
+ +
Excluded address.
+
+
+ garp + +
+ dictionary +
+
+ +
Garp.
+
+
+ interval + +
+ integer +
+
+ +
Interval.
+
+
+ master_delay + +
+ integer +
+
+ +
Master delay.
+
+
+ master_refresh + +
+ integer +
+
+ +
Master refresh.
+
+
+ master_refresh_repeat + +
+ integer +
+
+ +
Master refresh repeat.
+
+
+ master_repeat + +
+ integer +
+
+ +
Master repeat.
+
+
+ health_check + +
+ dictionary +
+
+ +
Health check.
+
+
+ failure_count + +
+ integer +
+
+ +
Failure count.
+
+
+ interval + +
+ integer +
+
+ +
Interval.
+
+
+ ping + +
+ string +
+
+ +
Ping.
+
+
+ script + +
+ string +
+
+ +
Script.
+
+
+ hello_source_address + +
+ string +
+
+ +
Hello source address.
+
+
+ interface + +
+ string +
+
+ +
Interface.
+
+
+ name + +
+ string + / required +
+
+ +
Name.
+
+
+ no_preempt + +
+ boolean +
+
+
    Choices: +
  • no ←
  • +
  • yes
  • +
+
+
No preempt.
+
+
+ peer_address + +
+ string +
+
+ +
Peer address.
+
+
+ preempt_delay + +
+ integer +
+
+ +
Preempt delay.
+
+
+ priority + +
+ integer +
+
+ +
Priority.
+
+
+ rfc3768_compatibility + +
+ boolean +
+
+
    Choices: +
  • no ←
  • +
  • yes
  • +
+
+
Rfc3768 compatibility.
+
+
+ track + +
+ dictionary +
+
+ +
Track.
+
+
+ exclude_vrrp_interface + +
+ boolean +
+
+
    Choices: +
  • no
  • +
  • yes
  • +
+
+
Exclude vrrp interface.
+
+
+ interface + +
+ list + / elements=string +
+
+ +
Interface.
+
+
+ transition_script + +
+ dictionary +
+
+ +
Transition script.
+
+
+ backup + +
+ string +
+
+ +
Backup.
+
+
+ fault + +
+ string +
+
+ +
Fault.
+
+
+ master + +
+ string +
+
+ +
Master.
+
+
+ stop + +
+ string +
+
+ +
Stop.
+
+
+ vrid + +
+ integer +
+
+ +
Vrid.
+
+
+ snmp + +
+ string +
+
+
    Choices: +
  • enabled
  • +
  • disabled
  • +
+
+
Snmp.
+
+
+ sync_groups + +
+ list + / elements=dictionary +
+
+ +
Sync groups.
+
+
+ health_check + +
+ dictionary +
+
+ +
Health check.
+
+
+ failure_count + +
+ integer +
+
+ +
Failure count.
+
+
+ interval + +
+ integer +
+
+ +
Interval.
+
+
+ ping + +
+ string +
+
+ +
Ping.
+
+
+ script + +
+ string +
+
+ +
Script.
+
+
+ member + +
+ list + / elements=string +
+
+ +
Member.
+
+
+ name + +
+ string + / required +
+
+ +
Name.
+
+
+ transition_script + +
+ dictionary +
+
+ +
Transition script.
+
+
+ backup + +
+ string +
+
+ +
Backup.
+
+
+ fault + +
+ string +
+
+ +
Fault.
+
+
+ master + +
+ string +
+
+ +
Master.
+
+
+ stop + +
+ string +
+
+ +
Stop.
+
+
+ state + +
+ string +
+
+
    Choices: +
  • merged ←
  • +
  • replaced
  • +
  • overridden
  • +
  • deleted
  • +
  • gathered
  • +
+
+
Desired end state of the configuration.
+
+
+ + + + +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 `_, the following are the fields unique to this module: + +.. raw:: html + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KeyReturnedDescription
+
+ after + +
+ dictionary +
+
when changed +
HA configuration after this module ran.
+
+
+
+ before + +
+ dictionary +
+
always +
HA configuration before this module ran.
+
+
+
+ commands + +
+ list +
+
always +
List of API commands sent to the device.
+
+
+
+ gathered + +
+ dictionary +
+
when state is gathered +
Current HA configuration as structured data.
+
+
+
+ saved + +
+ boolean +
+
when changed +
Whether the config was saved after changes.
+
+
+

+ + +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. @@ -122,150 +122,6 @@ Parameters - - - -
- files - -
- list - / elements=dictionary -
- - - - -
Logging to local files.
- - - - - - -
- archive - -
- dictionary -
- - - - -
Log file archive/rotation settings.
- - - - - - - -
- file_num - -
- integer -
- - - - -
Number of archived log files to keep.
- - - - - - - -
- size - -
- integer -
- - - - -
Maximum size of log file in kilobytes before rotation.
- - - - - - - -
- facilities - -
- list - / elements=dictionary -
- - - - -
List of syslog facilities to log to this file.
- - - - - - - -
- facility - -
- string -
- - - - -
Syslog facility name.
- - - - - - - -
- severity - -
- string -
- - - - -
Minimum severity level to log.
- - - - - - - -
- path - -
- string -
- - - - -
Path to the log file on the device.
- - - @@ -279,66 +135,12 @@ Parameters -
Global syslog parameters (maps to system syslog global).
- - - - - - -
- archive - -
- dictionary -
- - - - -
Global log archive/rotation settings.
+
Global syslog parameters (maps to system syslog local on device).
- - -
- file_num - -
- integer -
- - - - -
Number of archived log files to keep.
- - - - - - - -
- size - -
- integer -
- - - - -
Maximum size of log file in kilobytes before rotation.
- - - - - -
facilities @@ -351,7 +153,7 @@ Parameters -
List of syslog facilities for global logging.
+
List of syslog facilities for local logging.
@@ -444,7 +246,7 @@ Parameters -
Logging to remote syslog hosts.
+
Logging to remote syslog hosts (maps to system syslog remote).
@@ -663,21 +465,6 @@ Parameters - - -
- running_config - -
- string -
- - - - -
Used only with state parsed.
- -
@@ -694,8 +481,6 @@ Parameters
  • overridden
  • deleted
  • gathered
  • -
  • rendered
  • -
  • parsed
  • @@ -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 dictionary - when changes are applied + when changed
    Result of save_config after applying changes.

    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 + + + + + + + + + + + + + + + + + +
    ParameterChoices/DefaultsComments
    +
    + config + +
    + dictionary +
    +
    + +
    NAT configuration.
    +
    +
    + state + +
    + string +
    +
    +
      Choices: +
    • merged ←
    • +
    • replaced
    • +
    • overridden
    • +
    • deleted
    • +
    • gathered
    • +
    +
    +
    The desired state of the NAT configuration.
    +
    +
    + + + + +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 `_, the following are the fields unique to this module: + +.. raw:: html + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    KeyReturnedDescription
    +
    + after + +
    + dictionary +
    +
    when changed +
    NAT configuration after this module ran.
    +
    +
    +
    + before + +
    + dictionary +
    +
    always +
    NAT configuration before this module ran.
    +
    +
    +
    + commands + +
    + list +
    +
    always +
    List of API commands sent to the device.
    +
    +
    +
    + gathered + +
    + dictionary +
    +
    when state is gathered +
    Current NAT configuration as structured data.
    +
    +
    +
    + saved + +
    + boolean +
    +
    when changed +
    Whether the config was saved after changes.
    +
    +
    +

    + + +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 @@ -152,22 +152,6 @@ Parameters - - -
    - running_config - -
    - string -
    - - - - -
    Used only with state parsed.
    -
    Provide the output of show configuration commands | grep ntp.
    - -
    @@ -184,8 +168,6 @@ Parameters
  • overridden
  • deleted
  • gathered
  • -
  • rendered
  • -
  • parsed
  • @@ -311,36 +293,6 @@ Common return values are documented `here - - -
    - parsed - -
    - dictionary -
    - - when state is parsed - -
    Structured data parsed from running_config.
    -
    - - - - -
    - rendered - -
    - list -
    - - when state is rendered - -
    CLI commands generated for the provided config (offline).
    -
    - -
    @@ -350,7 +302,7 @@ Common return values are documented `here boolean - when changes are applied + when changed
    Whether the config was saved after changes.

    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,49 +31,705 @@ Parameters - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + - + @@ -421,6 +421,7 @@ Parameters
    string + / required
    @@ -1014,7 +1015,7 @@ Parameters @@ -1026,6 +1027,7 @@ Parameters
    string + / required
    @@ -1220,7 +1222,7 @@ Common return values are documented `here boolean - + - + - +
    ParameterParameter Choices/Defaults Comments
    +
    + config + +
    + list + / elements=dictionary +
    +
    + +
    List of route-map configurations.
    +
    +
    + entries + +
    + list + / elements=dictionary +
    +
    + +
    Route map rules.
    +
    +
    + action + +
    + string +
    +
    +
      Choices: +
    • permit
    • +
    • deny
    • +
    +
    +
    Permit or deny.
    +
    +
    + call + +
    + string +
    +
    + +
    Call another route map.
    +
    +
    + continue_sequence + +
    + integer +
    +
    + +
    Continue at a different sequence number.
    +
    +
    + description + +
    + string +
    +
    + +
    Rule description.
    +
    +
    + match + +
    + dictionary +
    +
    + +
    Match conditions.
    +
    +
    + interface + +
    + string +
    +
    + +
    Interface to match.
    +
    +
    + ip + +
    + dictionary +
    +
    + +
    IPv4 next-hop match parameters.
    +
    +
    + nexthop_address + +
    + string +
    +
    + +
    IPv4 next-hop address to match.
    +
    +
    + nexthop_prefix_list + +
    + string +
    +
    + +
    IPv4 next-hop prefix-list to match.
    +
    +
    + ipv6 + +
    + dictionary +
    +
    + +
    IPv6 next-hop match parameters.
    +
    +
    + nexthop_address + +
    + string +
    +
    + +
    IPv6 next-hop address to match.
    +
    +
    + metric + +
    + integer +
    +
    + +
    Metric of route to match.
    +
    +
    + origin + +
    + string +
    +
    +
      Choices: +
    • egp
    • +
    • igp
    • +
    • incomplete
    • +
    +
    +
    BGP origin code to match.
    +
    +
    + peer + +
    + string +
    +
    + +
    Peer address to match.
    +
    +
    + prefix_list + +
    + string +
    +
    + +
    IPv4 prefix-list to match.
    +
    +
    + prefix_list6 + +
    + string +
    +
    + +
    IPv6 prefix-list to match.
    +
    +
    + protocol + +
    + string +
    +
    +
      Choices: +
    • babel
    • +
    • bgp
    • +
    • connected
    • +
    • isis
    • +
    • kernel
    • +
    • ospf
    • +
    • ospfv3
    • +
    • rip
    • +
    • ripng
    • +
    • static
    • +
    • table
    • +
    • vnc
    • +
    +
    +
    Match protocol via which the route was learnt.
    +
    +
    + sequence + +
    + integer + / required +
    +
    + +
    Rule sequence number (1-65535).
    +
    - config + set + +
    + dictionary +
    +
    + +
    Route parameters to set.
    +
    +
    + aggregator + +
    + dictionary +
    +
    + +
    BGP aggregator attribute.
    +
    +
    + as_ + +
    + integer +
    +
    + +
    AS number of an aggregation.
    +

    aliases: as
    +
    +
    + ip + +
    + string +
    +
    + +
    IP address of an aggregation.
    +
    +
    + as_path_exclude + +
    + string +
    +
    + +
    AS number(s) to remove from the as-path attribute.
    +
    +
    + as_path_prepend + +
    + string +
    +
    + +
    AS number(s) to prepend to the as-path attribute.
    +
    +
    + as_path_prepend_last_as + +
    + integer +
    +
    + +
    Number of times to prepend the last AS number in the as-path.
    +
    +
    + atomic_aggregate + +
    + boolean +
    +
    +
      Choices: +
    • no
    • +
    • yes
    • +
    +
    +
    Set the BGP atomic aggregate attribute.
    +
    +
    + community + +
    + dictionary +
    +
    + +
    BGP community attribute.
    +
    +
    + add + +
    + list + / elements=string +
    +
    + +
    Communities to add to a prefix.
    +
    +
    + delete + +
    + string +
    +
    + +
    Remove communities defined in a list from a prefix.
    +
    +
    + none + +
    + boolean +
    +
    +
      Choices: +
    • no
    • +
    • yes
    • +
    +
    +
    Completely remove the communities attribute from a prefix.
    +
    +
    + replace + +
    + list + / elements=string +
    +
    + +
    Communities to set for a prefix.
    +
    +
    + distance
    - list - / elements=dictionary + integer
    -
    List of route-map configurations.
    +
    Locally significant administrative distance.
    - entries + ip_next_hop
    - list - / elements=dictionary + string
    -
    Route map rules.
    +
    IPv4 next-hop address to set.
    +
    +
    + ipv6_next_hop + +
    + dictionary +
    +
    + +
    IPv6 next-hop to set.
    - action + global
    string @@ -81,15 +738,17 @@ Parameters
    -
    Permit or deny.
    +
    Nexthop IPv6 global address.
    - call + local
    string @@ -98,49 +757,63 @@ Parameters
    -
    Call another route map.
    +
    Nexthop IPv6 local address.
    - continue_sequence + peer_address
    - integer + boolean
    +
      Choices: +
    • no
    • +
    • yes
    • +
    -
    Continue at a different sequence number.
    +
    Use the peer address (BGP only) as the nexthop.
    - description + prefer_global
    - string + boolean
    +
      Choices: +
    • no
    • +
    • yes
    • +
    -
    Rule description.
    +
    Prefer the global address as the nexthop.
    +
    - match + large_community
    dictionary @@ -149,48 +822,264 @@ Parameters
    -
    Match conditions (passed through to VyOS API).
    +
    BGP large community attribute.
    +
    +
    + add + +
    + list + / elements=string +
    +
    + +
    Large communities to add to a prefix.
    - sequence + delete
    - integer - / required + string
    -
    Rule sequence number (1-65535).
    +
    Remove large communities defined in a list from a prefix.
    - set + none
    - dictionary + boolean +
    +
    +
      Choices: +
    • no
    • +
    • yes
    • +
    +
    +
    Completely remove the large-community attribute from a prefix.
    +
    +
    + replace + +
    + list + / elements=string
    -
    Route parameters to set (passed through to VyOS API).
    +
    Large communities to set for a prefix.
    +
    + local_preference + +
    + integer +
    +
    + +
    BGP local preference.
    +
    +
    + metric + +
    + integer +
    +
    + +
    Metric of route.
    +
    +
    + metric_type + +
    + string +
    +
    + +
    Metric type.
    +
    +
    + origin + +
    + string +
    +
    +
      Choices: +
    • egp
    • +
    • igp
    • +
    • incomplete
    • +
    +
    +
    BGP origin code to set.
    +
    +
    + originator_id + +
    + string +
    +
    + +
    BGP originator ID.
    +
    +
    + src + +
    + string +
    +
    + +
    Source address for route.
    +
    +
    + table + +
    + integer +
    +
    + +
    Non-main kernel routing table.
    +
    +
    + tag + +
    + integer +
    +
    + +
    Route tag value.
    +
    +
    + weight + +
    + integer +
    +
    + +
    BGP weight.
    +
    route_map @@ -207,7 +1096,7 @@ Parameters
    +
    state @@ -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 dictionary
    when changes are appliedalways
    Raw API response.

    @@ -380,7 +1268,7 @@ Common return values are documented `here boolean
    when changes are appliedwhen changed
    Whether the config was saved after changes.

    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
    -
    Register a subtree for SMUX-based processing.
    +
    Register a subtree for SMUX-based processing. The device supports multiple values here; this module manages a single value only.
    @@ -907,7 +908,7 @@ Parameters -
    SNMPv3 view configuration.
    +
    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.
    -
    SNMP trap target.
    +
    SNMP (v2) trap target. The device supports multiple trap targets; this module manages a single one only.
    @@ -1192,7 +1194,7 @@ Common return values are documented `here always -
    List of API command dicts sent to the device.
    +
    List of API command tuples sent to the device.

    when changes are appliedwhen changed
    Whether the config was saved after changes.

    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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    ParameterChoices/DefaultsComments
    +
    + domain_name + +
    + string +
    +
    + +
    Device domain name.
    +
    +
    + domain_search + +
    + list + / elements=string +
    +
    + +
    List of domain search suffixes.
    +
    +
    + host_name + +
    + string +
    +
    + +
    Device hostname.
    +
    +
    + name_server + +
    + list + / elements=string +
    +
    + +
    List of DNS name servers.
    +

    aliases: name_servers
    +
    +
    + state + +
    + string +
    +
    +
      Choices: +
    • present ←
    • +
    • absent
    • +
    +
    +
    present applies the configuration.
    +
    absent removes the configuration.
    +
    +
    + + +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 `_, the following are the fields unique to this module: + +.. raw:: html + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    KeyReturnedDescription
    +
    + after + +
    + dictionary +
    +
    when changed +
    Module-owned system configuration after this module ran.
    +
    +
    +
    + before + +
    + dictionary +
    +
    always +
    Module-owned system configuration before this module ran.
    +
    +
    +
    + commands + +
    + list +
    +
    always +
    List of API command tuples sent to the device.
    +
    +
    +
    + response + +
    + dictionary +
    +
    always +
    Raw API response.
    +
    +
    +
    + saved + +
    + boolean +
    +
    when changed +
    Whether the config was saved after changes.
    +
    +
    +

    + + +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 dictionary
    when changes are appliedalways
    Raw API response.

    @@ -373,7 +373,7 @@ Common return values are documented `here boolean
    when changes are appliedwhen changed
    Whether the config was saved after changes.

    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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    ParameterChoices/DefaultsComments
    +
    + config + +
    + list + / elements=dictionary +
    +
    + +
    List of VLAN configurations.
    +
    +
    + address + +
    + string +
    +
    + +
    IP address for the VLAN interface.
    +
    +
    + description + +
    + string +
    +
    + +
    VLAN description.
    +
    +
    + interfaces + +
    + list + / elements=string + / required +
    +
    + +
    List of Ethernet interfaces to configure this VLAN on.
    +
    +
    + vlan_id + +
    + integer + / required +
    +
    + +
    VLAN ID (0-4094).
    +
    +
    + state + +
    + string +
    +
    +
      Choices: +
    • present ←
    • +
    • absent
    • +
    • gathered
    • +
    +
    +
    present creates or updates VLANs.
    +
    absent removes VLANs.
    +
    gathered returns current VLAN configuration.
    +
    +
    + + +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 `_, the following are the fields unique to this module: + +.. raw:: html + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    KeyReturnedDescription
    +
    + after + +
    + list +
    +
    when changed +
    VLAN configuration after this module ran.
    +
    +
    +
    + before + +
    + list +
    +
    always +
    VLAN configuration before this module ran.
    +
    +
    +
    + commands + +
    + list +
    +
    always +
    List of API command tuples sent to the device.
    +
    +
    +
    + gathered + +
    + list +
    +
    when state is gathered +
    Current VLAN configuration as structured data.
    +
    +
    +
    + response + +
    + dictionary +
    +
    always +
    Raw API response.
    +
    +
    +
    + saved + +
    + boolean +
    +
    when changed +
    Whether the config was saved after changes.
    +
    +
    +

    + + +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." 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 ) -- +# 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.. Sibling +# top-level keys under the same afi (e.g. firewall..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..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..name -- + the most targeted path available, deliberately not a broader fetch at + firewall. 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..address is a genuine tagNode (VRRP +# virtual IPs, each with real child structure), but virtual-server. +# .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 , 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 level + system syslog local facility level (was: global) + system syslog remote facility ... (was: host) + system syslog user facility ... + system syslog marker interval (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..range is a tagNode +# (has a nested "seq" leaf), but cgnat.pool.internal..range is a +# plain multi-value leafNode (). 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..range is a genuine tag node +# (confirmed: nested "seq" leaf); cgnat.pool.internal..range is a +# plain multi-value leafNode (confirmed ). 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 ), 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.). 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 / +# 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..vif. -> 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. 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..name fetch is preserved + (not widened to a broader firewall. 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 + 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 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..address is a flat + scalar (the load-balancer bind address), unlike vrrp.group. + .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 ), 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"][], 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..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() -- cgit v1.2.3