diff --git a/meta/runtime.yml b/meta/runtime.yml index 23f05cbd..a851eb64 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -35,3 +35,5 @@ action_groups: - proxmox_user - proxmox_user_info - proxmox_vm_info + - proxmox_zone + - proxmox_zone_info diff --git a/plugins/module_utils/proxmox_sdn.py b/plugins/module_utils/proxmox_sdn.py new file mode 100644 index 00000000..3ed65854 --- /dev/null +++ b/plugins/module_utils/proxmox_sdn.py @@ -0,0 +1,101 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2025, Jana Hoch +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +from typing import List, Dict + +from ansible_collections.community.proxmox.plugins.module_utils.proxmox import ( + ansible_to_proxmox_bool, + ProxmoxAnsible +) + + +class ProxmoxSdnAnsible(ProxmoxAnsible): + """Base Class for All Proxmox SDN Classes""" + + def __init__(self, module): + super(ProxmoxSdnAnsible, self).__init__(module) + self.module = module + + def get_global_sdn_lock(self) -> str: + """Acquire global SDN lock. Needed for any changes under SDN. + + :return: lock-token + """ + try: + return self.proxmox_api.cluster().sdn().lock().post() + except Exception as e: + self.module.fail_json( + msg=f'Failed to acquire global sdn lock {e}' + ) + + def apply_sdn_changes_and_release_lock(self, lock: str, release_lock: bool = True) -> None: + """Apply all SDN changes done under a lock token. + + :param lock: Global SDN lock token + :param release_lock: if True release lock after successfully applying changes + """ + lock_params = { + 'lock-token': lock, + 'release-lock': ansible_to_proxmox_bool(release_lock) + } + try: + self.proxmox_api.cluster().sdn().put(**lock_params) + except Exception as e: + self.rollback_sdn_changes_and_release_lock(lock) + self.module.fail_json( + msg=f'Failed to apply sdn changes {e}. Rolling back all pending changes.' + ) + + def rollback_sdn_changes_and_release_lock(self, lock: str, release_lock: bool = True) -> None: + """Rollback all changes done under a lock token. + + :param lock: Global SDN lock token + :param release_lock: if True release lock after successfully rolling back changes + """ + lock_params = { + 'lock-token': lock, + 'release-lock': ansible_to_proxmox_bool(release_lock) + } + try: + self.proxmox_api.cluster().sdn().rollback().post(**lock_params) + except Exception as e: + self.module.fail_json( + msg=f'Rollback attempt failed - {e}. Manually clear lock by deleting /etc/pve/sdn/.lock' + ) + + def release_lock(self, lock: str, force: bool = False) -> None: + """Release Global SDN lock + + :param lock: Global SDN lock token + :param force: if true, allow releasing lock without providing the token + """ + lock_params = { + 'lock-token': lock, + 'force': ansible_to_proxmox_bool(force) + } + try: + self.proxmox_api.cluster().sdn().lock().delete(**lock_params) + except Exception as e: + self.module.fail_json( + msg=f'Failed to release lock - {e}. Manually clear lock by deleting /etc/pve/sdn/.lock' + ) + + def get_zones(self, zone_type: str = None) -> List[Dict]: + """Get Proxmox SDN zones + + :param zone_type: Filter zones based on type. + :return: list of all zones and their properties. + """ + try: + return self.proxmox_api.cluster().sdn().zones().get(type=zone_type) + except Exception as e: + self.module.fail_json( + msg=f'Failed to retrieve zone information from cluster: {e}' + ) diff --git a/plugins/modules/proxmox_zone.py b/plugins/modules/proxmox_zone.py new file mode 100644 index 00000000..b0f88df8 --- /dev/null +++ b/plugins/modules/proxmox_zone.py @@ -0,0 +1,418 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2025, Jana Hoch +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = r""" +module: proxmox_zone +short_description: Manage Proxmox zone configurations. +description: + - Create/Update/Delete proxmox sdn zones. +author: 'Jana Hoch (!UNKNOWN)' +attributes: + check_mode: + support: none + diff_mode: + support: none +options: + state: + description: + - The desired state of the zone configuration. + type: str + choices: + - present + - absent + default: present + update: + description: + - If O(state=present) and zone exists it'll update. + type: bool + default: true + type: + description: + - Specify the type of zone. + type: str + choices: + - evpn + - faucet + - qinq + - simple + - vlan + - vxlan + zone: + description: + - Unique zone name. + type: str + advertise_subnets: + description: + - Advertise evpn subnets if you have silent hosts. + type: bool + bridge: + description: + - Specify the bridge interface to use. + type: str + bridge_disable_mac_learning: + description: + - Disable auto MAC address learning on the bridge interface. + type: bool + controller: + description: + - Frr router name. + type: str + dhcp: + description: + - Type of the DHCP backend for this zone. + type: str + choices: + - dnsmasq + disable_arp_nd_suppression: + description: + - Disable ipv4 arp && ipv6 neighbour discovery suppression. + type: bool + dns: + description: + - DNS api server. + type: str + dnszone: + description: + - DNS domain zone. + type: str + dp_id: + description: + - Faucet dataplane id. + type: int + exitnodes: + description: + - List of cluster node names. + type: str + exitnodes_local_routing: + description: + - Allow exitnodes to connect to evpn guests. + type: bool + exitnodes_primary: + description: + - Force traffic to this exitnode first. + type: str + fabric: + description: + - SDN fabric to use as underlay for this VXLAN zone. + type: str + ipam: + description: + - Use a specific ipam. + type: str + mac: + description: + - Anycast logical router mac address. + type: str + mtu: + description: + - Set the Maximum Transmission Unit (MTU). + type: int + nodes: + description: + - List of cluster node names. + type: str + peers: + description: + - peers address list. + type: str + reversedns: + description: + - reverse dns api server + type: str + rt_import: + description: + - Route-Target import. + type: str + tag: + description: + - Service-VLAN Tag. + type: int + vlan_protocol: + description: + - Specify the VLAN protocol to use. + type: str + choices: + - 802.1q + - 802.1ad + vrf_vxlan: + description: + - Specify the VRF VXLAN identifier. + type: int + vxlan_port: + description: + - Vxlan tunnel udp port (default 4789). + type: int +extends_documentation_fragment: + - community.proxmox.proxmox.actiongroup_proxmox + - community.proxmox.proxmox.documentation + - community.proxmox.attributes +""" + +EXAMPLES = r""" +- name: create a simple zones + community.proxmox.proxmox_zone: + api_user: "root@pam" + api_password: "{{ vault.proxmox.root_password }}" + api_host: "{{ pc.proxmox.api_host }}" + validate_certs: no + type: simple + zone: ansible + state: present + +- name: create a vlan zones + community.proxmox.proxmox_zone: + api_user: "root@pam" + api_password: "{{ vault.proxmox.root_password }}" + api_host: "{{ pc.proxmox.api_host }}" + validate_certs: no + type: vlan + zone: ansible + state: present + bridge: vmbr0 + +- name: Delete a zones + community.proxmox.proxmox_zone: + api_user: "root@pam" + api_password: "{{ vault.proxmox.root_password }}" + api_host: "{{ pc.proxmox.api_host }}" + validate_certs: no + type: simple + zone: ansible + state: absent +""" + +RETURN = r""" +zone: + description: + - Name of the zone which was created/updated/deleted + returned: on success + type: str + sample: + test +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.proxmox.plugins.module_utils.proxmox_sdn import ProxmoxSdnAnsible +from ansible_collections.community.proxmox.plugins.module_utils.proxmox import ( + proxmox_auth_argument_spec, + ansible_to_proxmox_bool +) + + +def get_proxmox_args(): + return dict( + state=dict(type="str", default="present", choices=["present", "absent"]), + update=dict(type="bool", default=True), + type=dict(type="str", + choices=["evpn", "faucet", "qinq", "simple", "vlan", "vxlan"], + required=False), + zone=dict(type="str", required=False), + advertise_subnets=dict(type="bool", required=False), + bridge=dict(type="str", required=False), + bridge_disable_mac_learning=dict(type="bool", required=False), + controller=dict(type="str", required=False), + dhcp=dict(type="str", choices=["dnsmasq"], required=False), + disable_arp_nd_suppression=dict(type="bool", required=False), + dns=dict(type="str", required=False), + dnszone=dict(type="str", required=False), + dp_id=dict(type="int", required=False), + exitnodes=dict(type="str", required=False), + exitnodes_local_routing=dict(type="bool", required=False), + exitnodes_primary=dict(type="str", required=False), + fabric=dict(type="str", required=False), + ipam=dict(type="str", required=False), + mac=dict(type="str", required=False), + mtu=dict(type="int", required=False), + nodes=dict(type="str", required=False), + peers=dict(type="str", required=False), + reversedns=dict(type="str", required=False), + rt_import=dict(type="str", required=False), + tag=dict(type="int", required=False), + vlan_protocol=dict(type="str", choices=["802.1q", "802.1ad"], required=False), + vrf_vxlan=dict(type="int", required=False), + vxlan_port=dict(type="int", required=False), + ) + + +def get_ansible_module(): + module_args = proxmox_auth_argument_spec() + module_args.update(get_proxmox_args()) + + return AnsibleModule( + argument_spec=module_args, + required_if=[ + ('state', 'present', ['type', 'zone']), + ('state', 'absent', ['zone']) + ] + ) + + +class ProxmoxZoneAnsible(ProxmoxSdnAnsible): + def __init__(self, module): + super(ProxmoxZoneAnsible, self).__init__(module) + self.params = module.params + + def validate_params(self): + zone_type = self.params.get('type') + if self.params.get('state') == 'present': + if zone_type == 'vlan': + return self.params.get('bridge') + elif zone_type == 'qinq': + return self.params.get('tag') and self.params.get('vlan_protocol') + elif zone_type == 'vxlan': + return self.params.get('fabric') + elif zone_type == 'evpn': + return self.params.get('controller') and self.params.get('vrf_vxlan') + else: + return True + else: + return True + + def run(self): + state = self.params.get('state') + update = self.params.get('update') + zone_type = self.params.get('type') + + if not self.validate_params(): + required_params = { + 'vlan': ['bridge'], + 'qinq': ['bridge', 'tag', 'vlan_protocol'], + 'vxlan': ['fabric'], + 'evpn': ['controller', 'vrf_vxlan'] + } + self.module.fail_json( + msg=f'to create zone of type {zone_type} it needs - {required_params[zone_type]}' + ) + + zone_params = { + "type": self.params.get("type"), + "zone": self.params.get("zone"), + "advertise-subnets": ansible_to_proxmox_bool(self.params.get("advertise_subnets")), + "bridge": self.params.get("bridge"), + "bridge-disable-mac-learning": ansible_to_proxmox_bool(self.params.get("bridge_disable_mac_learning")), + "controller": self.params.get("controller"), + "dhcp": self.params.get("dhcp"), + "disable-arp-nd-suppression": ansible_to_proxmox_bool(self.params.get("disable_arp_nd_suppression")), + "dns": self.params.get("dns"), + "dnszone": self.params.get("dnszone"), + "dp-id": self.params.get("dp_id"), + "exitnodes": self.params.get("exitnodes"), + "exitnodes-local-routing": ansible_to_proxmox_bool(self.params.get("exitnodes_local_routing")), + "exitnodes-primary": self.params.get("exitnodes_primary"), + "fabric": self.params.get("fabric"), + "ipam": self.params.get("ipam"), + "lock-token": None, + "mac": self.params.get("mac"), + "mtu": self.params.get("mtu"), + "nodes": self.params.get("nodes"), + "peers": self.params.get("peers"), + "reversedns": self.params.get("reversedns"), + "rt-import": self.params.get("rt_import"), + "tag": self.params.get("tag"), + "vlan-protocol": self.params.get("vlan_protocol"), + "vrf-vxlan": self.params.get("vrf_vxlan"), + "vxlan-port": self.params.get("vxlan_port"), + } + + if state == "present": + self.zone_present(update, **zone_params) + + elif state == "absent": + self.zone_absent( + zone_name=zone_params.get('zone'), + lock=zone_params.get('lock-token') + ) + + def zone_present(self, update, **kwargs): + available_zones = {x.get('zone'): {'type': x.get('type'), 'digest': x.get('digest')} for x in self.get_zones()} + zone_name = kwargs.get("zone") + zone_type = kwargs.get("type") + + # Check if zone already exists + if zone_name in available_zones.keys() and update: + if zone_type != available_zones[zone_name]['type']: + self.module.fail_json( + msg=f'zone {zone_name} exists with different type and we cannot change type post fact.' + ) + else: + try: + kwargs['lock-token'] = self.get_global_sdn_lock() + kwargs['digest'] = available_zones[zone_name]['digest'] + del kwargs['zone'] + del kwargs['type'] + + zone = getattr(self.proxmox_api.cluster().sdn().zones(), zone_name) + zone.put(**kwargs) + self.apply_sdn_changes_and_release_lock(kwargs['lock-token']) + self.module.exit_json( + changed=True, zone=zone_name, msg=f'Updated zone - {zone_name}' + ) + except Exception as e: + self.rollback_sdn_changes_and_release_lock(kwargs['lock-token']) + self.module.fail_json( + msg=f'Failed to update zone {zone_name} - {e}' + ) + + elif zone_name in available_zones.keys() and not update: + self.module.exit_json( + changed=False, zone=zone_name, msg=f'Zone {zone_name} already exists and update is false!' + ) + else: + try: + kwargs['lock-token'] = self.get_global_sdn_lock() + + self.proxmox_api.cluster().sdn().zones().post(**kwargs) + self.apply_sdn_changes_and_release_lock(kwargs['lock-token']) + self.module.exit_json( + changed=True, zone=zone_name, msg=f'Created new Zone - {zone_name}' + ) + except Exception as e: + self.rollback_sdn_changes_and_release_lock(kwargs['lock-token']) + self.module.fail_json( + msg=f'Failed to create zone {zone_name} - {e}' + ) + + def zone_absent(self, zone_name, lock=None): + available_zones = [x.get('zone') for x in self.get_zones()] + params = {'lock-token': lock} + + try: + if zone_name not in available_zones: + self.module.exit_json( + changed=False, zone=zone_name, msg=f"zone {zone_name} is absent." + ) + else: + params['lock-token'] = self.get_global_sdn_lock() + zone = getattr(self.proxmox_api.cluster().sdn().zones(), zone_name) + zone.delete(**params) + self.apply_sdn_changes_and_release_lock(params['lock-token']) + self.module.exit_json( + changed=True, zone=zone_name, msg=f'Successfully deleted zone {zone_name}' + ) + except Exception as e: + self.rollback_sdn_changes_and_release_lock(params['lock-token']) + self.module.fail_json( + msg=f'Failed to delete zone {zone_name} {e}. Rolling back all pending changes.' + ) + + +def main(): + module = get_ansible_module() + proxmox = ProxmoxZoneAnsible(module) + + try: + proxmox.run() + except Exception as e: + module.fail_json(msg=f'An error occurred: {e}') + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/proxmox_zone_info.py b/plugins/modules/proxmox_zone_info.py new file mode 100644 index 00000000..1d92c417 --- /dev/null +++ b/plugins/modules/proxmox_zone_info.py @@ -0,0 +1,146 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2025, Jana Hoch +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = r""" +module: proxmox_zone_info +short_description: Get Proxmox zone info. +description: + - List all available zones. +author: 'Jana Hoch (!UNKNOWN)' +options: + type: + description: + - Filter zones on based on type. + type: str + choices: + - evpn + - faucet + - qinq + - simple + - vlan + - vxlan +extends_documentation_fragment: + - community.proxmox.proxmox.actiongroup_proxmox + - community.proxmox.proxmox.documentation + - community.proxmox.attributes + - community.proxmox.attributes.info_module +""" + +EXAMPLES = r""" +- name: Get all zones + community.proxmox.proxmox_zone_info: + api_user: "root@pam" + api_password: "{{ vault.proxmox.root_password }}" + api_host: "{{ pc.proxmox.api_host }}" + validate_certs: no + +- name: Get all simple zones + community.proxmox.proxmox_zone_info: + api_user: "root@pam" + api_password: "{{ vault.proxmox.root_password }}" + api_host: "{{ pc.proxmox.api_host }}" + validate_certs: no + type: simple + register: zones +""" + +RETURN = r""" +zones: + description: + - List of zones. + - If type is passed it'll filter based on type + returned: on success + type: list + elements: dict + sample: + [ + { + "digest": "e29dea494461aa699ab3bfb7264d95631c8d0e0d", + "type": "simple", + "zone": "ans1" + }, + { + "bridge": "vmbr0", + "digest": "e29dea494461aa699ab3bfb7264d95631c8d0e0d", + "mtu": 1200, + "type": "vlan", + "zone": "ansible" + }, + { + "bridge": "vmbr100", + "digest": "e29dea494461aa699ab3bfb7264d95631c8d0e0d", + "ipam": "pve", + "type": "vlan", + "zone": "lab" + }, + { + "dhcp": "dnsmasq", + "digest": "e29dea494461aa699ab3bfb7264d95631c8d0e0d", + "ipam": "pve", + "type": "simple", + "zone": "test1" + }, + { + "digest": "e29dea494461aa699ab3bfb7264d95631c8d0e0d", + "ipam": "pve", + "type": "simple", + "zone": "tsjsfv" + } + ] + +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.proxmox.plugins.module_utils.proxmox_sdn import ProxmoxSdnAnsible +from ansible_collections.community.proxmox.plugins.module_utils.proxmox import proxmox_auth_argument_spec + + +def get_proxmox_args(): + return dict( + type=dict(type='str', choices=["evpn", "faucet", "qinq", "simple", "vlan", "vxlan"], required=False) + ) + + +def get_ansible_module(): + module_args = proxmox_auth_argument_spec() + module_args.update(get_proxmox_args()) + return AnsibleModule( + argument_spec=module_args, + supports_check_mode=True, + ) + + +class ProxmoxZoneInfoAnsible(ProxmoxSdnAnsible): + def __init__(self, module): + super(ProxmoxZoneInfoAnsible, self).__init__(module) + self.params = module.params + + def run(self): + zones = self.get_zones( + zone_type=self.params.get('type') + ) + self.module.exit_json( + changed=False, zones=zones, msg="Successfully retrieved zone info." + ) + + +def main(): + module = get_ansible_module() + proxmox = ProxmoxZoneInfoAnsible(module) + + try: + proxmox.run() + except Exception as e: + module.fail_json(msg=f'An error occurred: {e}') + + +if __name__ == "__main__": + main() diff --git a/tests/unit/plugins/modules/test_proxmox_zone.py b/tests/unit/plugins/modules/test_proxmox_zone.py new file mode 100644 index 00000000..ca0b98f1 --- /dev/null +++ b/tests/unit/plugins/modules/test_proxmox_zone.py @@ -0,0 +1,149 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2025, Jana Hoch +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +from unittest.mock import patch, Mock + +import pytest + +proxmoxer = pytest.importorskip("proxmoxer") + +from ansible_collections.community.proxmox.plugins.modules import proxmox_zone +from ansible_collections.community.internal_test_tools.tests.unit.plugins.modules.utils import ( + ModuleTestCase, + set_module_args, +) +import ansible_collections.community.proxmox.plugins.module_utils.proxmox as proxmox_utils + +RAW_ZONES = [ + { + "zone": "ans1", + "digest": "e3105246736ab2420104e34bca1dea68d152acc7", + "ipam": "pve", + "dhcp": "dnsmasq", + "type": "simple" + }, + { + "type": "vlan", + "zone": "lab", + "digest": "e3105246736ab2420104e34bca1dea68d152acc7", + "ipam": "pve", + "bridge": "vmbr100" + }, + { + "digest": "e3105246736ab2420104e34bca1dea68d152acc7", + "ipam": "pve", + "zone": "test1", + "type": "simple", + "dhcp": "dnsmasq" + } +] + + +def exit_json(*args, **kwargs): + """function to patch over exit_json; package return data into an exception""" + if 'changed' not in kwargs: + kwargs['changed'] = False + raise SystemExit(kwargs) + + +def fail_json(*args, **kwargs): + """function to patch over fail_json; package return data into an exception""" + kwargs['failed'] = True + raise SystemExit(kwargs) + + +def get_module_args_state_none(): + return { + 'api_host': 'host', + 'api_user': 'user', + 'api_password': 'password', + } + + +def get_module_args_zone(zone_type, zone, state='present', update=True, bridge=None): + return { + 'api_host': 'host', + 'api_user': 'user', + 'api_password': 'password', + 'type': zone_type, + 'zone': zone, + 'state': state, + 'update': update, + 'bridge': bridge + } + + +class TestProxmoxZoneModule(ModuleTestCase): + def setUp(self): + super(TestProxmoxZoneModule, self).setUp() + proxmox_utils.HAS_PROXMOXER = True + self.module = proxmox_zone + self.fail_json_patcher = patch('ansible.module_utils.basic.AnsibleModule.fail_json', + new=Mock(side_effect=fail_json)) + self.exit_json_patcher = patch('ansible.module_utils.basic.AnsibleModule.exit_json', new=exit_json) + + self.fail_json_mock = self.fail_json_patcher.start() + self.exit_json_patcher.start() + self.connect_mock = patch( + "ansible_collections.community.proxmox.plugins.module_utils.proxmox.ProxmoxAnsible._connect", + ).start() + self.connect_mock.return_value.cluster.return_value.sdn.return_value.zones.return_value.get.return_value = RAW_ZONES + + def tearDown(self): + self.connect_mock.stop() + self.exit_json_patcher.stop() + self.fail_json_patcher.stop() + super(TestProxmoxZoneModule, self).tearDown() + + def test_zone_present(self): + # Create new Zone + with pytest.raises(SystemExit) as exc_info: + with set_module_args(get_module_args_zone(zone_type='simple', zone='test')): + self.module.main() + result = exc_info.value.args[0] + assert result["changed"] is True + assert result["msg"] == "Created new Zone - test" + assert result['zone'] == 'test' + + # Update the zone + with pytest.raises(SystemExit) as exc_info: + with set_module_args(get_module_args_zone(zone_type='simple', zone='test1', state='present')): + self.module.main() + result = exc_info.value.args[0] + assert result["changed"] is True + assert result["msg"] == "Updated zone - test1" + assert result['zone'] == 'test1' + + # Zone Already exists update=False + with pytest.raises(SystemExit) as exc_info: + with set_module_args(get_module_args_zone(zone_type='simple', zone='test1', update=False)): + self.module.main() + result = exc_info.value.args[0] + assert result["changed"] is False + assert result["msg"] == 'Zone test1 already exists and update is false!' + assert result['zone'] == 'test1' + + # Zone Already exists with update=True + with pytest.raises(SystemExit) as exc_info: + with set_module_args(get_module_args_zone(zone_type='vlan', zone='test1', update=True, bridge='test')): + self.module.main() + result = exc_info.value.args[0] + assert self.fail_json_mock.called + assert result['failed'] is True + assert result['msg'] == 'zone test1 exists with different type and we cannot change type post fact.' + + def test_zone_absent(self): + with pytest.raises(SystemExit) as exc_info: + with set_module_args(get_module_args_zone(zone_type='simple', zone='test1', state='absent')): + self.module.main() + result = exc_info.value.args[0] + assert result["changed"] is True + assert result["msg"] == "Successfully deleted zone test1" + assert result['zone'] == 'test1' diff --git a/tests/unit/plugins/modules/test_proxmox_zone_info.py b/tests/unit/plugins/modules/test_proxmox_zone_info.py new file mode 100644 index 00000000..75d50e68 --- /dev/null +++ b/tests/unit/plugins/modules/test_proxmox_zone_info.py @@ -0,0 +1,112 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2025, Jana Hoch +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +from unittest.mock import patch, Mock + +import pytest + +proxmoxer = pytest.importorskip("proxmoxer") + +from ansible_collections.community.proxmox.plugins.modules import proxmox_zone_info +from ansible_collections.community.internal_test_tools.tests.unit.plugins.modules.utils import ( + ModuleTestCase, + set_module_args, +) +import ansible_collections.community.proxmox.plugins.module_utils.proxmox as proxmox_utils + +RAW_ZONES = [ + { + "zone": "ans1", + "digest": "e3105246736ab2420104e34bca1dea68d152acc7", + "ipam": "pve", + "dhcp": "dnsmasq", + "type": "simple" + }, + { + "type": "vlan", + "zone": "lab", + "digest": "e3105246736ab2420104e34bca1dea68d152acc7", + "ipam": "pve", + "bridge": "vmbr100" + }, + { + "digest": "e3105246736ab2420104e34bca1dea68d152acc7", + "ipam": "pve", + "zone": "test1", + "type": "simple", + "dhcp": "dnsmasq" + } +] + + +def exit_json(*args, **kwargs): + """function to patch over exit_json; package return data into an exception""" + if 'changed' not in kwargs: + kwargs['changed'] = False + raise SystemExit(kwargs) + + +def fail_json(*args, **kwargs): + """function to patch over fail_json; package return data into an exception""" + kwargs['failed'] = True + raise SystemExit(kwargs) + + +def get_module_args_state_none(): + return { + 'api_host': 'host', + 'api_user': 'user', + 'api_password': 'password', + } + + +def get_module_args_zone(zone_type, zone, state='present', update=True, bridge=None): + return { + 'api_host': 'host', + 'api_user': 'user', + 'api_password': 'password', + 'type': zone_type, + 'zone': zone, + 'state': state, + 'update': update, + 'bridge': bridge + } + + +class TestProxmoxZoneInfoModule(ModuleTestCase): + def setUp(self): + super(TestProxmoxZoneInfoModule, self).setUp() + proxmox_utils.HAS_PROXMOXER = True + self.module = proxmox_zone_info + self.fail_json_patcher = patch('ansible.module_utils.basic.AnsibleModule.fail_json', + new=Mock(side_effect=fail_json)) + self.exit_json_patcher = patch('ansible.module_utils.basic.AnsibleModule.exit_json', new=exit_json) + + self.fail_json_mock = self.fail_json_patcher.start() + self.exit_json_patcher.start() + self.connect_mock = patch( + "ansible_collections.community.proxmox.plugins.module_utils.proxmox.ProxmoxAnsible._connect", + ).start() + self.connect_mock.return_value.cluster.return_value.sdn.return_value.zones.return_value.get.return_value = RAW_ZONES + + def tearDown(self): + self.connect_mock.stop() + self.exit_json_patcher.stop() + self.fail_json_patcher.stop() + super(TestProxmoxZoneInfoModule, self).tearDown() + + def test_get_zones(self): + with pytest.raises(SystemExit) as exc_info: + with set_module_args(get_module_args_state_none()): + self.module.main() + result = exc_info.value.args[0] + assert result["changed"] is False + assert result["msg"] == "Successfully retrieved zone info." + assert result["zones"] == RAW_ZONES