diff --git a/plugins/module_utils/network/vyos/rm_templates/vpn_ipsec.py b/plugins/module_utils/network/vyos/rm_templates/vpn_ipsec.py index ccb63585..3198691e 100644 --- a/plugins/module_utils/network/vyos/rm_templates/vpn_ipsec.py +++ b/plugins/module_utils/network/vyos/rm_templates/vpn_ipsec.py @@ -1,1020 +1,1020 @@ # -*- coding: utf-8 -*- # Copyright 2026 Red Hat # GNU General Public License v3.0+ # (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) from __future__ import absolute_import, division, print_function __metaclass__ = type """ The Vpn_ipsec parser templates file. This contains a list of parser definitions and associated functions that facilitates both facts gathering and native command generation for the given network resource. NOTE: this is a first pass, built only against the fixture lines actually captured on target150 so far: set vpn ipsec authentication psk PSK-TEST id 'local@example.com' set vpn ipsec authentication psk PSK-TEST id 'remote@example.com' set vpn ipsec authentication psk PSK-TEST secret 'test-not-real-secret' set vpn ipsec esp-group ESP-TEST proposal 1 encryption 'aes256' set vpn ipsec esp-group ESP-TEST proposal 1 hash 'sha256' set vpn ipsec ike-group IKE-TEST key-exchange 'ikev2' set vpn ipsec ike-group IKE-TEST proposal 1 dh-group '14' set vpn ipsec ike-group IKE-TEST proposal 1 encryption 'aes256' set vpn ipsec ike-group IKE-TEST proposal 1 hash 'sha256' set vpn ipsec profile testprofile authentication mode 'pre-shared-secret' set vpn ipsec profile testprofile authentication pre-shared-secret 'test-not-real-secret' set vpn ipsec profile testprofile bind tunnel 'tun0' set vpn ipsec profile testprofile esp-group 'ESP-TEST' set vpn ipsec profile testprofile ike-group 'IKE-TEST' Remaining fields from the docstring (dead_peer_detection, lifetime, mode, pfs, disable_rekey, compression, log, options, disable_uniqreqids, esp_group/ike_group bare-tag-only lines) are NOT covered here yet — need a second fixture pass exercising those before this is complete. """ import re from ansible_collections.ansible.netcommon.plugins.module_utils.network.common.rm_base.network_template import ( NetworkTemplate, ) class Vpn_ipsecTemplate(NetworkTemplate): def __init__(self, lines=None, module=None): prefix = {"set": "set", "remove": "delete"} super(Vpn_ipsecTemplate, self).__init__( lines=lines, tmplt=self, prefix=prefix, module=module, ) # fmt: off PARSERS = [ # --------------------------------------------------------------- # esp-group # --------------------------------------------------------------- { "name": "esp_group", "getval": re.compile( r""" ^set\svpn\sipsec\sesp-group\s(?P\S+) \s*$""", re.VERBOSE, ), "setval": "vpn ipsec esp-group {{ name }}", "result": { "esp_group": { "{{ esp_group }}": { "name": "{{ esp_group }}", }, }, }, }, { "name": "esp_group.proposal", "getval": re.compile( r""" ^set\svpn\sipsec\sesp-group\s(?P\S+) \sproposal\s(?P\d+) \s*$""", re.VERBOSE, ), "setval": "vpn ipsec esp-group {{ name }} proposal {{ proposal_id }}", "result": { "esp_group": { "{{ esp_group }}": { "name": "{{ esp_group }}", "proposal": { "{{ proposal_id }}": { "proposal_id": "{{ proposal_id }}", }, }, }, }, }, }, { "name": "esp_group.proposal.encryption", "getval": re.compile( r""" ^set\svpn\sipsec\sesp-group\s(?P\S+) \sproposal\s(?P\d+) \sencryption\s'?(?P[\w-]+)'? \s*$""", re.VERBOSE, ), "setval": "vpn ipsec esp-group {{ name }} proposal {{ proposal_id }} encryption {{ encryption }}", "result": { "esp_group": { "{{ esp_group }}": { "name": "{{ esp_group }}", "proposal": { "{{ proposal_id }}": { "proposal_id": "{{ proposal_id }}", "encryption": "{{ encryption }}", }, }, }, }, }, }, { "name": "esp_group.proposal.hash", "getval": re.compile( r""" ^set\svpn\sipsec\sesp-group\s(?P\S+) \sproposal\s(?P\d+) \shash\s'?(?P[\w-]+)'? \s*$""", re.VERBOSE, ), "setval": "vpn ipsec esp-group {{ name }} proposal {{ proposal_id }} hash {{ hash }}", "result": { "esp_group": { "{{ esp_group }}": { "name": "{{ esp_group }}", "proposal": { "{{ proposal_id }}": { "proposal_id": "{{ proposal_id }}", "hash": "{{ hash }}", }, }, }, }, }, }, # --------------------------------------------------------------- # ike-group # --------------------------------------------------------------- { "name": "ike_group", "getval": re.compile( r""" ^set\svpn\sipsec\sike-group\s(?P\S+) \s*$""", re.VERBOSE, ), "setval": "vpn ipsec ike-group {{ name }}", "result": { "ike_group": { "{{ ike_group }}": { "name": "{{ ike_group }}", }, }, }, }, { "name": "ike_group.key_exchange", "getval": re.compile( r""" ^set\svpn\sipsec\sike-group\s(?P\S+) \skey-exchange\s'?(?P\w+)'? \s*$""", re.VERBOSE, ), "setval": "vpn ipsec ike-group {{ name }} key-exchange {{ key_exchange }}", "result": { "ike_group": { "{{ ike_group }}": { "name": "{{ ike_group }}", "key_exchange": "{{ key_exchange }}", }, }, }, }, { "name": "ike_group.proposal", "getval": re.compile( r""" ^set\svpn\sipsec\sike-group\s(?P\S+) \sproposal\s(?P\d+) \s*$""", re.VERBOSE, ), "setval": "vpn ipsec ike-group {{ name }} proposal {{ proposal_id }}", "result": { "ike_group": { "{{ ike_group }}": { "name": "{{ ike_group }}", "proposal": { "{{ proposal_id }}": { "proposal_id": "{{ proposal_id }}", }, }, }, }, }, }, { "name": "ike_group.proposal.dh_group", "getval": re.compile( r""" ^set\svpn\sipsec\sike-group\s(?P\S+) \sproposal\s(?P\d+) \sdh-group\s'?(?P\d+)'? \s*$""", re.VERBOSE, ), "setval": "vpn ipsec ike-group {{ name }} proposal {{ proposal_id }} dh-group {{ dh_group }}", "result": { "ike_group": { "{{ ike_group }}": { "name": "{{ ike_group }}", "proposal": { "{{ proposal_id }}": { "proposal_id": "{{ proposal_id }}", "dh_group": "{{ dh_group }}", }, }, }, }, }, }, { "name": "ike_group.proposal.encryption", "getval": re.compile( r""" ^set\svpn\sipsec\sike-group\s(?P\S+) \sproposal\s(?P\d+) \sencryption\s'?(?P[\w-]+)'? \s*$""", re.VERBOSE, ), "setval": "vpn ipsec ike-group {{ name }} proposal {{ proposal_id }} encryption {{ encryption }}", "result": { "ike_group": { "{{ ike_group }}": { "name": "{{ ike_group }}", "proposal": { "{{ proposal_id }}": { "proposal_id": "{{ proposal_id }}", "encryption": "{{ encryption }}", }, }, }, }, }, }, { "name": "ike_group.proposal.hash", "getval": re.compile( r""" ^set\svpn\sipsec\sike-group\s(?P\S+) \sproposal\s(?P\d+) \shash\s'?(?P[\w-]+)'? \s*$""", re.VERBOSE, ), "setval": "vpn ipsec ike-group {{ name }} proposal {{ proposal_id }} hash {{ hash }}", "result": { "ike_group": { "{{ ike_group }}": { "name": "{{ ike_group }}", "proposal": { "{{ proposal_id }}": { "proposal_id": "{{ proposal_id }}", "hash": "{{ hash }}", }, }, }, }, }, }, # --------------------------------------------------------------- # authentication psk # --------------------------------------------------------------- { "name": "authentication.psk", "getval": re.compile( r""" ^set\svpn\sipsec\sauthentication\spsk\s(?P\S+) \s*$""", re.VERBOSE, ), "setval": "vpn ipsec authentication psk {{ name }}", "result": { "authentication": { "psk": { "{{ psk }}": { "name": "{{ psk }}", }, }, }, }, }, { "name": "authentication.psk.id", "getval": re.compile( r""" ^set\svpn\sipsec\sauthentication\spsk\s(?P\S+) \sid\s'?(?P\S+?)'? \s*$""", re.VERBOSE, ), "setval": "vpn ipsec authentication psk {{ name }} id {{ id }}", "result": { "authentication": { "psk": { "{{ psk }}": { "name": "{{ psk }}", "id": ["{{ id }}"], }, }, }, }, }, { "name": "authentication.psk.secret", "getval": re.compile( r""" ^set\svpn\sipsec\sauthentication\spsk\s(?P\S+) \ssecret\s'?(?P[^']+)'? \s*$""", re.VERBOSE, ), "setval": "vpn ipsec authentication psk {{ name }} secret '{{ secret }}'", "result": { "authentication": { "psk": { "{{ psk }}": { "name": "{{ psk }}", "secret": "{{ secret }}", }, }, }, }, }, # --------------------------------------------------------------- # profile # --------------------------------------------------------------- { "name": "profile", "getval": re.compile( r""" ^set\svpn\sipsec\sprofile\s(?P\S+) \s*$""", re.VERBOSE, ), "setval": "vpn ipsec profile {{ name }}", "result": { "profile": { "{{ profile }}": { "name": "{{ profile }}", }, }, }, }, { "name": "profile.authentication.mode", "getval": re.compile( r""" ^set\svpn\sipsec\sprofile\s(?P\S+) \sauthentication\smode\s'?(?P[\w-]+)'? \s*$""", re.VERBOSE, ), - "setval": "vpn ipsec profile {{ name }} authentication mode {{ authentication.mode }}", + "setval": "vpn ipsec profile {{ name }} authentication mode {{ mode }}", "result": { "profile": { "{{ profile }}": { "name": "{{ profile }}", "authentication": { "mode": "{{ mode }}", }, }, }, }, }, { "name": "profile.authentication.pre_shared_secret", "getval": re.compile( r""" ^set\svpn\sipsec\sprofile\s(?P\S+) \sauthentication\spre-shared-secret\s'?(?P[^']+)'? \s*$""", re.VERBOSE, ), - "setval": "vpn ipsec profile {{ name }} authentication pre-shared-secret '{{ authentication.pre_shared_secret }}'", + "setval": "vpn ipsec profile {{ name }} authentication pre-shared-secret '{{ pre_shared_secret }}'", "result": { "profile": { "{{ profile }}": { "name": "{{ profile }}", "authentication": { "pre_shared_secret": "{{ pre_shared_secret }}", }, }, }, }, }, { "name": "profile.bind_tunnel", "getval": re.compile( r""" ^set\svpn\sipsec\sprofile\s(?P\S+) \sbind\stunnel\s'?(?P\S+?)'? \s*$""", re.VERBOSE, ), "setval": "vpn ipsec profile {{ name }} bind tunnel {{ bind_tunnel }}", "result": { "profile": { "{{ profile }}": { "name": "{{ profile }}", "bind_tunnel": ["{{ bind_tunnel }}"], }, }, }, }, { "name": "profile.esp_group", "getval": re.compile( r""" ^set\svpn\sipsec\sprofile\s(?P\S+) \sesp-group\s'?(?P\S+?)'? \s*$""", re.VERBOSE, ), "setval": "vpn ipsec profile {{ name }} esp-group {{ esp_group }}", "result": { "profile": { "{{ profile }}": { "name": "{{ profile }}", "esp_group": "{{ esp_group }}", }, }, }, }, { "name": "profile.ike_group", "getval": re.compile( r""" ^set\svpn\sipsec\sprofile\s(?P\S+) \sike-group\s'?(?P\S+?)'? \s*$""", re.VERBOSE, ), "setval": "vpn ipsec profile {{ name }} ike-group {{ ike_group }}", "result": { "profile": { "{{ profile }}": { "name": "{{ profile }}", "ike_group": "{{ ike_group }}", }, }, }, }, # --------------------------------------------------------------- # ike-group: remaining fields # --------------------------------------------------------------- { "name": "ike_group.close_action", "getval": re.compile( r""" ^set\svpn\sipsec\sike-group\s(?P\S+) \sclose-action\s'?(?P[\w-]+)'? \s*$""", re.VERBOSE, ), "setval": "vpn ipsec ike-group {{ name }} close-action {{ close_action }}", "result": { "ike_group": { "{{ ike_group }}": { "name": "{{ ike_group }}", "close_action": "{{ close_action }}", }, }, }, }, { "name": "ike_group.dead_peer_detection.action", "getval": re.compile( r""" ^set\svpn\sipsec\sike-group\s(?P\S+) \sdead-peer-detection\saction\s'?(?P[\w-]+)'? \s*$""", re.VERBOSE, ), "setval": "vpn ipsec ike-group {{ name }} dead-peer-detection action {{ action }}", "result": { "ike_group": { "{{ ike_group }}": { "name": "{{ ike_group }}", "dead_peer_detection": {"action": "{{ action }}"}, }, }, }, }, { "name": "ike_group.dead_peer_detection.interval", "getval": re.compile( r""" ^set\svpn\sipsec\sike-group\s(?P\S+) \sdead-peer-detection\sinterval\s'?(?P\d+)'? \s*$""", re.VERBOSE, ), "setval": "vpn ipsec ike-group {{ name }} dead-peer-detection interval {{ interval }}", "result": { "ike_group": { "{{ ike_group }}": { "name": "{{ ike_group }}", "dead_peer_detection": {"interval": "{{ interval }}"}, }, }, }, }, { "name": "ike_group.dead_peer_detection.timeout", "getval": re.compile( r""" ^set\svpn\sipsec\sike-group\s(?P\S+) \sdead-peer-detection\stimeout\s'?(?P\d+)'? \s*$""", re.VERBOSE, ), "setval": "vpn ipsec ike-group {{ name }} dead-peer-detection timeout {{ timeout }}", "result": { "ike_group": { "{{ ike_group }}": { "name": "{{ ike_group }}", "dead_peer_detection": {"timeout": "{{ timeout }}"}, }, }, }, }, { "name": "ike_group.disable_mobike", "getval": re.compile( r""" ^set\svpn\sipsec\sike-group\s(?P\S+) \sdisable-mobike \s*$""", re.VERBOSE, ), "setval": "vpn ipsec ike-group {{ name }} disable-mobike", "result": { "ike_group": { "{{ ike_group }}": { "name": "{{ ike_group }}", "disable_mobike": True, }, }, }, }, { "name": "ike_group.ikev2_reauth", "getval": re.compile( r""" ^set\svpn\sipsec\sike-group\s(?P\S+) \sikev2-reauth \s*$""", re.VERBOSE, ), "setval": "vpn ipsec ike-group {{ name }} ikev2-reauth", "result": { "ike_group": { "{{ ike_group }}": { "name": "{{ ike_group }}", "ikev2_reauth": True, }, }, }, }, { "name": "ike_group.lifetime", "getval": re.compile( r""" ^set\svpn\sipsec\sike-group\s(?P\S+) \slifetime\s'?(?P\d+)'? \s*$""", re.VERBOSE, ), "setval": "vpn ipsec ike-group {{ name }} lifetime {{ lifetime }}", "result": { "ike_group": { "{{ ike_group }}": { "name": "{{ ike_group }}", "lifetime": "{{ lifetime }}", }, }, }, }, { "name": "ike_group.mode", "getval": re.compile( r""" ^set\svpn\sipsec\sike-group\s(?P\S+) \smode\s'?(?P[\w-]+)'? \s*$""", re.VERBOSE, ), "setval": "vpn ipsec ike-group {{ name }} mode {{ mode }}", "result": { "ike_group": { "{{ ike_group }}": { "name": "{{ ike_group }}", "mode": "{{ mode }}", }, }, }, }, # --------------------------------------------------------------- # esp-group: remaining fields # --------------------------------------------------------------- { "name": "esp_group.compression", "getval": re.compile( r""" ^set\svpn\sipsec\sesp-group\s(?P\S+) \scompression \s*$""", re.VERBOSE, ), "setval": "vpn ipsec esp-group {{ name }} compression", "result": { "esp_group": { "{{ esp_group }}": { "name": "{{ esp_group }}", "compression": True, }, }, }, }, { "name": "esp_group.disable_rekey", "getval": re.compile( r""" ^set\svpn\sipsec\sesp-group\s(?P\S+) \sdisable-rekey \s*$""", re.VERBOSE, ), "setval": "vpn ipsec esp-group {{ name }} disable-rekey", "result": { "esp_group": { "{{ esp_group }}": { "name": "{{ esp_group }}", "disable_rekey": True, }, }, }, }, { "name": "esp_group.life_bytes", "getval": re.compile( r""" ^set\svpn\sipsec\sesp-group\s(?P\S+) \slife-bytes\s'?(?P\d+)'? \s*$""", re.VERBOSE, ), "setval": "vpn ipsec esp-group {{ name }} life-bytes {{ life_bytes }}", "result": { "esp_group": { "{{ esp_group }}": { "name": "{{ esp_group }}", "life_bytes": "{{ life_bytes }}", }, }, }, }, { "name": "esp_group.life_packets", "getval": re.compile( r""" ^set\svpn\sipsec\sesp-group\s(?P\S+) \slife-packets\s'?(?P\d+)'? \s*$""", re.VERBOSE, ), "setval": "vpn ipsec esp-group {{ name }} life-packets {{ life_packets }}", "result": { "esp_group": { "{{ esp_group }}": { "name": "{{ esp_group }}", "life_packets": "{{ life_packets }}", }, }, }, }, { "name": "esp_group.lifetime", "getval": re.compile( r""" ^set\svpn\sipsec\sesp-group\s(?P\S+) \slifetime\s'?(?P\d+)'? \s*$""", re.VERBOSE, ), "setval": "vpn ipsec esp-group {{ name }} lifetime {{ lifetime }}", "result": { "esp_group": { "{{ esp_group }}": { "name": "{{ esp_group }}", "lifetime": "{{ lifetime }}", }, }, }, }, { "name": "esp_group.mode", "getval": re.compile( r""" ^set\svpn\sipsec\sesp-group\s(?P\S+) \smode\s'?(?P[\w-]+)'? \s*$""", re.VERBOSE, ), "setval": "vpn ipsec esp-group {{ name }} mode {{ mode }}", "result": { "esp_group": { "{{ esp_group }}": { "name": "{{ esp_group }}", "mode": "{{ mode }}", }, }, }, }, { "name": "esp_group.pfs", "getval": re.compile( r""" ^set\svpn\sipsec\sesp-group\s(?P\S+) \spfs\s'?(?P[\w-]+)'? \s*$""", re.VERBOSE, ), "setval": "vpn ipsec esp-group {{ name }} pfs {{ pfs }}", "result": { "esp_group": { "{{ esp_group }}": { "name": "{{ esp_group }}", "pfs": "{{ pfs }}", }, }, }, }, # --------------------------------------------------------------- # authentication.psk: remaining fields # --------------------------------------------------------------- { "name": "authentication.psk.secret_type", "getval": re.compile( r""" ^set\svpn\sipsec\sauthentication\spsk\s(?P\S+) \ssecret-type\s'?(?P[\w-]+)'? \s*$""", re.VERBOSE, ), "setval": "vpn ipsec authentication psk {{ name }} secret-type {{ secret_type }}", "result": { "authentication": { "psk": { "{{ psk }}": { "name": "{{ psk }}", "secret_type": "{{ secret_type }}", }, }, }, }, }, { "name": "authentication.psk.dhcp_interface", "getval": re.compile( r""" ^set\svpn\sipsec\sauthentication\spsk\s(?P\S+) \sdhcp-interface\s'?(?P\S+?)'? \s*$""", re.VERBOSE, ), "setval": "vpn ipsec authentication psk {{ name }} dhcp-interface {{ dhcp_interface }}", "result": { "authentication": { "psk": { "{{ psk }}": { "name": "{{ psk }}", "dhcp_interface": ["{{ dhcp_interface }}"], }, }, }, }, }, # --------------------------------------------------------------- # authentication.ppk # --------------------------------------------------------------- { "name": "authentication.ppk", "getval": re.compile( r""" ^set\svpn\sipsec\sauthentication\sppk\s(?P\S+) \s*$""", re.VERBOSE, ), "setval": "vpn ipsec authentication ppk {{ name }}", "result": { "authentication": { "ppk": { "{{ ppk }}": { "name": "{{ ppk }}", }, }, }, }, }, { "name": "authentication.ppk.id", "getval": re.compile( r""" ^set\svpn\sipsec\sauthentication\sppk\s(?P\S+) \sid\s'?(?P\S+?)'? \s*$""", re.VERBOSE, ), "setval": "vpn ipsec authentication ppk {{ name }} id {{ id }}", "result": { "authentication": { "ppk": { "{{ ppk }}": { "name": "{{ ppk }}", "id": ["{{ id }}"], }, }, }, }, }, { "name": "authentication.ppk.secret", "getval": re.compile( r""" ^set\svpn\sipsec\sauthentication\sppk\s(?P\S+) \ssecret\s'?(?P[^']+)'? \s*$""", re.VERBOSE, ), "setval": "vpn ipsec authentication ppk {{ name }} secret '{{ secret }}'", "result": { "authentication": { "ppk": { "{{ ppk }}": { "name": "{{ ppk }}", "secret": "{{ secret }}", }, }, }, }, }, { "name": "authentication.ppk.secret_type", "getval": re.compile( r""" ^set\svpn\sipsec\sauthentication\sppk\s(?P\S+) \ssecret-type\s'?(?P[\w-]+)'? \s*$""", re.VERBOSE, ), "setval": "vpn ipsec authentication ppk {{ name }} secret-type {{ secret_type }}", "result": { "authentication": { "ppk": { "{{ ppk }}": { "name": "{{ ppk }}", "secret_type": "{{ secret_type }}", }, }, }, }, }, # --------------------------------------------------------------- # profile: remaining fields # --------------------------------------------------------------- { "name": "profile.disable", "getval": re.compile( r""" ^set\svpn\sipsec\sprofile\s(?P\S+) \sdisable \s*$""", re.VERBOSE, ), "setval": "vpn ipsec profile {{ name }} disable", "result": { "profile": { "{{ profile }}": { "name": "{{ profile }}", "disable": True, }, }, }, }, # --------------------------------------------------------------- # top-level: interface, log, options, disable_uniqreqids # --------------------------------------------------------------- { "name": "interface", "getval": re.compile( r""" ^set\svpn\sipsec\sinterface\s'?(?P\S+?)'? \s*$""", re.VERBOSE, ), "setval": "vpn ipsec interface {{ interface }}", "result": { "interface": ["{{ interface }}"], }, }, { "name": "log.level", "getval": re.compile( r""" ^set\svpn\sipsec\slog\slevel\s'?(?P\d+)'? \s*$""", re.VERBOSE, ), "setval": "vpn ipsec log level {{ level }}", "result": { "log": {"level": "{{ level }}"}, }, }, { "name": "log.subsystem", "getval": re.compile( r""" ^set\svpn\sipsec\slog\ssubsystem\s'?(?P\S+?)'? \s*$""", re.VERBOSE, ), "setval": "vpn ipsec log subsystem {{ subsystem }}", "result": { "log": {"subsystem": ["{{ subsystem }}"]}, }, }, { "name": "options.disable_route_autoinstall", "getval": re.compile( r""" ^set\svpn\sipsec\soptions\sdisable-route-autoinstall \s*$""", re.VERBOSE, ), "setval": "vpn ipsec options disable-route-autoinstall", "result": { "options": {"disable_route_autoinstall": True}, }, }, { "name": "options.flexvpn", "getval": re.compile( r""" ^set\svpn\sipsec\soptions\sflexvpn \s*$""", re.VERBOSE, ), "setval": "vpn ipsec options flexvpn", "result": { "options": {"flexvpn": True}, }, }, { "name": "options.interface", "getval": re.compile( r""" ^set\svpn\sipsec\soptions\sinterface\s'?(?P\S+?)'? \s*$""", re.VERBOSE, ), "setval": "vpn ipsec options interface {{ interface }}", "result": { "options": {"interface": "{{ interface }}"}, }, }, { "name": "options.retransmission.attempts", "getval": re.compile( r""" ^set\svpn\sipsec\soptions\sretransmission\sattempts\s'?(?P\d+)'? \s*$""", re.VERBOSE, ), "setval": "vpn ipsec options retransmission attempts {{ attempts }}", "result": { "options": {"retransmission": {"attempts": "{{ attempts }}"}}, }, }, { "name": "options.retransmission.base", "getval": re.compile( r""" ^set\svpn\sipsec\soptions\sretransmission\sbase\s'?(?P[\d.]+)'? \s*$""", re.VERBOSE, ), "setval": "vpn ipsec options retransmission base {{ base }}", "result": { "options": {"retransmission": {"base": "{{ base }}"}}, }, }, { "name": "options.retransmission.timeout", "getval": re.compile( r""" ^set\svpn\sipsec\soptions\sretransmission\stimeout\s'?(?P\d+)'? \s*$""", re.VERBOSE, ), "setval": "vpn ipsec options retransmission timeout {{ timeout }}", "result": { "options": {"retransmission": {"timeout": "{{ timeout }}"}}, }, }, { "name": "options.virtual_ip", "getval": re.compile( r""" ^set\svpn\sipsec\soptions\svirtual-ip \s*$""", re.VERBOSE, ), "setval": "vpn ipsec options virtual-ip", "result": { "options": {"virtual_ip": True}, }, }, { "name": "disable_uniqreqids", "getval": re.compile( r""" ^set\svpn\sipsec\sdisable-uniqreqids \s*$""", re.VERBOSE, ), "setval": "vpn ipsec disable-uniqreqids", "result": { "disable_uniqreqids": True, }, }, ] # fmt: on diff --git a/tests/integration/targets/vyos_vpn_ipsec/defaults/main.yaml b/tests/integration/targets/vyos_vpn_ipsec/defaults/main.yaml new file mode 100644 index 00000000..164afead --- /dev/null +++ b/tests/integration/targets/vyos_vpn_ipsec/defaults/main.yaml @@ -0,0 +1,3 @@ +--- +testcase: "[^_].*" +test_items: [] diff --git a/tests/integration/targets/vyos_vpn_ipsec/meta/main.yaml b/tests/integration/targets/vyos_vpn_ipsec/meta/main.yaml new file mode 100644 index 00000000..ed97d539 --- /dev/null +++ b/tests/integration/targets/vyos_vpn_ipsec/meta/main.yaml @@ -0,0 +1 @@ +--- diff --git a/tests/integration/targets/vyos_vpn_ipsec/tasks/cli.yaml b/tests/integration/targets/vyos_vpn_ipsec/tasks/cli.yaml new file mode 100644 index 00000000..daccf720 --- /dev/null +++ b/tests/integration/targets/vyos_vpn_ipsec/tasks/cli.yaml @@ -0,0 +1,20 @@ +--- +- name: Collect all cli test cases + ansible.builtin.find: + paths: "{{ role_path }}/tests/cli" + 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 }}" + +- name: Run test case (connection=ansible.netcommon.network_cli) + ansible.builtin.include_tasks: "{{ test_case_to_run }}" + vars: + ansible_connection: ansible.netcommon.network_cli + with_items: "{{ test_items }}" + loop_control: + loop_var: test_case_to_run diff --git a/tests/integration/targets/vyos_vpn_ipsec/tasks/main.yaml b/tests/integration/targets/vyos_vpn_ipsec/tasks/main.yaml new file mode 100644 index 00000000..e6378581 --- /dev/null +++ b/tests/integration/targets/vyos_vpn_ipsec/tasks/main.yaml @@ -0,0 +1,5 @@ +--- +- name: Run CLI tests + ansible.builtin.include_tasks: cli.yaml + tags: + - network_cli diff --git a/tests/integration/targets/vyos_vpn_ipsec/tests/cli/_parsed.cfg b/tests/integration/targets/vyos_vpn_ipsec/tests/cli/_parsed.cfg new file mode 100644 index 00000000..85a382e7 --- /dev/null +++ b/tests/integration/targets/vyos_vpn_ipsec/tests/cli/_parsed.cfg @@ -0,0 +1,3 @@ +set vpn ipsec esp-group ESP-PARSE-TEST proposal 1 encryption 'aes256' +set vpn ipsec esp-group ESP-PARSE-TEST proposal 1 hash 'sha256' +set vpn ipsec ike-group IKE-PARSE-TEST key-exchange 'ikev2' diff --git a/tests/integration/targets/vyos_vpn_ipsec/tests/cli/_populate.yaml b/tests/integration/targets/vyos_vpn_ipsec/tests/cli/_populate.yaml new file mode 100644 index 00000000..96842807 --- /dev/null +++ b/tests/integration/targets/vyos_vpn_ipsec/tests/cli/_populate.yaml @@ -0,0 +1,8 @@ +--- +- ansible.builtin.include_tasks: _remove_config.yaml + +- name: POPULATE Apply the provided configuration + register: result + vyos.vyos.vyos_vpn_ipsec: + config: "{{ populate_config }}" + state: merged diff --git a/tests/integration/targets/vyos_vpn_ipsec/tests/cli/_remove_config.yaml b/tests/integration/targets/vyos_vpn_ipsec/tests/cli/_remove_config.yaml new file mode 100644 index 00000000..14b032e3 --- /dev/null +++ b/tests/integration/targets/vyos_vpn_ipsec/tests/cli/_remove_config.yaml @@ -0,0 +1,4 @@ +--- +- name: delete the provided configuration + vyos.vyos.vyos_vpn_ipsec: + state: deleted diff --git a/tests/integration/targets/vyos_vpn_ipsec/tests/cli/deleted.yaml b/tests/integration/targets/vyos_vpn_ipsec/tests/cli/deleted.yaml new file mode 100644 index 00000000..eee2c822 --- /dev/null +++ b/tests/integration/targets/vyos_vpn_ipsec/tests/cli/deleted.yaml @@ -0,0 +1,69 @@ +--- +- debug: + msg: START vyos_vpn_ipsec deleted integration tests on connection={{ ansible_connection }} + +- include_tasks: _populate.yaml + +- block: + - name: TEST - [deleted] Remove all configuration + vyos.vyos.vyos_vpn_ipsec: + state: deleted + register: result + + - name: TEST - [deleted] Assert that correct set of commands were generated + assert: + that: + - "{{ deleted['commands'] | symmetric_difference(result['commands']) | length == 0 }}" + - result.changed == true + - result.after == deleted.after + + - name: TEST - [deleted] Remove all configuration again (IDEMPOTENT) + vyos.vyos.vyos_vpn_ipsec: + state: deleted + register: result + + - name: TEST - [deleted] Assert idempotent + assert: + that: + - result.changed == false + - result.commands == [] + + always: + - include_tasks: _remove_config.yaml + +- block: + - include_tasks: _populate.yaml + + - name: TEST - [deleted scoped] Remove only IKE-EXTRA by name + vyos.vyos.vyos_vpn_ipsec: + config: + ike_group: + - name: IKE-EXTRA + state: deleted + register: result + + - name: TEST - [deleted scoped] Assert only IKE-EXTRA was removed + assert: + that: + - result.commands == ["delete vpn ipsec ike-group IKE-EXTRA"] + - result.changed == true + + - vyos.vyos.vyos_facts: + gather_network_resources: vpn_ipsec + + - name: TEST - [deleted scoped] Assert IKE-TEST and everything else survived + vars: + redacted: VALUE_SPECIFIED_IN_NO_LOG_PARAMETER + assert: + that: + - ansible_facts['network_resources']['vpn_ipsec']['ike_group'] == [populate_config.ike_group[0]] + - ansible_facts['network_resources']['vpn_ipsec']['esp_group'] == populate_config.esp_group + - ansible_facts['network_resources']['vpn_ipsec']['profile'][0]['name'] == populate_config.profile[0].name + - ansible_facts['network_resources']['vpn_ipsec']['profile'][0]['authentication']['mode'] == populate_config.profile[0].authentication.mode + - ansible_facts['network_resources']['vpn_ipsec']['profile'][0]['authentication']['pre_shared_secret'] == redacted + - ansible_facts['network_resources']['vpn_ipsec']['profile'][0]['bind_tunnel'] == populate_config.profile[0].bind_tunnel + - ansible_facts['network_resources']['vpn_ipsec']['profile'][0]['esp_group'] == populate_config.profile[0].esp_group + - ansible_facts['network_resources']['vpn_ipsec']['profile'][0]['ike_group'] == populate_config.profile[0].ike_group + + always: + - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_vpn_ipsec/tests/cli/empty_config.yaml b/tests/integration/targets/vyos_vpn_ipsec/tests/cli/empty_config.yaml new file mode 100644 index 00000000..7d0329fd --- /dev/null +++ b/tests/integration/targets/vyos_vpn_ipsec/tests/cli/empty_config.yaml @@ -0,0 +1,58 @@ +--- +- debug: + msg: START vyos_vpn_ipsec empty_config integration tests on connection={{ ansible_connection }} + +- name: Merged with empty config should give appropriate error message + register: result + ignore_errors: true + vyos.vyos.vyos_vpn_ipsec: + config: + state: merged + +- assert: + that: + - result.msg == 'value of config parameter must not be empty for state merged' + +- name: Replaced with empty config should give appropriate error message + register: result + ignore_errors: true + vyos.vyos.vyos_vpn_ipsec: + config: + state: replaced + +- assert: + that: + - result.msg == 'value of config parameter must not be empty for state replaced' + +- name: Overridden with empty config should give appropriate error message + register: result + ignore_errors: true + vyos.vyos.vyos_vpn_ipsec: + config: + state: overridden + +- assert: + that: + - result.msg == 'value of config parameter must not be empty for state overridden' + +- name: Rendered with empty config should give appropriate error message + register: result + ignore_errors: true + vyos.vyos.vyos_vpn_ipsec: + config: + state: rendered + +- assert: + that: + - result.msg == 'value of config parameter must not be empty for state rendered' + +- name: Parsed with empty running_config should give appropriate error message + register: result + ignore_errors: true + vyos.vyos.vyos_vpn_ipsec: + running_config: + state: parsed + +- assert: + that: + - result.msg == 'value of running_config parameter must not be empty for state parsed' diff --git a/tests/integration/targets/vyos_vpn_ipsec/tests/cli/gathered.yaml b/tests/integration/targets/vyos_vpn_ipsec/tests/cli/gathered.yaml new file mode 100644 index 00000000..716bc305 --- /dev/null +++ b/tests/integration/targets/vyos_vpn_ipsec/tests/cli/gathered.yaml @@ -0,0 +1,28 @@ +--- +- debug: + msg: START vyos_vpn_ipsec gathered integration tests on connection={{ ansible_connection }} + +- include_tasks: _populate.yaml + +- block: + - name: TEST - [gathered] Gather the existing running configuration + register: result + vyos.vyos.vyos_vpn_ipsec: + state: gathered + + - name: TEST - [gathered] Assert + vars: + redacted: VALUE_SPECIFIED_IN_NO_LOG_PARAMETER + assert: + that: + - result.changed == false + - result.gathered.esp_group == populate_config.esp_group + - result.gathered.profile[0].name == populate_config.profile[0].name + - result.gathered.profile[0].authentication.mode == populate_config.profile[0].authentication.mode + - result.gathered.profile[0].authentication.pre_shared_secret == redacted + - result.gathered.profile[0].bind_tunnel == populate_config.profile[0].bind_tunnel + - result.gathered.profile[0].esp_group == populate_config.profile[0].esp_group + - result.gathered.profile[0].ike_group == populate_config.profile[0].ike_group + + always: + - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_vpn_ipsec/tests/cli/merged.yaml b/tests/integration/targets/vyos_vpn_ipsec/tests/cli/merged.yaml new file mode 100644 index 00000000..6c21d107 --- /dev/null +++ b/tests/integration/targets/vyos_vpn_ipsec/tests/cli/merged.yaml @@ -0,0 +1,37 @@ +--- +- debug: + msg: START vyos_vpn_ipsec merged integration tests on connection={{ ansible_connection }} + +- include_tasks: _remove_config.yaml + +- block: + - name: Merge the provided configuration with the existing running configuration + register: result + vyos.vyos.vyos_vpn_ipsec: &id001 + config: "{{ merged.config }}" + state: merged + + - vyos.vyos.vyos_facts: + gather_network_resources: vpn_ipsec + + - name: Assert that correct set of commands were generated + assert: + that: + - "{{ merged['commands'] | symmetric_difference(result['commands']) | length == 0 }}" + - result.changed == true + - result.after == ansible_facts['network_resources']['vpn_ipsec'] + - result.after == merged.after + - result.before == {} + + - name: Merge the provided configuration with the existing running configuration (IDEMPOTENT) + register: result + vyos.vyos.vyos_vpn_ipsec: *id001 + + - name: Assert that the previous task was idempotent + assert: + that: + - result['changed'] == false + - result['commands'] == [] + + always: + - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_vpn_ipsec/tests/cli/overridden.yaml b/tests/integration/targets/vyos_vpn_ipsec/tests/cli/overridden.yaml new file mode 100644 index 00000000..6a8eed30 --- /dev/null +++ b/tests/integration/targets/vyos_vpn_ipsec/tests/cli/overridden.yaml @@ -0,0 +1,36 @@ +--- +- debug: + msg: START vyos_vpn_ipsec overridden integration tests on connection={{ ansible_connection }} + +- include_tasks: _populate.yaml + +- block: + - name: Override with only ESP-TEST named -- everything else must be removed + register: result + vyos.vyos.vyos_vpn_ipsec: &id001 + config: "{{ overridden.config }}" + state: overridden + + - vyos.vyos.vyos_facts: + gather_network_resources: vpn_ipsec + + - name: Assert that correct set of commands were generated + assert: + that: + - "{{ overridden['commands'] | symmetric_difference(result['commands']) | length == 0 }}" + - result.changed == true + - result.after == ansible_facts['network_resources']['vpn_ipsec'] + - result.after == overridden.after + + - name: Override with the same configuration (IDEMPOTENT) + register: result + vyos.vyos.vyos_vpn_ipsec: *id001 + + - name: Assert that the previous task was idempotent + assert: + that: + - result['changed'] == false + - result['commands'] == [] + + always: + - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_vpn_ipsec/tests/cli/parsed.yaml b/tests/integration/targets/vyos_vpn_ipsec/tests/cli/parsed.yaml new file mode 100644 index 00000000..0d1197e4 --- /dev/null +++ b/tests/integration/targets/vyos_vpn_ipsec/tests/cli/parsed.yaml @@ -0,0 +1,14 @@ +--- +- debug: + msg: START vyos_vpn_ipsec parsed integration tests on connection={{ ansible_connection }} + +- name: TEST - [parsed] Parse the commands for provided configuration + register: result + vyos.vyos.vyos_vpn_ipsec: + running_config: "{{ lookup('file', '_parsed.cfg') }}" + state: parsed + +- assert: + that: + - result.changed == false + - parsed['after'] == result.parsed diff --git a/tests/integration/targets/vyos_vpn_ipsec/tests/cli/rendered.yaml b/tests/integration/targets/vyos_vpn_ipsec/tests/cli/rendered.yaml new file mode 100644 index 00000000..1babcc2b --- /dev/null +++ b/tests/integration/targets/vyos_vpn_ipsec/tests/cli/rendered.yaml @@ -0,0 +1,15 @@ +--- +- debug: + msg: START vyos_vpn_ipsec rendered integration tests on connection={{ ansible_connection }} + +- block: + - name: Structure provided configuration into device specific commands + register: result + vyos.vyos.vyos_vpn_ipsec: + config: "{{ rendered.config }}" + state: rendered + + - name: Assert that correct set of commands were generated + assert: + that: + - "{{ rendered['commands'] | symmetric_difference(result['rendered']) | length == 0 }}" diff --git a/tests/integration/targets/vyos_vpn_ipsec/tests/cli/replaced.yaml b/tests/integration/targets/vyos_vpn_ipsec/tests/cli/replaced.yaml new file mode 100644 index 00000000..0714e8dc --- /dev/null +++ b/tests/integration/targets/vyos_vpn_ipsec/tests/cli/replaced.yaml @@ -0,0 +1,61 @@ +--- +- debug: + msg: START vyos_vpn_ipsec replaced integration tests on connection={{ ansible_connection }} + +- include_tasks: _populate.yaml + +- block: + - name: Replace only the named esp_group -- other sections must be untouched + register: result + vyos.vyos.vyos_vpn_ipsec: &id001 + config: "{{ replaced.config }}" + state: replaced + + - vyos.vyos.vyos_facts: + gather_network_resources: vpn_ipsec + + - name: Assert that correct set of commands were generated + assert: + that: + - "{{ replaced['commands'] | symmetric_difference(result['commands']) | length == 0 }}" + - result.changed == true + + - name: Assert that ESP-TEST was correctly reconciled + assert: + that: + - ansible_facts['network_resources']['vpn_ipsec']['esp_group'] == replaced.after.esp_group + + - name: Assert that un-named sections were left completely untouched + vars: + redacted: VALUE_SPECIFIED_IN_NO_LOG_PARAMETER + assert: + that: + - (ansible_facts['network_resources']['vpn_ipsec']['ike_group'] | sort(attribute='name')) == (populate_config.ike_group | sort(attribute='name')) + - ansible_facts['network_resources']['vpn_ipsec']['profile'][0]['name'] == populate_config.profile[0].name + - ansible_facts['network_resources']['vpn_ipsec']['profile'][0]['authentication']['mode'] == populate_config.profile[0].authentication.mode + # pre_shared_secret/secret carry no_log: True in the argspec -- + # gathered facts always redact them to this literal placeholder, + # never the real value, so that's what must be asserted here. + - ansible_facts['network_resources']['vpn_ipsec']['profile'][0]['authentication']['pre_shared_secret'] == redacted + - ansible_facts['network_resources']['vpn_ipsec']['profile'][0]['bind_tunnel'] == populate_config.profile[0].bind_tunnel + - ansible_facts['network_resources']['vpn_ipsec']['profile'][0]['esp_group'] == populate_config.profile[0].esp_group + - ansible_facts['network_resources']['vpn_ipsec']['profile'][0]['ike_group'] == populate_config.profile[0].ike_group + - ansible_facts['network_resources']['vpn_ipsec']['authentication']['psk'][0]['name'] == populate_config.authentication.psk[0].name + - ansible_facts['network_resources']['vpn_ipsec']['authentication']['psk'][0]['secret'] == redacted + - (ansible_facts['network_resources']['vpn_ipsec']['authentication']['psk'][0]['id'] | sort) == (populate_config.authentication.psk[0].id | sort) + - ansible_facts['network_resources']['vpn_ipsec']['authentication']['ppk'][0]['name'] == populate_config.authentication.ppk[0].name + - ansible_facts['network_resources']['vpn_ipsec']['authentication']['ppk'][0]['secret'] == redacted + - ansible_facts['network_resources']['vpn_ipsec']['authentication']['ppk'][0]['id'] == populate_config.authentication.ppk[0].id + + - name: Replace with the same configuration (IDEMPOTENT) + register: result + vyos.vyos.vyos_vpn_ipsec: *id001 + + - name: Assert that the previous task was idempotent + assert: + that: + - result['changed'] == false + - result['commands'] == [] + + always: + - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_vpn_ipsec/vars/main.yaml b/tests/integration/targets/vyos_vpn_ipsec/vars/main.yaml new file mode 100644 index 00000000..b86a8d72 --- /dev/null +++ b/tests/integration/targets/vyos_vpn_ipsec/vars/main.yaml @@ -0,0 +1,231 @@ +--- +merged: + config: + esp_group: + - name: ESP-TEST + compression: true + proposal: + - proposal_id: 1 + encryption: aes256 + hash: sha256 + ike_group: + - name: IKE-TEST + key_exchange: ikev2 + disable_mobike: true + dead_peer_detection: + action: restart + interval: 15 + timeout: 60 + proposal: + - proposal_id: 1 + encryption: aes256 + hash: sha256 + dh_group: 14 + commands: + - set vpn ipsec esp-group ESP-TEST + - set vpn ipsec esp-group ESP-TEST compression + - set vpn ipsec esp-group ESP-TEST proposal 1 + - set vpn ipsec esp-group ESP-TEST proposal 1 encryption aes256 + - set vpn ipsec esp-group ESP-TEST proposal 1 hash sha256 + - set vpn ipsec ike-group IKE-TEST + - set vpn ipsec ike-group IKE-TEST key-exchange ikev2 + - set vpn ipsec ike-group IKE-TEST disable-mobike + - set vpn ipsec ike-group IKE-TEST dead-peer-detection action restart + - set vpn ipsec ike-group IKE-TEST dead-peer-detection interval 15 + - set vpn ipsec ike-group IKE-TEST dead-peer-detection timeout 60 + - set vpn ipsec ike-group IKE-TEST proposal 1 + - set vpn ipsec ike-group IKE-TEST proposal 1 encryption aes256 + - set vpn ipsec ike-group IKE-TEST proposal 1 hash sha256 + - set vpn ipsec ike-group IKE-TEST proposal 1 dh-group 14 + after: + esp_group: + - name: ESP-TEST + compression: true + proposal: + - proposal_id: 1 + encryption: aes256 + hash: sha256 + ike_group: + - name: IKE-TEST + key_exchange: ikev2 + disable_mobike: true + dead_peer_detection: + action: restart + interval: 15 + timeout: 60 + proposal: + - proposal_id: 1 + encryption: aes256 + hash: sha256 + dh_group: 14 + +# Full pre-populated state used by replaced/overridden/deleted/gathered -- +# matches the same shape as the unit test fixture, so expected +# commands/after values here are grounded in what's already unit-tested, +# not invented fresh. +populate_config: + esp_group: + - name: ESP-TEST + compression: true + proposal: + - proposal_id: 1 + encryption: aes256 + hash: sha256 + ike_group: + - name: IKE-TEST + key_exchange: ikev2 + disable_mobike: true + dead_peer_detection: + action: restart + interval: 15 + timeout: 60 + proposal: + - proposal_id: 1 + encryption: aes256 + hash: sha256 + dh_group: 14 + - name: IKE-EXTRA + key_exchange: ikev1 + authentication: + psk: + - name: PSK-TEST + id: + - local@example.com + - remote@example.com + secret: test-not-real-secret + ppk: + - name: PPK-TEST + id: + - ppk-id-1 + secret: test-ppk-secret + profile: + - name: testprofile + authentication: + mode: pre-shared-secret + pre_shared_secret: test-not-real-secret + bind_tunnel: + - tun0 + esp_group: ESP-TEST + ike_group: IKE-TEST + disable_uniqreqids: true + interface: + - eth0 + - eth1 + log: + level: 1 + subsystem: + - ike + - chd + options: + flexvpn: true + retransmission: + attempts: 3 + +replaced: + config: + esp_group: + - name: ESP-TEST + proposal: + - proposal_id: 1 + encryption: aes128 + hash: sha256 + commands: + - delete vpn ipsec esp-group ESP-TEST compression + - set vpn ipsec esp-group ESP-TEST proposal 1 encryption aes128 + - delete vpn ipsec disable-uniqreqids + - delete vpn ipsec interface eth0 + - delete vpn ipsec interface eth1 + - delete vpn ipsec log level 1 + - delete vpn ipsec log subsystem chd + - delete vpn ipsec log subsystem ike + - delete vpn ipsec options flexvpn + - delete vpn ipsec options retransmission attempts 3 + # NOTE: ike_group/profile/authentication are NOT in `after` deletions -- + # they're left untouched since replaced only reconciles items it names. + # `after` below reflects only what replaced's scope actually changes; + # the un-named sections (ike_group, profile, authentication) remain + # exactly as populate_config left them and are checked separately in + # replaced.yaml via ansible_facts rather than duplicated here. + after: + esp_group: + - name: ESP-TEST + proposal: + - proposal_id: 1 + encryption: aes128 + hash: sha256 + +overridden: + config: + esp_group: + - name: ESP-TEST + proposal: + - proposal_id: 1 + encryption: aes128 + hash: sha256 + commands: + - delete vpn ipsec esp-group ESP-TEST compression + - set vpn ipsec esp-group ESP-TEST proposal 1 encryption aes128 + - delete vpn ipsec ike-group IKE-TEST + - delete vpn ipsec ike-group IKE-EXTRA + - delete vpn ipsec profile testprofile + - delete vpn ipsec authentication psk PSK-TEST + - delete vpn ipsec authentication ppk PPK-TEST + - delete vpn ipsec disable-uniqreqids + - delete vpn ipsec interface eth0 + - delete vpn ipsec interface eth1 + - delete vpn ipsec log level 1 + - delete vpn ipsec log subsystem chd + - delete vpn ipsec log subsystem ike + - delete vpn ipsec options flexvpn + - delete vpn ipsec options retransmission attempts 3 + after: + esp_group: + - name: ESP-TEST + proposal: + - proposal_id: 1 + encryption: aes128 + hash: sha256 + +deleted: + commands: + - delete vpn ipsec esp-group ESP-TEST + - delete vpn ipsec ike-group IKE-TEST + - delete vpn ipsec ike-group IKE-EXTRA + - delete vpn ipsec profile testprofile + - delete vpn ipsec authentication psk PSK-TEST + - delete vpn ipsec authentication ppk PPK-TEST + - delete vpn ipsec disable-uniqreqids + - delete vpn ipsec interface eth0 + - delete vpn ipsec interface eth1 + - delete vpn ipsec log level 1 + - delete vpn ipsec log subsystem chd + - delete vpn ipsec log subsystem ike + - delete vpn ipsec options flexvpn + - delete vpn ipsec options retransmission attempts 3 + after: {} + +rendered: + config: + esp_group: + - name: ESP-RENDER-TEST + proposal: + - proposal_id: 1 + encryption: aes256 + hash: sha256 + commands: + - set vpn ipsec esp-group ESP-RENDER-TEST + - set vpn ipsec esp-group ESP-RENDER-TEST proposal 1 + - set vpn ipsec esp-group ESP-RENDER-TEST proposal 1 encryption aes256 + - set vpn ipsec esp-group ESP-RENDER-TEST proposal 1 hash sha256 + +parsed: + after: + esp_group: + - name: ESP-PARSE-TEST + proposal: + - proposal_id: 1 + encryption: aes256 + hash: sha256 + ike_group: + - name: IKE-PARSE-TEST + key_exchange: ikev2 diff --git a/tests/unit/modules/network/vyos/fixtures/vyos_vpn_ipsec_config.cfg b/tests/unit/modules/network/vyos/fixtures/vyos_vpn_ipsec_config.cfg new file mode 100644 index 00000000..d0d0e19e --- /dev/null +++ b/tests/unit/modules/network/vyos/fixtures/vyos_vpn_ipsec_config.cfg @@ -0,0 +1,30 @@ +set vpn ipsec authentication ppk PPK-TEST id 'ppk-id-1' +set vpn ipsec authentication ppk PPK-TEST secret 'test-ppk-secret' +set vpn ipsec authentication psk PSK-TEST id 'local@example.com' +set vpn ipsec authentication psk PSK-TEST id 'remote@example.com' +set vpn ipsec authentication psk PSK-TEST secret 'test-not-real-secret' +set vpn ipsec disable-uniqreqids +set vpn ipsec esp-group ESP-TEST compression +set vpn ipsec esp-group ESP-TEST proposal 1 encryption 'aes256' +set vpn ipsec esp-group ESP-TEST proposal 1 hash 'sha256' +set vpn ipsec ike-group IKE-TEST key-exchange 'ikev2' +set vpn ipsec ike-group IKE-TEST dead-peer-detection action 'restart' +set vpn ipsec ike-group IKE-TEST dead-peer-detection interval '15' +set vpn ipsec ike-group IKE-TEST dead-peer-detection timeout '60' +set vpn ipsec ike-group IKE-TEST disable-mobike +set vpn ipsec ike-group IKE-TEST proposal 1 dh-group '14' +set vpn ipsec ike-group IKE-TEST proposal 1 encryption 'aes256' +set vpn ipsec ike-group IKE-TEST proposal 1 hash 'sha256' +set vpn ipsec ike-group IKE-EXTRA key-exchange 'ikev1' +set vpn ipsec interface 'eth0' +set vpn ipsec interface 'eth1' +set vpn ipsec log level '1' +set vpn ipsec log subsystem 'ike' +set vpn ipsec log subsystem 'chd' +set vpn ipsec options flexvpn +set vpn ipsec options retransmission attempts '3' +set vpn ipsec profile testprofile authentication mode 'pre-shared-secret' +set vpn ipsec profile testprofile authentication pre-shared-secret 'test-not-real-secret' +set vpn ipsec profile testprofile bind tunnel 'tun0' +set vpn ipsec profile testprofile esp-group 'ESP-TEST' +set vpn ipsec profile testprofile ike-group 'IKE-TEST' diff --git a/tests/unit/modules/network/vyos/test_vyos_vpn_ipsec.py b/tests/unit/modules/network/vyos/test_vyos_vpn_ipsec.py new file mode 100644 index 00000000..6c616ad1 --- /dev/null +++ b/tests/unit/modules/network/vyos/test_vyos_vpn_ipsec.py @@ -0,0 +1,568 @@ +# +# (c) 2026, Red Hat, inc +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +# + +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +from textwrap import dedent +from unittest.mock import patch + +from ansible_collections.vyos.vyos.plugins.modules import vyos_vpn_ipsec +from ansible_collections.vyos.vyos.tests.unit.modules.utils import set_module_args + +from .vyos_module import TestVyosModule, load_fixture + + +class TestVyosVpnIpsecModule(TestVyosModule): + module = vyos_vpn_ipsec + + def setUp(self): + super(TestVyosVpnIpsecModule, self).setUp() + + self.mock_get_resource_connection_config = patch( + "ansible_collections.ansible.netcommon.plugins.module_utils.network.common.rm_base.resource_module_base.get_resource_connection", + ) + self.get_resource_connection_config = self.mock_get_resource_connection_config.start() + + self.mock_get_resource_connection_facts = patch( + "ansible_collections.ansible.netcommon.plugins.module_utils.network.common.facts.facts.get_resource_connection", + ) + self.get_resource_connection_facts = self.mock_get_resource_connection_facts.start() + + self.mock_execute_show_command = patch( + "ansible_collections.vyos.vyos.plugins.module_utils.network.vyos.facts.vpn_ipsec.vpn_ipsec.Vpn_ipsecFacts.get_vpn_ipsec_data", + ) + self.execute_show_command = self.mock_execute_show_command.start() + + def tearDown(self): + super(TestVyosVpnIpsecModule, self).tearDown() + self.mock_get_resource_connection_config.stop() + self.mock_get_resource_connection_facts.stop() + self.mock_execute_show_command.stop() + + # Single fixture file, reused across every test. Carries: two + # ike_group entries (IKE-TEST richly configured incl. + # dead_peer_detection/disable_mobike, IKE-EXTRA minimal, to prove + # "unlisted items preserved"); esp_group with compression+proposal; + # psk/ppk with multi-value id lists; a profile; and every top-level + # singleton field (disable_uniqreqids, interface, log, options). + def load_fixtures(self, commands=None, filename=None): + if filename == "EMPTY": + self.execute_show_command.side_effect = None + self.execute_show_command.return_value = "" + return + + if filename is None: + filename = "vyos_vpn_ipsec_config.cfg" + + def load_from_file(*args, **kwargs): + return load_fixture(filename) + + self.execute_show_command.side_effect = load_from_file + + # ------------------------------------------------------------------- + # merged + # ------------------------------------------------------------------- + + def test_vyos_vpn_ipsec_merged_idempotent(self): + playbook = dict( + config=dict( + esp_group=[ + dict( + name="ESP-TEST", + compression=True, + proposal=[dict(proposal_id=1, encryption="aes256", hash="sha256")], + ), + ], + ike_group=[ + dict( + name="IKE-TEST", + key_exchange="ikev2", + disable_mobike=True, + dead_peer_detection=dict(action="restart", interval=15, timeout=60), + proposal=[ + dict(proposal_id=1, encryption="aes256", hash="sha256", dh_group=14), + ], + ), + ], + ), + state="merged", + ) + set_module_args(playbook) + result = self.execute_module(changed=False) + self.maxDiff = None + self.assertEqual(sorted(result["commands"]), []) + + def test_vyos_vpn_ipsec_merged_new_group_leaves_existing_untouched(self): + playbook = dict( + config=dict( + esp_group=[ + dict( + name="ESP-NEW", + proposal=[dict(proposal_id=1, encryption="aes256", hash="sha256")], + ), + ], + ), + state="merged", + ) + compare_cmds = [ + "set vpn ipsec esp-group ESP-NEW", + "set vpn ipsec esp-group ESP-NEW proposal 1", + "set vpn ipsec esp-group ESP-NEW proposal 1 encryption aes256", + "set vpn ipsec esp-group ESP-NEW proposal 1 hash sha256", + ] + set_module_args(playbook) + result = self.execute_module(changed=True) + self.maxDiff = None + self.assertEqual(sorted(result["commands"]), sorted(compare_cmds)) + + def test_vyos_vpn_ipsec_merged_bool_field_toggle(self): + # disable_mobike explicitly set to True in want when have already + # has it True -> no-op; here we flip a different bool + # (compression on ESP-TEST is already True in have) by instead + # adding a brand new bool-bearing field: options.virtual_ip, + # entirely absent from have. + playbook = dict(config=dict(options=dict(virtual_ip=True)), state="merged") + compare_cmds = ["set vpn ipsec options virtual-ip"] + set_module_args(playbook) + result = self.execute_module(changed=True) + self.maxDiff = None + self.assertEqual(sorted(result["commands"]), sorted(compare_cmds)) + + def test_vyos_vpn_ipsec_merged_add_psk_id(self): + # multi-value list field: add one id, existing ids must survive + playbook = dict( + config=dict( + authentication=dict( + psk=[dict(name="PSK-TEST", id=["third@example.com"])], + ), + ), + state="merged", + ) + compare_cmds = ["set vpn ipsec authentication psk PSK-TEST id third@example.com"] + set_module_args(playbook) + result = self.execute_module(changed=True) + self.maxDiff = None + self.assertEqual(sorted(result["commands"]), sorted(compare_cmds)) + + def test_vyos_vpn_ipsec_merged_create_ppk(self): + playbook = dict( + config=dict( + authentication=dict( + ppk=[dict(name="PPK-NEW", id=["new-ppk-id"], secret="new-secret")], + ), + ), + state="merged", + ) + compare_cmds = [ + "set vpn ipsec authentication ppk PPK-NEW", + "set vpn ipsec authentication ppk PPK-NEW id new-ppk-id", + "set vpn ipsec authentication ppk PPK-NEW secret 'new-secret'", + ] + set_module_args(playbook) + result = self.execute_module(changed=True) + self.maxDiff = None + self.assertEqual(sorted(result["commands"]), sorted(compare_cmds)) + + def test_vyos_vpn_ipsec_merged_profile_add_bind_tunnel(self): + # multi-value list field on an EXISTING profile: add one tunnel, + # existing tun0 must survive + playbook = dict( + config=dict( + profile=[dict(name="testprofile", bind_tunnel=["tun1"])], + ), + state="merged", + ) + compare_cmds = ["set vpn ipsec profile testprofile bind tunnel tun1"] + set_module_args(playbook) + result = self.execute_module(changed=True) + self.maxDiff = None + self.assertEqual(sorted(result["commands"]), sorted(compare_cmds)) + + def test_vyos_vpn_ipsec_merged_create_profile_from_scratch(self): + # Regression test: creating a brand new profile must correctly + # emit its authentication.mode/pre_shared_secret commands. This + # specific path was silently broken (a leftover dotted-Jinja + # setval caused render() to return nothing, so addcmd() quietly + # dropped both commands with no error) -- caught only by a real + # device integration test, since no existing unit test actually + # created a profile from scratch or touched its authentication + # sub-dict through generate_commands(). + playbook = dict( + config=dict( + profile=[ + dict( + name="NEWPROFILE", + authentication=dict( + mode="pre-shared-secret", + pre_shared_secret="brand-new-secret", + ), + esp_group="ESP-TEST", + ike_group="IKE-TEST", + ), + ], + ), + state="merged", + ) + compare_cmds = [ + "set vpn ipsec profile NEWPROFILE", + "set vpn ipsec profile NEWPROFILE authentication mode pre-shared-secret", + "set vpn ipsec profile NEWPROFILE authentication pre-shared-secret 'brand-new-secret'", + "set vpn ipsec profile NEWPROFILE esp-group ESP-TEST", + "set vpn ipsec profile NEWPROFILE ike-group IKE-TEST", + ] + set_module_args(playbook) + result = self.execute_module(changed=True) + self.maxDiff = None + self.assertEqual(sorted(result["commands"]), sorted(compare_cmds)) + + # ------------------------------------------------------------------- + # replaced + # ------------------------------------------------------------------- + + def test_vyos_vpn_ipsec_replaced_only_named_item_touched(self): + playbook = dict( + config=dict( + esp_group=[ + dict( + name="ESP-TEST", + proposal=[dict(proposal_id=1, encryption="aes128", hash="sha256")], + ), + ], + ), + state="replaced", + ) + compare_cmds = [ + # compression omitted from want -> reconciled away (attribute + # inside the selected instance is always reset under replaced) + "delete vpn ipsec esp-group ESP-TEST compression", + "set vpn ipsec esp-group ESP-TEST proposal 1 encryption aes128", + # ike_group/profile/psk/ppk not named at all -> left alone. + # Top-level singleton fields (no instance scope) are always + # reconciled under replaced/overridden regardless of what's + # named elsewhere. + "delete vpn ipsec disable-uniqreqids", + "delete vpn ipsec interface eth0", + "delete vpn ipsec interface eth1", + "delete vpn ipsec log level 1", + "delete vpn ipsec log subsystem chd", + "delete vpn ipsec log subsystem ike", + "delete vpn ipsec options flexvpn", + "delete vpn ipsec options retransmission attempts 3", + ] + set_module_args(playbook) + result = self.execute_module(changed=True) + self.maxDiff = None + self.assertEqual(sorted(result["commands"]), sorted(compare_cmds)) + + def test_vyos_vpn_ipsec_replaced_idempotent(self): + # want fully mirrors the ENTIRE fixture state for every top-level + # singleton field plus the one named esp_group -- proves + # replaced's per-item/per-field reconciliation doesn't spuriously + # touch a fully-matching configuration. + playbook = dict( + config=dict( + esp_group=[ + dict( + name="ESP-TEST", + compression=True, + proposal=[dict(proposal_id=1, encryption="aes256", hash="sha256")], + ), + ], + disable_uniqreqids=True, + interface=["eth0", "eth1"], + log=dict(level=1, subsystem=["chd", "ike"]), + options=dict(flexvpn=True, retransmission=dict(attempts=3)), + ), + state="replaced", + ) + set_module_args(playbook) + result = self.execute_module(changed=False) + self.maxDiff = None + self.assertEqual(sorted(result["commands"]), []) + + def test_vyos_vpn_ipsec_replaced_scoped_dead_peer_detection(self): + # naming ike_group under replaced without dead_peer_detection -> + # every DPD field reconciled away (nested-dict attribute reset) + playbook = dict( + config=dict( + ike_group=[dict(name="IKE-TEST", key_exchange="ikev2")], + ), + state="replaced", + ) + compare_cmds = [ + "delete vpn ipsec ike-group IKE-TEST dead-peer-detection action restart", + "delete vpn ipsec ike-group IKE-TEST dead-peer-detection interval 15", + "delete vpn ipsec ike-group IKE-TEST dead-peer-detection timeout 60", + "delete vpn ipsec ike-group IKE-TEST disable-mobike", + "delete vpn ipsec ike-group IKE-TEST proposal 1", + "delete vpn ipsec disable-uniqreqids", + "delete vpn ipsec interface eth0", + "delete vpn ipsec interface eth1", + "delete vpn ipsec log level 1", + "delete vpn ipsec log subsystem chd", + "delete vpn ipsec log subsystem ike", + "delete vpn ipsec options flexvpn", + "delete vpn ipsec options retransmission attempts 3", + ] + set_module_args(playbook) + result = self.execute_module(changed=True) + self.maxDiff = None + self.assertEqual(sorted(result["commands"]), sorted(compare_cmds)) + + # ------------------------------------------------------------------- + # overridden + # ------------------------------------------------------------------- + + def test_vyos_vpn_ipsec_overridden_removes_unlisted_instances(self): + playbook = dict( + config=dict( + esp_group=[ + dict( + name="ESP-TEST", + proposal=[dict(proposal_id=1, encryption="aes128", hash="sha256")], + ), + ], + ), + state="overridden", + ) + compare_cmds = [ + "delete vpn ipsec esp-group ESP-TEST compression", + "set vpn ipsec esp-group ESP-TEST proposal 1 encryption aes128", + "delete vpn ipsec ike-group IKE-TEST", + "delete vpn ipsec ike-group IKE-EXTRA", + "delete vpn ipsec profile testprofile", + "delete vpn ipsec authentication psk PSK-TEST", + "delete vpn ipsec authentication ppk PPK-TEST", + "delete vpn ipsec disable-uniqreqids", + "delete vpn ipsec interface eth0", + "delete vpn ipsec interface eth1", + "delete vpn ipsec log level 1", + "delete vpn ipsec log subsystem chd", + "delete vpn ipsec log subsystem ike", + "delete vpn ipsec options flexvpn", + "delete vpn ipsec options retransmission attempts 3", + ] + set_module_args(playbook) + result = self.execute_module(changed=True) + self.maxDiff = None + self.assertEqual(sorted(result["commands"]), sorted(compare_cmds)) + + # ------------------------------------------------------------------- + # deleted -- bare (delete everything) + # ------------------------------------------------------------------- + + def test_vyos_vpn_ipsec_deleted_bare(self): + playbook = dict(state="deleted") + compare_cmds = [ + "delete vpn ipsec esp-group ESP-TEST", + "delete vpn ipsec ike-group IKE-TEST", + "delete vpn ipsec ike-group IKE-EXTRA", + "delete vpn ipsec profile testprofile", + "delete vpn ipsec authentication psk PSK-TEST", + "delete vpn ipsec authentication ppk PPK-TEST", + "delete vpn ipsec disable-uniqreqids", + "delete vpn ipsec interface eth0", + "delete vpn ipsec interface eth1", + "delete vpn ipsec log level 1", + "delete vpn ipsec log subsystem chd", + "delete vpn ipsec log subsystem ike", + "delete vpn ipsec options flexvpn", + "delete vpn ipsec options retransmission attempts 3", + ] + set_module_args(playbook) + result = self.execute_module(changed=True) + self.maxDiff = None + self.assertEqual(sorted(result["commands"]), sorted(compare_cmds)) + + def test_vyos_vpn_ipsec_deleted_bare_idempotent(self): + playbook = dict(state="deleted") + set_module_args(playbook) + result = self.execute_module(changed=False, filename="EMPTY") + self.maxDiff = None + self.assertEqual(sorted(result["commands"]), []) + + # ------------------------------------------------------------------- + # deleted -- scoped (delete only what's named) + # ------------------------------------------------------------------- + + def test_vyos_vpn_ipsec_deleted_scoped_named_item_only(self): + playbook = dict(config=dict(ike_group=[dict(name="IKE-EXTRA")]), state="deleted") + compare_cmds = ["delete vpn ipsec ike-group IKE-EXTRA"] + set_module_args(playbook) + result = self.execute_module(changed=True) + self.maxDiff = None + self.assertEqual(sorted(result["commands"]), sorted(compare_cmds)) + + def test_vyos_vpn_ipsec_deleted_scoped_nonexistent_name_idempotent(self): + playbook = dict(config=dict(ike_group=[dict(name="IKE-NONEXISTENT")]), state="deleted") + set_module_args(playbook) + result = self.execute_module(changed=False) + self.maxDiff = None + self.assertEqual(sorted(result["commands"]), []) + + def test_vyos_vpn_ipsec_deleted_scoped_interface_element(self): + # element-level list deletion: name only eth0, eth1 must survive + playbook = dict(config=dict(interface=["eth0"]), state="deleted") + compare_cmds = ["delete vpn ipsec interface eth0"] + set_module_args(playbook) + result = self.execute_module(changed=True) + self.maxDiff = None + self.assertEqual(sorted(result["commands"]), sorted(compare_cmds)) + + def test_vyos_vpn_ipsec_deleted_scoped_log_subsystem_element(self): + playbook = dict(config=dict(log=dict(subsystem=["chd"])), state="deleted") + compare_cmds = ["delete vpn ipsec log subsystem chd"] + set_module_args(playbook) + result = self.execute_module(changed=True) + self.maxDiff = None + self.assertEqual(sorted(result["commands"]), sorted(compare_cmds)) + + def test_vyos_vpn_ipsec_deleted_scoped_options_field(self): + playbook = dict(config=dict(options=dict(flexvpn=True)), state="deleted") + compare_cmds = ["delete vpn ipsec options flexvpn"] + set_module_args(playbook) + result = self.execute_module(changed=True) + self.maxDiff = None + self.assertEqual(sorted(result["commands"]), sorted(compare_cmds)) + + # ------------------------------------------------------------------- + # rendered (no device contact -- have is empty regardless of fixture) + # ------------------------------------------------------------------- + + def test_vyos_vpn_ipsec_rendered(self): + playbook = dict( + config=dict( + esp_group=[ + dict( + name="ESP-RENDER-TEST", + proposal=[dict(proposal_id=1, encryption="aes256", hash="sha256")], + ), + ], + ), + state="rendered", + ) + compare_cmds = [ + "set vpn ipsec esp-group ESP-RENDER-TEST", + "set vpn ipsec esp-group ESP-RENDER-TEST proposal 1", + "set vpn ipsec esp-group ESP-RENDER-TEST proposal 1 encryption aes256", + "set vpn ipsec esp-group ESP-RENDER-TEST proposal 1 hash sha256", + ] + set_module_args(playbook) + result = self.execute_module() + self.maxDiff = None + self.assertEqual(sorted(result["rendered"]), sorted(compare_cmds)) + + # ------------------------------------------------------------------- + # parsed (reads running_config directly, not the mocked show command) + # ------------------------------------------------------------------- + + def test_vyos_vpn_ipsec_parsed(self): + set_module_args( + dict( + running_config=dedent( + """\ + set vpn ipsec esp-group ESP-PARSE-TEST proposal 1 encryption aes256 + set vpn ipsec esp-group ESP-PARSE-TEST proposal 1 hash sha256 + set vpn ipsec ike-group IKE-PARSE-TEST key-exchange ikev2 + """, + ), + state="parsed", + ), + ) + parsed = dict( + esp_group=[ + dict( + name="ESP-PARSE-TEST", + proposal=[dict(proposal_id=1, encryption="aes256", hash="sha256")], + ), + ], + ike_group=[dict(name="IKE-PARSE-TEST", key_exchange="ikev2")], + ) + result = self.execute_module(changed=False) + self.maxDiff = None + self.assertEqual(result["parsed"], parsed) + + # ------------------------------------------------------------------- + # gathered + # ------------------------------------------------------------------- + + def _normalize_scalar_lists(self, node): + """Multi-value scalar-list fields (id, subsystem, interface, ...) + have no meaningful order -- dict_merge's internal list handling + doesn't guarantee a stable sequence between runs. Sort them + in-place (recursively) before comparing so tests aren't + sensitive to that non-determinism, while list-of-dict + collections (already sorted by name in process_facts) are left + untouched. + """ + if isinstance(node, dict): + for k, v in node.items(): + if isinstance(v, list) and v and all(not isinstance(i, dict) for i in v): + node[k] = sorted(v) + else: + self._normalize_scalar_lists(v) + elif isinstance(node, list): + for item in node: + self._normalize_scalar_lists(item) + return node + + def test_vyos_vpn_ipsec_gathered(self): + set_module_args(dict(state="gathered")) + gathered = dict( + authentication=dict( + ppk=[dict(name="PPK-TEST", id=["ppk-id-1"], secret="test-ppk-secret")], + psk=[ + dict( + name="PSK-TEST", + id=["local@example.com", "remote@example.com"], + secret="test-not-real-secret", + ), + ], + ), + disable_uniqreqids=True, + esp_group=[ + dict( + name="ESP-TEST", + compression=True, + proposal=[dict(proposal_id=1, encryption="aes256", hash="sha256")], + ), + ], + ike_group=[ + dict(name="IKE-EXTRA", key_exchange="ikev1"), + dict( + name="IKE-TEST", + key_exchange="ikev2", + disable_mobike=True, + dead_peer_detection=dict(action="restart", interval=15, timeout=60), + proposal=[ + dict(proposal_id=1, encryption="aes256", hash="sha256", dh_group=14), + ], + ), + ], + interface=["eth0", "eth1"], + log=dict(level=1, subsystem=["chd", "ike"]), + options=dict(flexvpn=True, retransmission=dict(attempts=3)), + profile=[ + dict( + name="testprofile", + authentication=dict( + mode="pre-shared-secret", + pre_shared_secret="test-not-real-secret", + ), + bind_tunnel=["tun0"], + esp_group="ESP-TEST", + ike_group="IKE-TEST", + ), + ], + ) + result = self.execute_module(changed=False) + self.maxDiff = None + self.assertEqual( + self._normalize_scalar_lists(result["gathered"]), + self._normalize_scalar_lists(gathered), + )