diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index c92ce76034..fa0a057533 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -1154,6 +1154,8 @@ files: maintainers: IamLunchbox $modules/proxmox_backup_info.py: maintainers: raoufnezhad mmayabi + $modules/proxmox_backup_schedule.py: + maintainers: raoufnezhad mmayabi $modules/proxmox_nic.py: maintainers: Kogelvis krauthosting $modules/proxmox_node_info.py: diff --git a/meta/runtime.yml b/meta/runtime.yml index 6499587af7..c7d5f5c623 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -18,6 +18,7 @@ action_groups: - proxmox - proxmox_backup - proxmox_backup_info + - proxmox_backup_schedule - proxmox_disk - proxmox_domain_info - proxmox_group_info diff --git a/plugins/modules/proxmox_backup_schedule.py b/plugins/modules/proxmox_backup_schedule.py new file mode 100644 index 0000000000..7a4c11acd0 --- /dev/null +++ b/plugins/modules/proxmox_backup_schedule.py @@ -0,0 +1,264 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2025 Marzieh Raoufnezhad +# Copyright (c) 2025 Maryam Mayabi +# 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 = """ +--- +module: proxmox_backup_schedule + +short_description: Schedule VM backups and removing them + +version_added: 10.5.0 + +description: The module modifies backup jobs such as set or delete C(vmid). + +author: + - "Marzieh Raoufnezhad (@raoufnezhad) " + - "Maryam Mayabi (@mmayabi) " + +options: + vm_name: + description: + - The name of the Proxmox VM. + - Mutually exclusive with O(vm_id). + type: str + vm_id: + description: + - The ID of the Proxmox VM. + - Mutually exclusive with O(vm_name). + type: str + backup_id: + description: The backup job ID. + type: str + state: + description: + - If V(present), the module will update backup job with new VM ID. + - If V(absent), the module will remove the VM ID from all backup jobs where the VM ID has existed. + required: true + choices: ["present", "absent"] + type: str + +extends_documentation_fragment: + - community.general.proxmox.documentation + - community.general.attributes + - community.general.attributes.info_module + - community.general.proxmox.actiongroup_proxmox +""" + +EXAMPLES = """ +- name: Scheduling VM Backups based on VM name + proxmox_backup_schedule: + api_user: 'myUser@pam' + api_password: '*******' + api_host: '192.168.20.20' + vm_name: 'VM Name' + backup_id: 'backup-b2adffdc-316e' + state: 'present' + +- name: Scheduling VM Backups based on VM ID + proxmox_backup_schedule: + api_user: 'myUser@pam' + api_password: '*******' + api_host: '192.168.20.20' + vm_id: 'VM ID' + backup_id: 'backup-b2adffdc-316e' + state: 'present' + +- name: Removing backup setting based on VM name + proxmox_backup_schedule: + api_user: 'myUser@pam' + api_password: '*******' + api_host: '192.168.20.20' + vm_name: 'VM Name' + state: 'absent' + +- name: Removing backup setting based on VM ID + proxmox_backup_schedule: + api_user: 'myUser@pam' + api_password: '*******' + api_host: '192.168.20.20' + vm_id: 'VM ID' + state: 'absent' + +- name: Removing backup setting based on VM name from specific backup job + proxmox_backup_schedule: + api_user: 'myUser@pam' + api_password: '*******' + api_host: '192.168.20.20' + vm_name: 'VM Name' + backup_id: 'backup-b2adffdc-316e' + state: 'absent' + +- name: Removing backup setting based on VM ID from specific backup job + proxmox_backup_schedule: + api_user: 'myUser@pam' + api_password: '*******' + api_host: '192.168.20.20' + vm_id: 'VM ID' + backup_id: 'backup-b2adffdc-316e' + state: 'absent' +""" + +RETURN = """ +--- +backup_schedule: + description: + - If V(present), the backup_schedule will return True after adding the VM ID to the backup job. + - If V(absent), the backup_schedule will return True after removing it from the backup job. + returned: always, but can be empty + type: raw +""" + +from ansible.module_utils.basic import AnsibleModule, missing_required_lib +from ansible_collections.community.general.plugins.module_utils.proxmox import ( + proxmox_auth_argument_spec, ProxmoxAnsible, HAS_PROXMOXER, PROXMOXER_IMP_ERR) + + +class ProxmoxSetVMBackupAnsible(ProxmoxAnsible): + # Getting backup sections + def get_cluster_bklist(self): + try: + backupSections = self.proxmox_api.cluster.backup.get() + except Exception as e: + self.module.fail_json(msg="Getting backup sections failed: %s" % e) + return backupSections + + def get_cluster_specific_bkjobid(self, backup_id): + try: + specificBackupID = self.proxmox_api.cluster.backup.get(backup_id) + except Exception as e: + self.module.fail_json(msg="Getting specific backup ID failed: %s" % e) + return specificBackupID + + def set_vmid_backup(self, backup_id, bk_id_vmids): + try: + self.proxmox_api.cluster.backup.put(backup_id, vmid=bk_id_vmids) + except Exception as e: + self.module.fail_json(msg="Setting vmid backup failed: %s" % e) + return + + def get_vms_list(self): + try: + vms = self.proxmox_api.cluster.resources.get(type='vm') + except Exception as e: + self.module.fail_json(msg="Getting vms info from cluster failed: %s" % e) + return vms + + # convert vm name to vm ID + def vmname_2_vmid(self, vmname): + vmInfo = self.get_vms_list() + vms = [vm for vm in vmInfo if vm['name'] == vmname] + return (vms[0]['vmid']) + + # add vmid to backup job + def backup_present(self, vm_id, backup_id): + bk_id_info = self.get_cluster_specific_bkjobid(backup_id) + + # If bk_id_info is a list, get the first item (assuming there's only one backup job returned) + if isinstance(bk_id_info, list): + bk_id_info = bk_id_info[0] # Access the first item in the list + vms_id = bk_id_info['vmid'].split(',') + if str(vm_id) not in vms_id: + bk_id_vmids = bk_id_info['vmid'] + ',' + str(vm_id) + self.set_vmid_backup(backup_id, bk_id_vmids) + return True + else: + return False + + # delete vmid from backup job + def backup_absent(self, vm_id, backup_id): + if backup_id: + bk_id_info = self.get_cluster_specific_bkjobid(backup_id) + if isinstance(bk_id_info, list): + bk_id_info = bk_id_info[0] # Access the first item in the list + vmids = bk_id_info['vmid'].split(',') + if str(vm_id) in vmids: + if len(vmids) > 1: + vmids.remove(str(vm_id)) + new_vmids = ','.join(map(str, vmids)) + self.set_vmid_backup(bk_id_info['id'], new_vmids) + return True + else: + self.module.fail_json(msg="No more than one vmid is assigned to %s. You just can remove job." % bk_id_info['id']) + return False + else: + bkID_delvm = [] + backupList = self.get_cluster_bklist() + for backupItem in backupList: + vmids = list(backupItem['vmid'].split(',')) + if str(vm_id) in vmids: + if len(vmids) > 1: + vmids.remove(str(vm_id)) + new_vmids = ','.join(map(str, vmids)) + self.set_vmid_backup(backupItem['id'], new_vmids) + bkID_delvm.append(backupItem['id']) + else: + self.module.fail_json(msg="No more than one vmid is assigned to %s. You just can remove job." % backupItem['id']) + if bkID_delvm: + return True + else: + return False + + +# main function +def main(): + # Define module args + args = proxmox_auth_argument_spec() + backup_schedule_args = dict( + vm_name=dict(type='str'), + vm_id=dict(type='str'), + backup_id=dict(type='str'), + state=dict(choices=['present', 'absent'], required=True) + ) + args.update(backup_schedule_args) + + module = AnsibleModule( + argument_spec=args, + mutually_exclusive=[('vm_id', 'vm_name')], + supports_check_mode=True + ) + + # Define (init) result value + result = dict( + changed=False, + message='' + ) + + # Check if proxmoxer exist + if not HAS_PROXMOXER: + module.fail_json(msg=missing_required_lib('proxmoxer'), exception=PROXMOXER_IMP_ERR) + + # Start to connect to proxmox to get backup data + proxmox = ProxmoxSetVMBackupAnsible(module) + vm_name = module.params['vm_name'] + vm_id = module.params['vm_id'] + backup_id = module.params['backup_id'] + state = module.params['state'] + + if vm_name: + vm_id = proxmox.vmname_2_vmid(vm_name) + + if state == 'present': + result['backup_schedule'] = proxmox.backup_present(vm_id, backup_id) + + if state == 'absent': + result['backup_schedule'] = proxmox.backup_absent(vm_id, backup_id) + + if result['backup_schedule']: + result['changed'] = True + result['message'] = 'The backup schedule has been changed successfully.' + else: + result['message'] = 'The backup schedule did not change anything.' + + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/tests/unit/plugins/modules/test_proxmox_backup_schedule.py b/tests/unit/plugins/modules/test_proxmox_backup_schedule.py new file mode 100644 index 0000000000..d130175ed2 --- /dev/null +++ b/tests/unit/plugins/modules/test_proxmox_backup_schedule.py @@ -0,0 +1,226 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2025 Marzieh Raoufnezhad +# Copyright (c) 2025 Maryam Mayabi +# 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 sys +import pytest + +proxmoxer = pytest.importorskip("proxmoxer") +mandatory_py_version = pytest.mark.skipif( + sys.version_info < (2, 7), + reason="The proxmoxer dependency requires python2.7 or higher", +) + +from ansible_collections.community.general.plugins.modules import proxmox_backup_schedule +from ansible_collections.community.internal_test_tools.tests.unit.compat.mock import patch +from ansible_collections.community.internal_test_tools.tests.unit.plugins.modules.utils import ( + AnsibleExitJson, + AnsibleFailJson, + ModuleTestCase, + set_module_args, +) +import ansible_collections.community.general.plugins.module_utils.proxmox as proxmox_utils + +RESOURCE_LIST = [ + { + "uptime": 0, + "diskwrite": 0, + "name": "test01", + "maxcpu": 0, + "node": "NODE1", + "mem": 0, + "netout": 0, + "netin": 0, + "maxmem": 0, + "diskread": 0, + "disk": 0, + "maxdisk": 0, + "status": "running", + "cpu": 0, + "id": "qemu/100", + "template": 0, + "vmid": 100, + "type": "qemu" + }, + { + "uptime": 0, + "diskwrite": 0, + "name": "test02", + "maxcpu": 0, + "node": "NODE1", + "mem": 0, + "netout": 0, + "netin": 0, + "maxmem": 0, + "diskread": 0, + "disk": 0, + "maxdisk": 0, + "status": "running", + "cpu": 0, + "id": "qemu/101", + "template": 0, + "vmid": 101, + "type": "qemu" + }, + { + "uptime": 0, + "diskwrite": 0, + "name": "test03", + "maxcpu": 0, + "node": "NODE2", + "mem": 0, + "netout": 0, + "netin": 0, + "maxmem": 0, + "diskread": 0, + "disk": 0, + "maxdisk": 0, + "status": "running", + "cpu": 0, + "id": "qemu/102", + "template": 0, + "vmid": 102, + "type": "qemu" + }, + { + "uptime": 0, + "diskwrite": 0, + "name": "test04", + "maxcpu": 0, + "node": "NODE3", + "mem": 0, + "netout": 0, + "netin": 0, + "maxmem": 0, + "diskread": 0, + "disk": 0, + "maxdisk": 0, + "status": "running", + "cpu": 0, + "id": "qemu/103", + "template": 0, + "vmid": 103, + "type": "qemu" + }, + { + "uptime": 0, + "diskwrite": 0, + "name": "test05", + "maxcpu": 0, + "node": "NODE3", + "mem": 0, + "netout": 0, + "netin": 0, + "maxmem": 0, + "diskread": 0, + "disk": 0, + "maxdisk": 0, + "status": "running", + "cpu": 0, + "id": "qemu/105", + "template": 0, + "vmid": 105, + "type": "qemu" + } +] + +BACKUP_JOBS = [ + { + "type": "vzdump", + "id": "backup-001", + "storage": "local", + "vmid": "100,101", + "enabled": 1, + "next-run": 1735138800, + "mailnotification": "always", + "schedule": "06,18:30", + "mode": "snapshot", + "notes-template": "{{guestname}}" + }, + { + "schedule": "sat 15:00", + "notes-template": "{{guestname}}", + "mode": "snapshot", + "mailnotification": "always", + "next-run": 1735385400, + "type": "vzdump", + "enabled": 1, + "vmid": "101,102,103", + "storage": "local", + "id": "backup-002", + } +] + +EXPECTED_UPDATE_BACKUP_SCHEDULE = True +EXPECTED_DEL_BACKUP_SCHEDULE = True + + +class TestProxmoxBackupScheduleModule(ModuleTestCase): + def setUp(self): + super(TestProxmoxBackupScheduleModule, self).setUp() + proxmox_utils.HAS_PROXMOXER = True + self.module = proxmox_backup_schedule + self.connect_mock = patch( + "ansible_collections.community.general.plugins.module_utils.proxmox.ProxmoxAnsible._connect", + ).start() + self.connect_mock.return_value.cluster.resources.get.return_value = ( + RESOURCE_LIST + ) + self.connect_mock.return_value.cluster.backup.get.side_effect = ( + lambda backup_id=None: BACKUP_JOBS if backup_id is None else [job for job in BACKUP_JOBS if job['id'] == backup_id] + ) + + def tearDown(self): + self.connect_mock.stop() + super(TestProxmoxBackupScheduleModule, self).tearDown() + + def test_module_fail_when_required_args_missing(self): + with pytest.raises(AnsibleFailJson) as exc_info: + set_module_args({}) + self.module.main() + + result = exc_info.value.args[0] + assert result["msg"] == "missing required arguments: api_host, api_user, state" + + def test_update_vmid_in_backup(self): + with pytest.raises(AnsibleExitJson) as exc_info: + set_module_args({ + 'api_host': 'proxmoxhost', + 'api_user': 'root@pam', + 'api_password': 'supersecret', + 'vm_name': 'test05', + 'backup_id': 'backup-001', + 'state': 'present' + }) + self.module.main() + + result = exc_info.value.args[0] + + assert result['changed'] is True + assert result['backup_schedule'] == EXPECTED_UPDATE_BACKUP_SCHEDULE + + def test_delete_vmid_from_backup(self): + with pytest.raises(AnsibleExitJson) as exc_info: + set_module_args({ + 'api_host': 'proxmoxhost', + 'api_user': 'root@pam', + 'api_password': 'supersecret', + 'vm_id': 101, + 'state': 'absent' + }) + self.module.main() + + result = exc_info.value.args[0] + assert result['changed'] is True + assert result['backup_schedule'] == EXPECTED_DEL_BACKUP_SCHEDULE + + +if __name__ == '__main__': + pytest.main([__file__])