From d76ddeef6b5cc4461e5c57d4d720d6c381470fc9 Mon Sep 17 00:00:00 2001 From: Jana Hoch Date: Thu, 28 Aug 2025 09:55:15 +0530 Subject: [PATCH 01/19] proxmox_zone: new module for proxmox_zones - List zones --- plugins/modules/proxmox_zone.py | 66 +++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 plugins/modules/proxmox_zone.py diff --git a/plugins/modules/proxmox_zone.py b/plugins/modules/proxmox_zone.py new file mode 100644 index 00000000..adba7d4a --- /dev/null +++ b/plugins/modules/proxmox_zone.py @@ -0,0 +1,66 @@ +#!/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"""""" + +EXAMPLES = r"""""" + +RETURN = r"""""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.proxmox.plugins.module_utils.proxmox import ( + proxmox_auth_argument_spec, + ProxmoxAnsible +) + + +class ProxmoxZoneAnsible(ProxmoxAnsible): + def get_zones(self, type): + try: + if type == "all": + zones = self.proxmox_api.cluster().sdn().zones().get() + else: + zones = self.proxmox_api.cluster().sdn().zones().get(type=type) + return zones + + except Exception as e: + self.module.fail_json( + msg=f'Failed to retrieve zone information from cluster: {e}' + ) + + +def main(): + + module_args = proxmox_auth_argument_spec() + zone_args = dict( + type=dict(type="str", + choices=["evpn", "faucet", "qinq", "simple", "vlan", "vxlan", "all"], + default="all", required=False) + ) + module_args.update(zone_args) + + module = AnsibleModule( + argument_spec=module_args, + required_together=[("api_token_id", "api_token_secret")], + required_one_of=[("api_password", "api_token_id")], + supports_check_mode=True, + ) + + proxmox = ProxmoxZoneAnsible(module) + type = module.params['type'] + results = {} + zones = proxmox.get_zones(type) + + results['zones'] = zones + module.exit_json(**results) + +if __name__ == "__main__": + main() \ No newline at end of file From d8ac15395d51da78dacb75dd309b16fc74796f07 Mon Sep 17 00:00:00 2001 From: Jana Hoch Date: Sat, 30 Aug 2025 01:12:17 +0530 Subject: [PATCH 02/19] proxmox_zone: Create new zone --- plugins/modules/proxmox_zone.py | 172 ++++++++++++++++++++++++++------ 1 file changed, 144 insertions(+), 28 deletions(-) diff --git a/plugins/modules/proxmox_zone.py b/plugins/modules/proxmox_zone.py index adba7d4a..1e2ed9ee 100644 --- a/plugins/modules/proxmox_zone.py +++ b/plugins/modules/proxmox_zone.py @@ -9,6 +9,9 @@ __metaclass__ = type +# from ansible_collections.community.sap_libs.plugins.modules.sap_control_exec import choices +# from pygments.lexer import default + DOCUMENTATION = r"""""" EXAMPLES = r"""""" @@ -21,46 +24,159 @@ ProxmoxAnsible ) +def get_proxmox_args(): + return dict( + state=dict(type="str", choices=["present", "absent"], required=False), + force=dict(type="bool", default=False, required=False), + update=dict(type="bool", default=False, required=False), + 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), + lock_token=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']), + ('update', True, ['zone']) + ] + ) + +class ProxmoxZoneAnsible(ProxmoxAnsible): + def __init__(self, module): + super(ProxmoxZoneAnsible, self).__init__(module) + self.params = module.params -class ProxmoxZoneAnsible(ProxmoxAnsible): - def get_zones(self, type): + def run(self): + state = self.params.get("state") + force = self.params.get("force") + + if state == "present": + params = { + "type": self.params.get("type"), + "zone": self.params.get("zone"), + "advertise-subnets": self.params.get("advertise_subnets"), + "bridge": self.params.get("bridge"), + "bridge-disable-mac-learning": self.params.get("bridge_disable_mac_learning"), + "controller": self.params.get("controller"), + "dhcp": self.params.get("dhcp"), + "disable-arp-nd-suppression": 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": 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": self.params.get("lock_token"), + "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"), + } + self.zone_present(force, **params) + + elif state == "update": + self.zone_update( + + ) + elif state == "absent": + self.zone_absent( + + ) + else: + zones = self.get_zones({'type': self.params.get('type')}) + self.module.exit_json( + changed=False, msg=zones + ) + + + def get_zones(self, type=dict()): + print("reached") try: - if type == "all": - zones = self.proxmox_api.cluster().sdn().zones().get() - else: - zones = self.proxmox_api.cluster().sdn().zones().get(type=type) - return zones - + return self.proxmox_api.cluster().sdn().zones().get(**type) except Exception as e: self.module.fail_json( msg=f'Failed to retrieve zone information from cluster: {e}' ) + def zone_present(self, force, **kwargs): + available_zones = {x["zone"]: x["type"] for x in self.get_zones()} + zone = kwargs.get("zone") + type = kwargs.get("type") -def main(): - - module_args = proxmox_auth_argument_spec() - zone_args = dict( - type=dict(type="str", - choices=["evpn", "faucet", "qinq", "simple", "vlan", "vxlan", "all"], - default="all", required=False) - ) - module_args.update(zone_args) + # Check if zone already exists + if zone in available_zones.keys() and force: + if type != available_zones[zone]: + self.module.fail_json( + zone=zone, + msg=f'zone {zone} exists with different type and we cannot change type post fact.' + ) + else: + del kwargs['type'] + self.zone_update(kwargs) + elif zone in available_zones.keys() and not force: + self.module.exit_json( + changed=False, zone=zone, msg=f'Zone {zone} already exists and force is false!' + ) + else: + self.proxmox_api.cluster().sdn().zones().post(**kwargs) + self.module.exit_json( + changed=True, zone=zone, msg=f'Created new Zone - {zone}' + ) - module = AnsibleModule( - argument_spec=module_args, - required_together=[("api_token_id", "api_token_secret")], - required_one_of=[("api_password", "api_token_id")], - supports_check_mode=True, - ) + def zone_update(self): + pass + def zone_absent(self): + pass + + +def main(): + module = get_ansible_module() proxmox = ProxmoxZoneAnsible(module) - type = module.params['type'] - results = {} - zones = proxmox.get_zones(type) - results['zones'] = zones - module.exit_json(**results) + try: + proxmox.run() + except Exception as e: + module.fail_json(msg=f'An error occurred: {e}') if __name__ == "__main__": main() \ No newline at end of file From dee432ffff7a614066d3607c4a69dcd3c6fb81f7 Mon Sep 17 00:00:00 2001 From: Jana Hoch Date: Sat, 30 Aug 2025 01:21:35 +0530 Subject: [PATCH 03/19] proxmox_zone: Keep common parameter for all conditions --- plugins/modules/proxmox_zone.py | 68 ++++++++++++++++----------------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/plugins/modules/proxmox_zone.py b/plugins/modules/proxmox_zone.py index 1e2ed9ee..3d43b622 100644 --- a/plugins/modules/proxmox_zone.py +++ b/plugins/modules/proxmox_zone.py @@ -81,40 +81,41 @@ def run(self): state = self.params.get("state") force = self.params.get("force") + zone_params = { + "type": self.params.get("type"), + "zone": self.params.get("zone"), + "advertise-subnets": self.params.get("advertise_subnets"), + "bridge": self.params.get("bridge"), + "bridge-disable-mac-learning": self.params.get("bridge_disable_mac_learning"), + "controller": self.params.get("controller"), + "dhcp": self.params.get("dhcp"), + "disable-arp-nd-suppression": 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": 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": self.params.get("lock_token"), + "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": - params = { - "type": self.params.get("type"), - "zone": self.params.get("zone"), - "advertise-subnets": self.params.get("advertise_subnets"), - "bridge": self.params.get("bridge"), - "bridge-disable-mac-learning": self.params.get("bridge_disable_mac_learning"), - "controller": self.params.get("controller"), - "dhcp": self.params.get("dhcp"), - "disable-arp-nd-suppression": 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": 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": self.params.get("lock_token"), - "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"), - } - self.zone_present(force, **params) + self.zone_present(force, **zone_params) elif state == "update": - self.zone_update( + self.zone_update(**zone_params ) elif state == "absent": @@ -122,13 +123,13 @@ def run(self): ) else: - zones = self.get_zones({'type': self.params.get('type')}) + zones = self.get_zones(**zone_params) self.module.exit_json( changed=False, msg=zones ) - def get_zones(self, type=dict()): + def get_zones(self, **type): print("reached") try: return self.proxmox_api.cluster().sdn().zones().get(**type) @@ -146,7 +147,6 @@ def zone_present(self, force, **kwargs): if zone in available_zones.keys() and force: if type != available_zones[zone]: self.module.fail_json( - zone=zone, msg=f'zone {zone} exists with different type and we cannot change type post fact.' ) else: From 2e27f61d22bf31e2c69b45f37eb851d9cb6ebbf5 Mon Sep 17 00:00:00 2001 From: Jana Hoch Date: Sat, 30 Aug 2025 02:32:22 +0530 Subject: [PATCH 04/19] proxmox_zone: Implement locking --- plugins/modules/proxmox_zone.py | 55 +++++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/plugins/modules/proxmox_zone.py b/plugins/modules/proxmox_zone.py index 3d43b622..1f3731e5 100644 --- a/plugins/modules/proxmox_zone.py +++ b/plugins/modules/proxmox_zone.py @@ -111,13 +111,15 @@ def run(self): "vxlan-port": self.params.get("vxlan_port"), } + if zone_params['lock-token'] is None and state is not None: + zone_params['lock-token'] = self.get_global_sdn_lock() + if state == "present": self.zone_present(force, **zone_params) elif state == "update": - self.zone_update(**zone_params + self.zone_update(**zone_params) - ) elif state == "absent": self.zone_absent( @@ -128,6 +130,48 @@ def run(self): changed=False, msg=zones ) + def get_global_sdn_lock(self): + try: + return self.proxmox_api.cluster().sdn().lock().post() + except Exception as e: + self.apply_sdn_changes_and_release_lock() + self.module.fail_json( + msg=f'Failed to acquire global sdn lock {e}' + ) + + def apply_sdn_changes_and_release_lock(self, lock): + lock_params = { + 'lock-token': lock, + 'release-lock': 1 + } + try: + return self.proxmox_api.cluster().sdn().put(**lock_params) + except Exception as e: + self.rollback_sdn_changes_and_release_lock(lock_params) + 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_params): + 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): + lock_params = { + 'lock-token': lock, + 'force': 0 + } + 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, **type): print("reached") @@ -142,27 +186,32 @@ def zone_present(self, force, **kwargs): available_zones = {x["zone"]: x["type"] for x in self.get_zones()} zone = kwargs.get("zone") type = kwargs.get("type") + lock = kwargs.get('lock-token') # Check if zone already exists if zone in available_zones.keys() and force: if type != available_zones[zone]: + self.release_lock(lock) self.module.fail_json( + lock=lock, msg=f'zone {zone} exists with different type and we cannot change type post fact.' ) else: del kwargs['type'] self.zone_update(kwargs) elif zone in available_zones.keys() and not force: + self.release_lock(lock) self.module.exit_json( changed=False, zone=zone, msg=f'Zone {zone} already exists and force is false!' ) else: self.proxmox_api.cluster().sdn().zones().post(**kwargs) + self.apply_sdn_changes_and_release_lock(lock) self.module.exit_json( changed=True, zone=zone, msg=f'Created new Zone - {zone}' ) - def zone_update(self): + def zone_update(self, **kwargs): pass def zone_absent(self): From fa60bb46a47bad68077792715aac0c72bd4b8904 Mon Sep 17 00:00:00 2001 From: Jana Hoch Date: Sat, 30 Aug 2025 10:05:43 +0530 Subject: [PATCH 05/19] proxmox_zone: Added update_zone() --- plugins/modules/proxmox_zone.py | 52 ++++++++++++++++++++++++++------- 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/plugins/modules/proxmox_zone.py b/plugins/modules/proxmox_zone.py index 1f3731e5..07c6bc5b 100644 --- a/plugins/modules/proxmox_zone.py +++ b/plugins/modules/proxmox_zone.py @@ -26,9 +26,8 @@ def get_proxmox_args(): return dict( - state=dict(type="str", choices=["present", "absent"], required=False), + state=dict(type="str", choices=["present", "absent", "update"], required=False), force=dict(type="bool", default=False, required=False), - update=dict(type="bool", default=False, required=False), type=dict(type="str", choices=["evpn", "faucet", "qinq", "simple", "vlan", "vxlan"], required=False), @@ -68,7 +67,7 @@ def get_ansible_module(): argument_spec=module_args, required_if=[ ('state', 'present', ['type', 'zone']), - ('update', True, ['zone']) + ('state', 'update', ['type', 'zone']) ] ) @@ -134,7 +133,6 @@ def get_global_sdn_lock(self): try: return self.proxmox_api.cluster().sdn().lock().post() except Exception as e: - self.apply_sdn_changes_and_release_lock() self.module.fail_json( msg=f'Failed to acquire global sdn lock {e}' ) @@ -147,12 +145,16 @@ def apply_sdn_changes_and_release_lock(self, lock): try: return self.proxmox_api.cluster().sdn().put(**lock_params) except Exception as e: - self.rollback_sdn_changes_and_release_lock(lock_params) + 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_params): + def rollback_sdn_changes_and_release_lock(self, lock): + lock_params = { + 'lock-token': lock, + 'release-lock': 1 + } try: self.proxmox_api.cluster().sdn().rollback().post(**lock_params) except Exception as e: @@ -183,22 +185,21 @@ def get_zones(self, **type): ) def zone_present(self, force, **kwargs): - available_zones = {x["zone"]: x["type"] for x in self.get_zones()} + available_zones = {x['zone']: {'type': x["type"], 'digest': x['digest']} for x in self.get_zones()} zone = kwargs.get("zone") type = kwargs.get("type") lock = kwargs.get('lock-token') # Check if zone already exists if zone in available_zones.keys() and force: - if type != available_zones[zone]: + if type != available_zones[zone]['type']: self.release_lock(lock) self.module.fail_json( lock=lock, msg=f'zone {zone} exists with different type and we cannot change type post fact.' ) else: - del kwargs['type'] - self.zone_update(kwargs) + self.zone_update(**kwargs) elif zone in available_zones.keys() and not force: self.release_lock(lock) self.module.exit_json( @@ -212,7 +213,36 @@ def zone_present(self, force, **kwargs): ) def zone_update(self, **kwargs): - pass + available_zones = {x['zone']: {'type': x["type"], 'digest': x['digest']} for x in self.get_zones()} + type = kwargs.get("type") + zone_name = kwargs.get("zone") + lock = kwargs.get('lock-token') + + try: + # If zone is not present create it + if zone_name not in available_zones.keys(): + self.zone_present(force=False, **kwargs) + elif type == available_zones[zone_name]['type']: + del kwargs['type'] + del kwargs['zone'] + kwargs['digest'] = available_zones[zone_name]['digest'] + + zone = getattr(self.proxmox_api.cluster().sdn().zones(), zone_name) + zone.put(**kwargs) + self.apply_sdn_changes_and_release_lock(lock) + self.module.exit_json( + changed=True, msg=f'Updated zone {zone_name}' + ) + else: + self.release_lock(lock) + self.module.fail_json( + msg=f'zone {zone_name} already exists with different type' + ) + 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 zone_absent(self): pass From de142261994e0ca7f5db59d3603fc42e48c54f46 Mon Sep 17 00:00:00 2001 From: Jana Hoch Date: Sat, 30 Aug 2025 10:20:36 +0530 Subject: [PATCH 06/19] proxmox_zone: added zone_absent() --- plugins/modules/proxmox_zone.py | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/plugins/modules/proxmox_zone.py b/plugins/modules/proxmox_zone.py index 07c6bc5b..515bdb0e 100644 --- a/plugins/modules/proxmox_zone.py +++ b/plugins/modules/proxmox_zone.py @@ -121,7 +121,8 @@ def run(self): elif state == "absent": self.zone_absent( - + zone_name=zone_params.get('zone'), + lock=zone_params.get('lock-token') ) else: zones = self.get_zones(**zone_params) @@ -241,11 +242,32 @@ def zone_update(self, **kwargs): 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.' + msg=f'Failed to update zone {e}' + ) + + def zone_absent(self, zone_name, lock): + available_zones = [x['zone'] for x in self.get_zones()] + params = {'lock-token': lock} + + try: + if zone_name not in available_zones: + self.release_lock(lock) + self.module.exit_json( + changed=False, msg=f"zone {zone_name} already doesn't exist." + ) + else: + zone = getattr(self.proxmox_api.cluster().sdn().zones(), zone_name) + zone.delete(**params) + self.apply_sdn_changes_and_release_lock(lock) + self.module.exit_json( + changed=True, msg=f'Successfully deleted zone {zone_name}' + ) + except Exception as e: + self.rollback_sdn_changes_and_release_lock(lock) + self.module.fail_json( + msg=f'Failed to delete zone {zone_name} {e}. Rolling back all pending changes.' ) - def zone_absent(self): - pass def main(): From 57c7e7d9e4c105c63b9e0b57f2e2c1cf443a3670 Mon Sep 17 00:00:00 2001 From: Jana Hoch Date: Sat, 30 Aug 2025 11:21:45 +0530 Subject: [PATCH 07/19] proxmox_zone: Added document and examples --- plugins/modules/proxmox_zone.py | 258 +++++++++++++++++++++++++++++++- 1 file changed, 253 insertions(+), 5 deletions(-) diff --git a/plugins/modules/proxmox_zone.py b/plugins/modules/proxmox_zone.py index 515bdb0e..c277f28d 100644 --- a/plugins/modules/proxmox_zone.py +++ b/plugins/modules/proxmox_zone.py @@ -12,11 +12,259 @@ # from ansible_collections.community.sap_libs.plugins.modules.sap_control_exec import choices # from pygments.lexer import default -DOCUMENTATION = r"""""" - -EXAMPLES = r"""""" - -RETURN = r"""""" +DOCUMENTATION = r""" +module: proxmox_zone +short_description: Manage Proxmox zone configurations +description: + - list/create/update/delete proxmox sdn zones +author: 'Jana Hoch ' +options: + state: + description: + - The desired state of the zone configuration. + type: str + choices: + - present + - absent + - update + force: + description: + - If state is present and zone exists it'll update. + - If state is update and zone doesn't exists it'll create new zone + type: bool + default: false + 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 ex: mydomain.com + 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 + lock_token: + description: + - the token for unlocking the global SDN configuration. If not provided it will generate new token + - If the playbook fails for some reason you can manually clear lock token by deleting `/etc/pve/sdn/.lock` + 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: Get all zones + community.proxmox.proxmox_zone: + 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: + api_user: "root@pam" + api_password: "{{ vault.proxmox.root_password }}" + api_host: "{{ pc.proxmox.api_host }}" + validate_certs: no + type: simple + register: zones + +- 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: update 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: vlan + zone: ansible + state: update + mtu: 1200 + +- 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""" +zones: + description: + - List of zones. if you do not pass zone name. + - If you are creating/updating/deleting it'll just return a msg with status + 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 import ( From 3f82ab6148bce779f6ffa831a0af52740f60e21c Mon Sep 17 00:00:00 2001 From: Jana Hoch Date: Sat, 30 Aug 2025 12:06:02 +0530 Subject: [PATCH 08/19] proxmox_zone: add missing exception handling for zone_preent() --- plugins/modules/proxmox_zone.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/plugins/modules/proxmox_zone.py b/plugins/modules/proxmox_zone.py index c277f28d..bb3da13d 100644 --- a/plugins/modules/proxmox_zone.py +++ b/plugins/modules/proxmox_zone.py @@ -444,7 +444,6 @@ def zone_present(self, force, **kwargs): if type != available_zones[zone]['type']: self.release_lock(lock) self.module.fail_json( - lock=lock, msg=f'zone {zone} exists with different type and we cannot change type post fact.' ) else: @@ -455,11 +454,17 @@ def zone_present(self, force, **kwargs): changed=False, zone=zone, msg=f'Zone {zone} already exists and force is false!' ) else: - self.proxmox_api.cluster().sdn().zones().post(**kwargs) - self.apply_sdn_changes_and_release_lock(lock) - self.module.exit_json( - changed=True, zone=zone, msg=f'Created new Zone - {zone}' - ) + try: + self.proxmox_api.cluster().sdn().zones().post(**kwargs) + self.apply_sdn_changes_and_release_lock(lock) + self.module.exit_json( + changed=True, zone=zone, msg=f'Created new Zone - {zone}' + ) + except Exception as e: + self.rollback_sdn_changes_and_release_lock(lock) + self.module.fail_json( + msg=f'Failed to create zone {zone}' + ) def zone_update(self, **kwargs): available_zones = {x['zone']: {'type': x["type"], 'digest': x['digest']} for x in self.get_zones()} From 70d3d830818db7d5d97f0cebed954cda872b0799 Mon Sep 17 00:00:00 2001 From: Jana Hoch Date: Sat, 30 Aug 2025 12:52:55 +0530 Subject: [PATCH 09/19] proxmox_zone: validate params --- plugins/modules/proxmox_zone.py | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/plugins/modules/proxmox_zone.py b/plugins/modules/proxmox_zone.py index bb3da13d..6dd6246f 100644 --- a/plugins/modules/proxmox_zone.py +++ b/plugins/modules/proxmox_zone.py @@ -315,7 +315,8 @@ def get_ansible_module(): argument_spec=module_args, required_if=[ ('state', 'present', ['type', 'zone']), - ('state', 'update', ['type', 'zone']) + ('state', 'update', ['type', 'zone']), + ('state', 'absent', ['zone']) ] ) @@ -324,9 +325,35 @@ def __init__(self, module): super(ProxmoxZoneAnsible, self).__init__(module) self.params = module.params + def validate_params(self): + type = self.params.get('type') + if self.params.get('state') in ['present', 'update']: + if type == 'vlan': + return self.params.get('bridge') + elif type == 'qinq': + return self.params.get('tag') and self.params.get('vlan_protocol') + elif type == 'vxlan': + return self.params.get('fabric') + elif type == 'evpn': + return self.params.get('controller') and self.params.get('vrf_vxlan') + else: + return True + def run(self): state = self.params.get("state") force = self.params.get("force") + type = self.params['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 {type} it needs - {required_params[type]}' + ) zone_params = { "type": self.params.get("type"), From 0c0bb74bcf675c62dc96587b496114bfd4070a5e Mon Sep 17 00:00:00 2001 From: Jana Hoch Date: Sat, 30 Aug 2025 15:37:48 +0530 Subject: [PATCH 10/19] proxmox_zone and proxmox module_utils - Move sdn locking functions to proxmox module_utils --- plugins/module_utils/proxmox.py | 64 +++++++++++++++++++++++++++++++++ plugins/modules/proxmox_zone.py | 46 ------------------------ 2 files changed, 64 insertions(+), 46 deletions(-) diff --git a/plugins/module_utils/proxmox.py b/plugins/module_utils/proxmox.py index 0455e793..9ba100bf 100644 --- a/plugins/module_utils/proxmox.py +++ b/plugins/module_utils/proxmox.py @@ -245,3 +245,67 @@ def get_storage_content(self, node, storage, content=None, vmid=None): msg="Unable to list content on %s, %s for %s and %s: %s" % (node, storage, content, vmid, e) ) + + def get_global_sdn_lock(self): + """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, release_lock=True): + """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, release_lock=True): + """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, force=False): + """Release 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' + ) diff --git a/plugins/modules/proxmox_zone.py b/plugins/modules/proxmox_zone.py index 6dd6246f..6187e48e 100644 --- a/plugins/modules/proxmox_zone.py +++ b/plugins/modules/proxmox_zone.py @@ -405,52 +405,6 @@ def run(self): changed=False, msg=zones ) - def get_global_sdn_lock(self): - 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): - lock_params = { - 'lock-token': lock, - 'release-lock': 1 - } - try: - return 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): - lock_params = { - 'lock-token': lock, - 'release-lock': 1 - } - 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): - lock_params = { - 'lock-token': lock, - 'force': 0 - } - 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, **type): print("reached") try: From a5773bf1e1b2cd3a3bf70ad032f98df8c84dfa07 Mon Sep 17 00:00:00 2001 From: Jana Hoch Date: Fri, 5 Sep 2025 23:58:38 +0530 Subject: [PATCH 11/19] proxmox_zone: Fix sanity issues - Fix PEP8 issues - Fix Doc issues for dnszone - Add workaround for author string - Add attribute check_mode and diff_mode - Add no_log=False for lock_token as this is not a secret - Add proxmox_zone to runtime.yml --- meta/runtime.yml | 1 + plugins/modules/proxmox_zone.py | 35 ++++++++++++++++++++------------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/meta/runtime.yml b/meta/runtime.yml index 23f05cbd..dd45c3d4 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -35,3 +35,4 @@ action_groups: - proxmox_user - proxmox_user_info - proxmox_vm_info + - proxmox_zone \ No newline at end of file diff --git a/plugins/modules/proxmox_zone.py b/plugins/modules/proxmox_zone.py index 6187e48e..61fd25f1 100644 --- a/plugins/modules/proxmox_zone.py +++ b/plugins/modules/proxmox_zone.py @@ -17,7 +17,12 @@ short_description: Manage Proxmox zone configurations description: - list/create/update/delete proxmox sdn zones -author: 'Jana Hoch ' +author: 'Jana Hoch (!UNKNOWN)' +attributes: + check_mode: + support: none + diff_mode: + support: none options: state: description: @@ -80,7 +85,7 @@ type: str dnszone: description: - - dns domain zone ex: mydomain.com + - dns domain zone. type: str dp_id: description: @@ -167,7 +172,7 @@ api_password: "{{ vault.proxmox.root_password }}" api_host: "{{ pc.proxmox.api_host }}" validate_certs: no - + - name: Get all simple zones community.proxmox.proxmox_zone: api_user: "root@pam" @@ -186,7 +191,7 @@ type: simple zone: ansible state: present - + - name: create a vlan zones community.proxmox.proxmox_zone: api_user: "root@pam" @@ -197,7 +202,7 @@ zone: ansible state: present bridge: vmbr0 - + - name: update a zones community.proxmox.proxmox_zone: api_user: "root@pam" @@ -208,7 +213,7 @@ zone: ansible state: update mtu: 1200 - + - name: Delete a zones community.proxmox.proxmox_zone: api_user: "root@pam" @@ -222,14 +227,14 @@ RETURN = r""" zones: - description: - - List of zones. if you do not pass zone name. + description: + - List of zones. if you do not pass zone name. - If you are creating/updating/deleting it'll just return a msg with status returned: on success type: list elements: dict sample: - [ + [ { "digest": "e29dea494461aa699ab3bfb7264d95631c8d0e0d", "type": "simple", @@ -263,7 +268,7 @@ "zone": "tsjsfv" } ] - + """ from ansible.module_utils.basic import AnsibleModule @@ -272,6 +277,7 @@ ProxmoxAnsible ) + def get_proxmox_args(): return dict( state=dict(type="str", choices=["present", "absent", "update"], required=False), @@ -294,7 +300,7 @@ def get_proxmox_args(): exitnodes_primary=dict(type="str", required=False), fabric=dict(type="str", required=False), ipam=dict(type="str", required=False), - lock_token=dict(type="str", required=False), + lock_token=dict(type="str", required=False, no_log=False), mac=dict(type="str", required=False), mtu=dict(type="int", required=False), nodes=dict(type="str", required=False), @@ -307,6 +313,7 @@ def get_proxmox_args(): vxlan_port=dict(type="int", required=False), ) + def get_ansible_module(): module_args = proxmox_auth_argument_spec() module_args.update(get_proxmox_args()) @@ -320,6 +327,7 @@ def get_ansible_module(): ] ) + class ProxmoxZoneAnsible(ProxmoxAnsible): def __init__(self, module): super(ProxmoxZoneAnsible, self).__init__(module) @@ -406,7 +414,6 @@ def run(self): ) def get_zones(self, **type): - print("reached") try: return self.proxmox_api.cluster().sdn().zones().get(**type) except Exception as e: @@ -503,7 +510,6 @@ def zone_absent(self, zone_name, lock): ) - def main(): module = get_ansible_module() proxmox = ProxmoxZoneAnsible(module) @@ -513,5 +519,6 @@ def main(): except Exception as e: module.fail_json(msg=f'An error occurred: {e}') + if __name__ == "__main__": - main() \ No newline at end of file + main() From e69c8d98132e8be40e90e8d7e9d4ca552c04d23d Mon Sep 17 00:00:00 2001 From: Jana Hoch Date: Wed, 10 Sep 2025 21:48:23 +0530 Subject: [PATCH 12/19] proxmox_zone: fix minor issues found during testing --- plugins/modules/proxmox_zone.py | 35 ++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/plugins/modules/proxmox_zone.py b/plugins/modules/proxmox_zone.py index 61fd25f1..18d6a179 100644 --- a/plugins/modules/proxmox_zone.py +++ b/plugins/modules/proxmox_zone.py @@ -226,10 +226,17 @@ """ RETURN = r""" +zone: + description: + - Name of the zone which was created/updated/deleted + returned: on success + type: str + sample: + test zones: description: - - List of zones. if you do not pass zone name. - - If you are creating/updating/deleting it'll just return a msg with status + - List of zones. + - If type is passed it'll filter based on type returned: on success type: list elements: dict @@ -344,6 +351,8 @@ def validate_params(self): return self.params.get('fabric') elif type == 'evpn': return self.params.get('controller') and self.params.get('vrf_vxlan') + else: + return True else: return True @@ -408,21 +417,23 @@ def run(self): lock=zone_params.get('lock-token') ) else: - zones = self.get_zones(**zone_params) + zones = self.get_zones( + type=self.params.get('type') + ) self.module.exit_json( - changed=False, msg=zones + changed=False, zones=zones, msg="Successfully retrieved zone info." ) - def get_zones(self, **type): + def get_zones(self, type=None): try: - return self.proxmox_api.cluster().sdn().zones().get(**type) + return self.proxmox_api.cluster().sdn().zones().get(type=type) except Exception as e: self.module.fail_json( msg=f'Failed to retrieve zone information from cluster: {e}' ) def zone_present(self, force, **kwargs): - available_zones = {x['zone']: {'type': x["type"], 'digest': x['digest']} for x in self.get_zones()} + available_zones = {x.get('zone'): {'type': x.get('type'), 'digest': x.get('digest')} for x in self.get_zones()} zone = kwargs.get("zone") type = kwargs.get("type") lock = kwargs.get('lock-token') @@ -455,7 +466,7 @@ def zone_present(self, force, **kwargs): ) def zone_update(self, **kwargs): - available_zones = {x['zone']: {'type': x["type"], 'digest': x['digest']} for x in self.get_zones()} + available_zones = {x.get('zone'): {'type': x.get('type'), 'digest': x.get('digest')} for x in self.get_zones()} type = kwargs.get("type") zone_name = kwargs.get("zone") lock = kwargs.get('lock-token') @@ -473,7 +484,7 @@ def zone_update(self, **kwargs): zone.put(**kwargs) self.apply_sdn_changes_and_release_lock(lock) self.module.exit_json( - changed=True, msg=f'Updated zone {zone_name}' + changed=True, zone=zone_name, msg=f'Updated zone {zone_name}' ) else: self.release_lock(lock) @@ -487,21 +498,21 @@ def zone_update(self, **kwargs): ) def zone_absent(self, zone_name, lock): - available_zones = [x['zone'] for x in self.get_zones()] + available_zones = [x.get('zone') for x in self.get_zones()] params = {'lock-token': lock} try: if zone_name not in available_zones: self.release_lock(lock) self.module.exit_json( - changed=False, msg=f"zone {zone_name} already doesn't exist." + changed=False, zone=zone_name, msg=f"zone {zone_name} already doesn't exist." ) else: zone = getattr(self.proxmox_api.cluster().sdn().zones(), zone_name) zone.delete(**params) self.apply_sdn_changes_and_release_lock(lock) self.module.exit_json( - changed=True, msg=f'Successfully deleted zone {zone_name}' + changed=True, zone=zone_name, msg=f'Successfully deleted zone {zone_name}' ) except Exception as e: self.rollback_sdn_changes_and_release_lock(lock) From 8e549c31521b40f53d976bfa891aa5f6098bca14 Mon Sep 17 00:00:00 2001 From: Jana Hoch Date: Wed, 10 Sep 2025 21:48:44 +0530 Subject: [PATCH 13/19] proxmox_zone: Add unit tests --- .../unit/plugins/modules/test_proxmox_zone.py | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 tests/unit/plugins/modules/test_proxmox_zone.py 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..36c100f1 --- /dev/null +++ b/tests/unit/plugins/modules/test_proxmox_zone.py @@ -0,0 +1,159 @@ +# -*- 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(type, zone, state='present', force=False, bridge=None): + return { + 'api_host': 'host', + 'api_user': 'user', + 'api_password': 'password', + 'type': type, + 'zone': zone, + 'state': state, + 'force': force, + '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.mock_module_helper.stop() + self.exit_json_patcher.stop() + self.fail_json_patcher.stop() + super(TestProxmoxZoneModule, 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 + + def test_zone_present(self): + # Create new Zone + with pytest.raises(SystemExit) as exc_info: + with set_module_args(get_module_args_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' + + # Zone Already exists without force + with pytest.raises(SystemExit) as exc_info: + with set_module_args(get_module_args_zone(type='simple', zone='test1')): + self.module.main() + result = exc_info.value.args[0] + assert result["changed"] is False + assert result["msg"] == 'Zone test1 already exists and force is false!' + assert result['zone'] == 'test1' + + # Zone Already exists with force and different type + with pytest.raises(SystemExit) as exc_info: + with set_module_args(get_module_args_zone(type='vlan', zone='test1', force=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_update(self): + with pytest.raises(SystemExit) as exc_info: + with set_module_args(get_module_args_zone(type='simple', zone='test1', state='update')): + self.module.main() + result = exc_info.value.args[0] + assert result["changed"] is True + assert result["msg"] == "Updated zone test1" + assert result['zone'] == 'test1' + + def test_zone_absent(self): + with pytest.raises(SystemExit) as exc_info: + with set_module_args(get_module_args_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' From 7b247507ac18bd4231bff6702d74ef98f4ae439c Mon Sep 17 00:00:00 2001 From: Jana Hoch Date: Fri, 12 Sep 2025 00:59:03 +0530 Subject: [PATCH 14/19] proxmox_zone: Add missing boolean conversion --- plugins/modules/proxmox_zone.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/plugins/modules/proxmox_zone.py b/plugins/modules/proxmox_zone.py index 18d6a179..f50e03f9 100644 --- a/plugins/modules/proxmox_zone.py +++ b/plugins/modules/proxmox_zone.py @@ -281,6 +281,7 @@ from ansible.module_utils.basic import AnsibleModule from ansible_collections.community.proxmox.plugins.module_utils.proxmox import ( proxmox_auth_argument_spec, + ansible_to_proxmox_bool, ProxmoxAnsible ) @@ -375,17 +376,17 @@ def run(self): zone_params = { "type": self.params.get("type"), "zone": self.params.get("zone"), - "advertise-subnets": self.params.get("advertise_subnets"), + "advertise-subnets": ansible_to_proxmox_bool(self.params.get("advertise_subnets")), "bridge": self.params.get("bridge"), - "bridge-disable-mac-learning": self.params.get("bridge_disable_mac_learning"), + "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": self.params.get("disable_arp_nd_suppression"), + "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": self.params.get("exitnodes_local_routing"), + "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"), From 4693b66e8cb7344413c315d33c2092fbbd99dc05 Mon Sep 17 00:00:00 2001 From: Jana Hoch Date: Sun, 14 Sep 2025 14:21:49 +0530 Subject: [PATCH 15/19] module_utils: Create new base class ProxmoxSdnAnsible() - Move SDN locking methods from ProxmoxAnsible class to ProxmoxSdnAnsible - Add ge_zones() method to ProxmoxSdnAnsible As we will be splitting zone module into zone and zone_info --- plugins/module_utils/proxmox.py | 64 ---------------- plugins/module_utils/proxmox_sdn.py | 112 ++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 64 deletions(-) create mode 100644 plugins/module_utils/proxmox_sdn.py diff --git a/plugins/module_utils/proxmox.py b/plugins/module_utils/proxmox.py index 9ba100bf..0455e793 100644 --- a/plugins/module_utils/proxmox.py +++ b/plugins/module_utils/proxmox.py @@ -245,67 +245,3 @@ def get_storage_content(self, node, storage, content=None, vmid=None): msg="Unable to list content on %s, %s for %s and %s: %s" % (node, storage, content, vmid, e) ) - - def get_global_sdn_lock(self): - """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, release_lock=True): - """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, release_lock=True): - """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, force=False): - """Release 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' - ) diff --git a/plugins/module_utils/proxmox_sdn.py b/plugins/module_utils/proxmox_sdn.py new file mode 100644 index 00000000..cc9c8d16 --- /dev/null +++ b/plugins/module_utils/proxmox_sdn.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 + +import traceback +from typing import List, Dict + +PROXMOXER_IMP_ERR = None +try: + from proxmoxer import ProxmoxAPI + from proxmoxer import __version__ as proxmoxer_version + + HAS_PROXMOXER = True +except ImportError: + HAS_PROXMOXER = False + PROXMOXER_IMP_ERR = traceback.format_exc() + +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}' + ) From cf1f4c21c4095b2eb164c87d8a1ec28d711ebc0b Mon Sep 17 00:00:00 2001 From: Jana Hoch Date: Sun, 14 Sep 2025 14:27:40 +0530 Subject: [PATCH 16/19] Create seprate proxmox_zone_info - Split proxmox_zone and create proxmox_zone_info - Rename variable type to zone_type --- plugins/modules/proxmox_zone.py | 114 ++++----------------- plugins/modules/proxmox_zone_info.py | 145 +++++++++++++++++++++++++++ 2 files changed, 167 insertions(+), 92 deletions(-) create mode 100644 plugins/modules/proxmox_zone_info.py diff --git a/plugins/modules/proxmox_zone.py b/plugins/modules/proxmox_zone.py index f50e03f9..f151d07a 100644 --- a/plugins/modules/proxmox_zone.py +++ b/plugins/modules/proxmox_zone.py @@ -9,9 +9,6 @@ __metaclass__ = type -# from ansible_collections.community.sap_libs.plugins.modules.sap_control_exec import choices -# from pygments.lexer import default - DOCUMENTATION = r""" module: proxmox_zone short_description: Manage Proxmox zone configurations @@ -166,22 +163,6 @@ """ EXAMPLES = r""" -- name: Get all zones - community.proxmox.proxmox_zone: - 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: - api_user: "root@pam" - api_password: "{{ vault.proxmox.root_password }}" - api_host: "{{ pc.proxmox.api_host }}" - validate_certs: no - type: simple - register: zones - - name: create a simple zones community.proxmox.proxmox_zone: api_user: "root@pam" @@ -233,56 +214,13 @@ type: str sample: test -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, - ansible_to_proxmox_bool, - ProxmoxAnsible + ansible_to_proxmox_bool ) @@ -336,21 +274,21 @@ def get_ansible_module(): ) -class ProxmoxZoneAnsible(ProxmoxAnsible): +class ProxmoxZoneAnsible(ProxmoxSdnAnsible): def __init__(self, module): super(ProxmoxZoneAnsible, self).__init__(module) self.params = module.params def validate_params(self): - type = self.params.get('type') + zone_type = self.params.get('type') if self.params.get('state') in ['present', 'update']: - if type == 'vlan': + if zone_type == 'vlan': return self.params.get('bridge') - elif type == 'qinq': + elif zone_type == 'qinq': return self.params.get('tag') and self.params.get('vlan_protocol') - elif type == 'vxlan': + elif zone_type == 'vxlan': return self.params.get('fabric') - elif type == 'evpn': + elif zone_type == 'evpn': return self.params.get('controller') and self.params.get('vrf_vxlan') else: return True @@ -360,7 +298,7 @@ def validate_params(self): def run(self): state = self.params.get("state") force = self.params.get("force") - type = self.params['type'] + zone_type = self.params.get('type') if not self.validate_params(): required_params = { @@ -370,7 +308,7 @@ def run(self): 'evpn': ['controller', 'vrf_vxlan'] } self.module.fail_json( - msg=f'to create zone of type {type} it needs - {required_params[type]}' + msg=f'to create zone of type {zone_type} it needs - {required_params[zone_type]}' ) zone_params = { @@ -419,56 +357,48 @@ def run(self): ) else: zones = self.get_zones( - type=self.params.get('type') + zone_type=self.params.get('type') ) self.module.exit_json( changed=False, zones=zones, msg="Successfully retrieved zone info." ) - def get_zones(self, type=None): - try: - return self.proxmox_api.cluster().sdn().zones().get(type=type) - except Exception as e: - self.module.fail_json( - msg=f'Failed to retrieve zone information from cluster: {e}' - ) - def zone_present(self, force, **kwargs): available_zones = {x.get('zone'): {'type': x.get('type'), 'digest': x.get('digest')} for x in self.get_zones()} - zone = kwargs.get("zone") - type = kwargs.get("type") + zone_name = kwargs.get("zone") + zone_type = kwargs.get("type") lock = kwargs.get('lock-token') # Check if zone already exists - if zone in available_zones.keys() and force: - if type != available_zones[zone]['type']: + if zone_name in available_zones.keys() and force: + if zone_type != available_zones[zone_name]['type']: self.release_lock(lock) self.module.fail_json( - msg=f'zone {zone} exists with different type and we cannot change type post fact.' + msg=f'zone {zone_name} exists with different type and we cannot change type post fact.' ) else: self.zone_update(**kwargs) - elif zone in available_zones.keys() and not force: + elif zone_name in available_zones.keys() and not force: self.release_lock(lock) self.module.exit_json( - changed=False, zone=zone, msg=f'Zone {zone} already exists and force is false!' + changed=False, zone=zone_name, msg=f'Zone {zone_name} already exists and force is false!' ) else: try: self.proxmox_api.cluster().sdn().zones().post(**kwargs) self.apply_sdn_changes_and_release_lock(lock) self.module.exit_json( - changed=True, zone=zone, msg=f'Created new Zone - {zone}' + changed=True, zone=zone_name, msg=f'Created new Zone - {zone_name}' ) except Exception as e: self.rollback_sdn_changes_and_release_lock(lock) self.module.fail_json( - msg=f'Failed to create zone {zone}' + msg=f'Failed to create zone {zone_name} - {e}' ) def zone_update(self, **kwargs): available_zones = {x.get('zone'): {'type': x.get('type'), 'digest': x.get('digest')} for x in self.get_zones()} - type = kwargs.get("type") + zone_type = kwargs.get("type") zone_name = kwargs.get("zone") lock = kwargs.get('lock-token') @@ -476,7 +406,7 @@ def zone_update(self, **kwargs): # If zone is not present create it if zone_name not in available_zones.keys(): self.zone_present(force=False, **kwargs) - elif type == available_zones[zone_name]['type']: + elif zone_type == available_zones[zone_name]['type']: del kwargs['type'] del kwargs['zone'] kwargs['digest'] = available_zones[zone_name]['digest'] diff --git a/plugins/modules/proxmox_zone_info.py b/plugins/modules/proxmox_zone_info.py new file mode 100644 index 00000000..f57af801 --- /dev/null +++ b/plugins/modules/proxmox_zone_info.py @@ -0,0 +1,145 @@ +#!/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: 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, + ) + + +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() From 57417960bdad578263b14ebaf64fd2171461a84b Mon Sep 17 00:00:00 2001 From: Jana Hoch Date: Sun, 14 Sep 2025 15:12:45 +0530 Subject: [PATCH 17/19] proxmox_zone: Merge statepresent and update --- plugins/modules/proxmox_zone.py | 91 +++++++++------------------------ 1 file changed, 25 insertions(+), 66 deletions(-) diff --git a/plugins/modules/proxmox_zone.py b/plugins/modules/proxmox_zone.py index f151d07a..98a6781b 100644 --- a/plugins/modules/proxmox_zone.py +++ b/plugins/modules/proxmox_zone.py @@ -29,12 +29,11 @@ - present - absent - update - force: + update: description: - If state is present and zone exists it'll update. - - If state is update and zone doesn't exists it'll create new zone type: bool - default: false + default: true type: description: - Specify the type of zone. @@ -184,17 +183,6 @@ state: present bridge: vmbr0 -- name: update 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: vlan - zone: ansible - state: update - mtu: 1200 - - name: Delete a zones community.proxmox.proxmox_zone: api_user: "root@pam" @@ -226,8 +214,8 @@ def get_proxmox_args(): return dict( - state=dict(type="str", choices=["present", "absent", "update"], required=False), - force=dict(type="bool", default=False, required=False), + state=dict(type="str", choices=["present", "absent"], required=True), + update=dict(type="bool", default=True), type=dict(type="str", choices=["evpn", "faucet", "qinq", "simple", "vlan", "vxlan"], required=False), @@ -281,7 +269,7 @@ def __init__(self, module): def validate_params(self): zone_type = self.params.get('type') - if self.params.get('state') in ['present', 'update']: + if self.params.get('state') == 'present': if zone_type == 'vlan': return self.params.get('bridge') elif zone_type == 'qinq': @@ -296,8 +284,8 @@ def validate_params(self): return True def run(self): - state = self.params.get("state") - force = self.params.get("force") + state = self.params.get('state') + update = self.params.get('update') zone_type = self.params.get('type') if not self.validate_params(): @@ -345,40 +333,43 @@ def run(self): zone_params['lock-token'] = self.get_global_sdn_lock() if state == "present": - self.zone_present(force, **zone_params) - - elif state == "update": - self.zone_update(**zone_params) + self.zone_present(update, **zone_params) elif state == "absent": self.zone_absent( zone_name=zone_params.get('zone'), lock=zone_params.get('lock-token') ) - else: - zones = self.get_zones( - zone_type=self.params.get('type') - ) - self.module.exit_json( - changed=False, zones=zones, msg="Successfully retrieved zone info." - ) - def zone_present(self, force, **kwargs): + 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") lock = kwargs.get('lock-token') # Check if zone already exists - if zone_name in available_zones.keys() and force: + if zone_name in available_zones.keys() and update: if zone_type != available_zones[zone_name]['type']: self.release_lock(lock) self.module.fail_json( msg=f'zone {zone_name} exists with different type and we cannot change type post fact.' ) else: - self.zone_update(**kwargs) - elif zone_name in available_zones.keys() and not force: + try: + kwargs['digest'] = available_zones[zone_name]['digest'] + zone = getattr(self.proxmox_api.cluster().sdn().zones(), zone_name) + zone.put(**kwargs) + self.apply_sdn_changes_and_release_lock(lock) + 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(lock) + self.module.fail_json( + msg=f'Failed to update zone {zone_name} - {e}' + ) + + elif zone_name in available_zones.keys() and not update: self.release_lock(lock) self.module.exit_json( changed=False, zone=zone_name, msg=f'Zone {zone_name} already exists and force is false!' @@ -396,38 +387,6 @@ def zone_present(self, force, **kwargs): msg=f'Failed to create zone {zone_name} - {e}' ) - def zone_update(self, **kwargs): - available_zones = {x.get('zone'): {'type': x.get('type'), 'digest': x.get('digest')} for x in self.get_zones()} - zone_type = kwargs.get("type") - zone_name = kwargs.get("zone") - lock = kwargs.get('lock-token') - - try: - # If zone is not present create it - if zone_name not in available_zones.keys(): - self.zone_present(force=False, **kwargs) - elif zone_type == available_zones[zone_name]['type']: - del kwargs['type'] - del kwargs['zone'] - kwargs['digest'] = available_zones[zone_name]['digest'] - - zone = getattr(self.proxmox_api.cluster().sdn().zones(), zone_name) - zone.put(**kwargs) - self.apply_sdn_changes_and_release_lock(lock) - self.module.exit_json( - changed=True, zone=zone_name, msg=f'Updated zone {zone_name}' - ) - else: - self.release_lock(lock) - self.module.fail_json( - msg=f'zone {zone_name} already exists with different type' - ) - except Exception as e: - self.rollback_sdn_changes_and_release_lock(lock) - self.module.fail_json( - msg=f'Failed to update zone {e}' - ) - def zone_absent(self, zone_name, lock): available_zones = [x.get('zone') for x in self.get_zones()] params = {'lock-token': lock} From 0cf6a3b046298b09bab943194f5fa3ebf4ad7ac7 Mon Sep 17 00:00:00 2001 From: Jana Hoch Date: Sun, 14 Sep 2025 15:22:28 +0530 Subject: [PATCH 18/19] proxmox_zone: Get lock just before making changes - also fix minor issues in proxmox_zone,proxmox_zone_info, proxmox_sdn. --- meta/runtime.yml | 3 +- plugins/module_utils/proxmox_sdn.py | 11 ------ plugins/modules/proxmox_zone.py | 57 ++++++++++++---------------- plugins/modules/proxmox_zone_info.py | 5 ++- 4 files changed, 30 insertions(+), 46 deletions(-) diff --git a/meta/runtime.yml b/meta/runtime.yml index dd45c3d4..a851eb64 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -35,4 +35,5 @@ action_groups: - proxmox_user - proxmox_user_info - proxmox_vm_info - - proxmox_zone \ No newline at end of file + - proxmox_zone + - proxmox_zone_info diff --git a/plugins/module_utils/proxmox_sdn.py b/plugins/module_utils/proxmox_sdn.py index cc9c8d16..3ed65854 100644 --- a/plugins/module_utils/proxmox_sdn.py +++ b/plugins/module_utils/proxmox_sdn.py @@ -8,19 +8,8 @@ __metaclass__ = type -import traceback from typing import List, Dict -PROXMOXER_IMP_ERR = None -try: - from proxmoxer import ProxmoxAPI - from proxmoxer import __version__ as proxmoxer_version - - HAS_PROXMOXER = True -except ImportError: - HAS_PROXMOXER = False - PROXMOXER_IMP_ERR = traceback.format_exc() - from ansible_collections.community.proxmox.plugins.module_utils.proxmox import ( ansible_to_proxmox_bool, ProxmoxAnsible diff --git a/plugins/modules/proxmox_zone.py b/plugins/modules/proxmox_zone.py index 98a6781b..b0f88df8 100644 --- a/plugins/modules/proxmox_zone.py +++ b/plugins/modules/proxmox_zone.py @@ -11,9 +11,9 @@ DOCUMENTATION = r""" module: proxmox_zone -short_description: Manage Proxmox zone configurations +short_description: Manage Proxmox zone configurations. description: - - list/create/update/delete proxmox sdn zones + - Create/Update/Delete proxmox sdn zones. author: 'Jana Hoch (!UNKNOWN)' attributes: check_mode: @@ -28,10 +28,10 @@ choices: - present - absent - - update + default: present update: description: - - If state is present and zone exists it'll update. + - If O(state=present) and zone exists it'll update. type: bool default: true type: @@ -77,11 +77,11 @@ type: bool dns: description: - - dns api server. + - DNS api server. type: str dnszone: description: - - dns domain zone. + - DNS domain zone. type: str dp_id: description: @@ -105,12 +105,7 @@ type: str ipam: description: - - use a specific ipam. - type: str - lock_token: - description: - - the token for unlocking the global SDN configuration. If not provided it will generate new token - - If the playbook fails for some reason you can manually clear lock token by deleting `/etc/pve/sdn/.lock` + - Use a specific ipam. type: str mac: description: @@ -214,7 +209,7 @@ def get_proxmox_args(): return dict( - state=dict(type="str", choices=["present", "absent"], required=True), + 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"], @@ -234,7 +229,6 @@ def get_proxmox_args(): exitnodes_primary=dict(type="str", required=False), fabric=dict(type="str", required=False), ipam=dict(type="str", required=False), - lock_token=dict(type="str", required=False, no_log=False), mac=dict(type="str", required=False), mtu=dict(type="int", required=False), nodes=dict(type="str", required=False), @@ -256,7 +250,6 @@ def get_ansible_module(): argument_spec=module_args, required_if=[ ('state', 'present', ['type', 'zone']), - ('state', 'update', ['type', 'zone']), ('state', 'absent', ['zone']) ] ) @@ -316,7 +309,7 @@ def run(self): "exitnodes-primary": self.params.get("exitnodes_primary"), "fabric": self.params.get("fabric"), "ipam": self.params.get("ipam"), - "lock-token": self.params.get("lock_token"), + "lock-token": None, "mac": self.params.get("mac"), "mtu": self.params.get("mtu"), "nodes": self.params.get("nodes"), @@ -329,9 +322,6 @@ def run(self): "vxlan-port": self.params.get("vxlan_port"), } - if zone_params['lock-token'] is None and state is not None: - zone_params['lock-token'] = self.get_global_sdn_lock() - if state == "present": self.zone_present(update, **zone_params) @@ -345,67 +335,70 @@ 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") - lock = kwargs.get('lock-token') # Check if zone already exists if zone_name in available_zones.keys() and update: if zone_type != available_zones[zone_name]['type']: - self.release_lock(lock) 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(lock) + 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(lock) + 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.release_lock(lock) self.module.exit_json( - changed=False, zone=zone_name, msg=f'Zone {zone_name} already exists and force is false!' + 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(lock) + 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(lock) + 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): + 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.release_lock(lock) self.module.exit_json( - changed=False, zone=zone_name, msg=f"zone {zone_name} already doesn't exist." + 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(lock) + 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(lock) + 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.' ) diff --git a/plugins/modules/proxmox_zone_info.py b/plugins/modules/proxmox_zone_info.py index f57af801..1d92c417 100644 --- a/plugins/modules/proxmox_zone_info.py +++ b/plugins/modules/proxmox_zone_info.py @@ -10,7 +10,7 @@ __metaclass__ = type DOCUMENTATION = r""" -module: proxmox_zone +module: proxmox_zone_info short_description: Get Proxmox zone info. description: - List all available zones. @@ -18,7 +18,7 @@ options: type: description: - - Filter zones on based on type + - Filter zones on based on type. type: str choices: - evpn @@ -114,6 +114,7 @@ def get_ansible_module(): module_args.update(get_proxmox_args()) return AnsibleModule( argument_spec=module_args, + supports_check_mode=True, ) From 5fef6692132a664f3e0c4c9e376bebc0a764a85c Mon Sep 17 00:00:00 2001 From: Jana Hoch Date: Sun, 14 Sep 2025 20:21:34 +0530 Subject: [PATCH 19/19] Update unit tests for proxmox_zone & proxmox_zone_info --- .../unit/plugins/modules/test_proxmox_zone.py | 48 +++----- .../plugins/modules/test_proxmox_zone_info.py | 112 ++++++++++++++++++ 2 files changed, 131 insertions(+), 29 deletions(-) create mode 100644 tests/unit/plugins/modules/test_proxmox_zone_info.py diff --git a/tests/unit/plugins/modules/test_proxmox_zone.py b/tests/unit/plugins/modules/test_proxmox_zone.py index 36c100f1..ca0b98f1 100644 --- a/tests/unit/plugins/modules/test_proxmox_zone.py +++ b/tests/unit/plugins/modules/test_proxmox_zone.py @@ -67,15 +67,15 @@ def get_module_args_state_none(): } -def get_module_args_zone(type, zone, state='present', force=False, bridge=None): +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': type, + 'type': zone_type, 'zone': zone, 'state': state, - 'force': force, + 'update': update, 'bridge': bridge } @@ -98,60 +98,50 @@ def setUp(self): def tearDown(self): self.connect_mock.stop() - # self.mock_module_helper.stop() self.exit_json_patcher.stop() self.fail_json_patcher.stop() super(TestProxmoxZoneModule, 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 - def test_zone_present(self): # Create new Zone with pytest.raises(SystemExit) as exc_info: - with set_module_args(get_module_args_zone(type='simple', zone='test')): + 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' - # Zone Already exists without force + # Update the zone with pytest.raises(SystemExit) as exc_info: - with set_module_args(get_module_args_zone(type='simple', zone='test1')): + 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 False - assert result["msg"] == 'Zone test1 already exists and force is false!' + assert result["changed"] is True + assert result["msg"] == "Updated zone - test1" assert result['zone'] == 'test1' - # Zone Already exists with force and different type + # Zone Already exists update=False with pytest.raises(SystemExit) as exc_info: - with set_module_args(get_module_args_zone(type='vlan', zone='test1', force=True, bridge='test')): + 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 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.' + assert result["changed"] is False + assert result["msg"] == 'Zone test1 already exists and update is false!' + assert result['zone'] == 'test1' - def test_zone_update(self): + # Zone Already exists with update=True with pytest.raises(SystemExit) as exc_info: - with set_module_args(get_module_args_zone(type='simple', zone='test1', state='update')): + 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 result["changed"] is True - assert result["msg"] == "Updated zone test1" - assert result['zone'] == 'test1' + 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(type='simple', zone='test1', state='absent')): + 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 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