From 7777b48c99c1a5ef5645fd42b8b50ab6182eef5d Mon Sep 17 00:00:00 2001 From: castorsky Date: Mon, 19 Sep 2022 04:06:21 +0800 Subject: [PATCH] New module: Proxmox disk management (#5101) * New module: Proxmox disk management * Remove misplaced option * Type missed * Fixed docs, quotes, 2.7 syntax * Forgotten comma * Version added 5.5.0 Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com> * Italic options Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com> * Missed dot Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com> * Pythonify python Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com> * Shorten command Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com> * Action parameter drop. General improvements. * Add proxmox_disk integration testing * Shorten getting vmid Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com> * Code tag for value Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com> * Italic tag for option Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com> * Definite ID of the VM Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com> * Docs edit and loop condition * Simplify conditions Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com> * Dropped bps options, added idempotency checks * Documentaion edit * Rewrite create/import condition * Trainling comma Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com> * Added type field to all choosable arguments * Description of disk bus ranges * Fix imports * Update version Co-authored-by: Felix Fontein * Lowercase YAML boolean * Rename grown to resized and update documentation * Documentation updated before actual changes * Added 'update' flag for 'present' state * Traling space * YAML indentation * Merged 'updated' option into 'present'. * Doc update. * Exclude 'import_from' on update * Version bump * yaml boolean lowercase Co-authored-by: Felix Fontein * yaml boolean lowercase Co-authored-by: Felix Fontein * More detailed description Co-authored-by: Felix Fontein Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com> Co-authored-by: Felix Fontein --- .github/BOTMETA.yml | 2 + meta/runtime.yml | 2 + plugins/modules/cloud/misc/proxmox_disk.py | 744 ++++++++++++++++++ .../targets/proxmox/tasks/main.yml | 196 +++++ 4 files changed, 944 insertions(+) create mode 100644 plugins/modules/cloud/misc/proxmox_disk.py diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index a75397852f..1e2fc66a19 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -378,6 +378,8 @@ files: $modules/cloud/misc/proxmox_template.py: maintainers: UnderGreen ignore: skvidal + $modules/cloud/misc/proxmox_disk.py: + maintainers: castorsky $modules/cloud/misc/rhevm.py: maintainers: $team_virt TimothyVandenbrande labels: rhevm virt diff --git a/meta/runtime.yml b/meta/runtime.yml index a7578eea22..c93f00d760 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -1213,6 +1213,8 @@ plugin_routing: redirect: community.general.cloud.profitbricks.profitbricks_volume_attachments proxmox: redirect: community.general.cloud.misc.proxmox + proxmox_disk: + redirect: community.general.cloud.misc.proxmox_disk proxmox_domain_info: redirect: community.general.cloud.misc.proxmox_domain_info proxmox_group_info: diff --git a/plugins/modules/cloud/misc/proxmox_disk.py b/plugins/modules/cloud/misc/proxmox_disk.py new file mode 100644 index 0000000000..182a0d25f2 --- /dev/null +++ b/plugins/modules/cloud/misc/proxmox_disk.py @@ -0,0 +1,744 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2022, Castor Sky (@castorsky) +# GNU General Public License v3.0+ (see COPYING 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_disk +short_description: Management of a disk of a Qemu(KVM) VM in a Proxmox VE cluster. +version_added: 5.7.0 +description: + - Allows you to perform some supported operations on a disk in Qemu(KVM) Virtual Machines in a Proxmox VE cluster. +author: "Castor Sky (@castorsky) " +options: + name: + description: + - The unique name of the VM. + - You can specify either I(name) or I(vmid) or both of them. + type: str + vmid: + description: + - The unique ID of the VM. + - You can specify either I(vmid) or I(name) or both of them. + type: int + disk: + description: + - The disk key (C(unused[n]), C(ide[n]), C(sata[n]), C(scsi[n]) or C(virtio[n])) you want to operate on. + - Disk buses (IDE, SATA and so on) have fixed ranges of C(n) that accepted by Proxmox API. + - > + For IDE: 0-3; + for SCSI: 0-30; + for SATA: 0-5; + for VirtIO: 0-15; + for Unused: 0-255. + type: str + required: true + state: + description: + - Indicates desired state of the disk. + - > + I(state=present) can be used to create, replace disk or update options in existing disk. It will create missing + disk or update options in existing one by default. See the I(create) parameter description to control behavior + of this option. + - Some updates on options (like I(cache)) are not being applied instantly and require VM restart. + - > + Use I(state=detached) to detach existing disk from VM but do not remove it entirely. + When I(state=detached) and disk is C(unused[n]) it will be left in same state (not removed). + - > + I(state=moved) may be used to change backing storage for the disk in bounds of the same VM + or to send the disk to another VM (using the same backing storage). + - > + I(state=resized) intended to change the disk size. As of Proxmox 7.2 you can only increase the disk size + because shrinking disks is not supported by the PVE API and has to be done manually. + - To entirely remove the disk from backing storage use I(state=absent). + type: str + choices: ['present', 'resized', 'detached', 'moved', 'absent'] + default: present + create: + description: + - With I(create) flag you can control behavior of I(state=present). + - When I(create=disabled) it will not create new disk (if not exists) but will update options in existing disk. + - When I(create=regular) it will either create new disk (if not exists) or update options in existing disk. + - When I(create=forced) it will always create new disk (if disk exists it will be detached and left unused). + type: str + choices: ['disabled', 'regular', 'forced'] + default: regular + storage: + description: + - The drive's backing storage. + - Used only when I(state) is C(present). + type: str + size: + description: + - Desired volume size in GB to allocate when I(state=present) (specify I(size) without suffix). + - > + New (or additional) size of volume when I(state=resized). With the C(+) sign + the value is added to the actual size of the volume + and without it, the value is taken as an absolute one. + type: str + bwlimit: + description: + - Override I/O bandwidth limit (in KB/s). + - Used only when I(state=moved). + type: int + delete_moved: + description: + - Delete the original disk after successful copy. + - By default the original disk is kept as unused disk. + - Used only when I(state=moved). + type: bool + target_disk: + description: + - The config key the disk will be moved to on the target VM (for example, C(ide0) or C(scsi1)). + - Default is the source disk key. + - Used only when I(state=moved). + type: str + target_storage: + description: + - Move the disk to this storage when I(state=moved). + - You can move between storages only in scope of one VM. + - Mutually exclusive with I(target_vmid). + type: str + target_vmid: + description: + - The (unique) ID of the VM where disk will be placed when I(state=moved). + - You can move disk between VMs only when the same storage is used. + - Mutually exclusive with I(target_vmid). + type: int + timeout: + description: + - Timeout in seconds to wait when moving disk. + - Used only when I(state=moved). + type: int + default: 600 + aio: + description: + - AIO type to use. + type: str + choices: ['native', 'threads', 'io_uring'] + backup: + description: + - Whether the drive should be included when making backups. + type: bool + bps_max_length: + description: + - Maximum length of total r/w I/O bursts in seconds. + type: int + bps_rd_max_length: + description: + - Maximum length of read I/O bursts in seconds. + type: int + bps_wr_max_length: + description: + - Maximum length of write I/O bursts in seconds. + type: int + cache: + description: + - The drive's cache mode. + type: str + choices: ['none', 'writethrough', 'writeback', 'unsafe', 'directsync'] + cyls: + description: + - Force the drive's physical geometry to have a specific cylinder count. + type: int + detect_zeroes: + description: + - Control whether to detect and try to optimize writes of zeroes. + type: bool + discard: + description: + - Control whether to pass discard/trim requests to the underlying storage. + type: str + choices: ['ignore', 'on'] + format: + description: + - The drive's backing file's data format. + type: str + choices: ['raw', 'cow', 'qcow', 'qed', 'qcow2', 'vmdk', 'cloop'] + heads: + description: + - Force the drive's physical geometry to have a specific head count. + type: int + import_from: + description: + - Import volume from this existing one. + - Volume string format + - C(:/) or C(/) + - Attention! Only root can use absolute paths. + - This parameter is mutually exclusive with I(size). + type: str + iops: + description: + - Maximum total r/w I/O in operations per second. + - You can specify either total limit or per operation (mutually exclusive with I(iops_rd) and I(iops_wr)). + type: int + iops_max: + description: + - Maximum unthrottled total r/w I/O pool in operations per second. + type: int + iops_max_length: + description: + - Maximum length of total r/w I/O bursts in seconds. + type: int + iops_rd: + description: + - Maximum read I/O in operations per second. + - You can specify either read or total limit (mutually exclusive with I(iops)). + type: int + iops_rd_max: + description: + - Maximum unthrottled read I/O pool in operations per second. + type: int + iops_rd_max_length: + description: + - Maximum length of read I/O bursts in seconds. + type: int + iops_wr: + description: + - Maximum write I/O in operations per second. + - You can specify either write or total limit (mutually exclusive with I(iops)). + type: int + iops_wr_max: + description: + - Maximum unthrottled write I/O pool in operations per second. + type: int + iops_wr_max_length: + description: + - Maximum length of write I/O bursts in seconds. + type: int + iothread: + description: + - Whether to use iothreads for this drive (only for SCSI and VirtIO) + type: bool + mbps: + description: + - Maximum total r/w speed in megabytes per second. + - Can be fractional but use with caution - fractionals less than 1 are not supported officially. + - You can specify either total limit or per operation (mutually exclusive with I(mbps_rd) and I(mbps_wr)). + type: float + mbps_max: + description: + - Maximum unthrottled total r/w pool in megabytes per second. + type: float + mbps_rd: + description: + - Maximum read speed in megabytes per second. + - You can specify either read or total limit (mutually exclusive with I(mbps)). + type: float + mbps_rd_max: + description: + - Maximum unthrottled read pool in megabytes per second. + type: float + mbps_wr: + description: + - Maximum write speed in megabytes per second. + - You can specify either write or total limit (mutually exclusive with I(mbps)). + type: float + mbps_wr_max: + description: + - Maximum unthrottled write pool in megabytes per second. + type: float + media: + description: + - The drive's media type. + type: str + choices: ['cdrom', 'disk'] + queues: + description: + - Number of queues (SCSI only). + type: int + replicate: + description: + - Whether the drive should considered for replication jobs. + type: bool + rerror: + description: + - Read error action. + type: str + choices: ['ignore', 'report', 'stop'] + ro: + description: + - Whether the drive is read-only. + type: bool + scsiblock: + description: + - Whether to use scsi-block for full passthrough of host block device. + - Can lead to I/O errors in combination with low memory or high memory fragmentation on host. + type: bool + secs: + description: + - Force the drive's physical geometry to have a specific sector count. + type: int + serial: + description: + - The drive's reported serial number, url-encoded, up to 20 bytes long. + type: str + shared: + description: + - Mark this locally-managed volume as available on all nodes. + - This option does not share the volume automatically, it assumes it is shared already! + type: bool + snapshot: + description: + - Control qemu's snapshot mode feature. + - If activated, changes made to the disk are temporary and will be discarded when the VM is shutdown. + type: bool + ssd: + description: + - Whether to expose this drive as an SSD, rather than a rotational hard disk. + type: bool + trans: + description: + - Force disk geometry bios translation mode. + type: str + choices: ['auto', 'lba', 'none'] + werror: + description: + - Write error action. + type: str + choices: ['enospc', 'ignore', 'report', 'stop'] + wwn: + description: + - The drive's worldwide name, encoded as 16 bytes hex string, prefixed by C(0x). + type: str +extends_documentation_fragment: + - community.general.proxmox.documentation +''' + +EXAMPLES = ''' +- name: Create new disk in VM (do not rewrite in case it exists already) + community.general.proxmox_disk: + api_host: node1 + api_user: root@pam + api_token_id: token1 + api_token_secret: some-token-data + name: vm-name + disk: scsi3 + backup: true + cache: none + storage: local-zfs + size: 5 + state: present + +- name: Create new disk in VM (force rewrite in case it exists already) + community.general.proxmox_disk: + api_host: node1 + api_user: root@pam + api_token_id: token1 + api_token_secret: some-token-data + vmid: 101 + disk: scsi3 + format: qcow2 + storage: local + size: 16 + create: forced + state: present + +- name: Update existing disk + community.general.proxmox_disk: + api_host: node1 + api_user: root@pam + api_token_id: token1 + api_token_secret: some-token-data + vmid: 101 + disk: ide0 + backup: false + ro: true + aio: native + state: present + +- name: Grow existing disk + community.general.proxmox_disk: + api_host: node1 + api_user: root@pam + api_token_id: token1 + api_token_secret: some-token-data + vmid: 101 + disk: sata4 + size: +5G + state: resized + +- name: Detach disk (leave it unused) + community.general.proxmox_disk: + api_host: node1 + api_user: root@pam + api_token_id: token1 + api_token_secret: some-token-data + name: vm-name + disk: virtio0 + state: detached + +- name: Move disk to another storage + community.general.proxmox_disk: + api_host: node1 + api_user: root@pam + api_password: secret + vmid: 101 + disk: scsi7 + target_storage: local + format: qcow2 + state: moved + +- name: Move disk from one VM to another + community.general.proxmox_disk: + api_host: node1 + api_user: root@pam + api_token_id: token1 + api_token_secret: some-token-data + vmid: 101 + disk: scsi7 + target_vmid: 201 + state: moved + +- name: Remove disk permanently + community.general.proxmox_disk: + api_host: node1 + api_user: root@pam + api_password: secret + vmid: 101 + disk: scsi4 + state: absent +''' + +RETURN = ''' +vmid: + description: The VM vmid. + returned: success + type: int + sample: 101 +msg: + description: A short message on what the module did. + returned: always + type: str + sample: "Disk scsi3 created in VM 101" +''' + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.general.plugins.module_utils.proxmox import (proxmox_auth_argument_spec, + ProxmoxAnsible) +from re import compile, match, sub +from time import sleep + + +def disk_conf_str_to_dict(config_string): + config = config_string.split(',') + storage_volume = config.pop(0).split(':') + config.sort() + storage_name = storage_volume[0] + volume_name = storage_volume[1] + config_current = dict( + volume='%s:%s' % (storage_name, volume_name), + storage_name=storage_name, + volume_name=volume_name + ) + + for option in config: + k, v = option.split('=') + config_current[k] = v + + return config_current + + +class ProxmoxDiskAnsible(ProxmoxAnsible): + create_update_fields = [ + 'aio', 'backup', 'bps_max_length', 'bps_rd_max_length', 'bps_wr_max_length', + 'cache', 'cyls', 'detect_zeroes', 'discard', 'format', 'heads', 'import_from', 'iops', 'iops_max', + 'iops_max_length', 'iops_rd', 'iops_rd_max', 'iops_rd_max_length', 'iops_wr', 'iops_wr_max', + 'iops_wr_max_length', 'iothread', 'mbps', 'mbps_max', 'mbps_rd', 'mbps_rd_max', 'mbps_wr', 'mbps_wr_max', + 'media', 'queues', 'replicate', 'rerror', 'ro', 'scsiblock', 'secs', 'serial', 'shared', 'snapshot', + 'ssd', 'trans', 'werror', 'wwn' + ] + supported_bus_num_ranges = dict( + ide=range(0, 4), + scsi=range(0, 31), + sata=range(0, 6), + virtio=range(0, 16), + unused=range(0, 256) + ) + + def get_create_attributes(self): + # Sanitize parameters dictionary: + # - Remove not defined args + # - Ensure True and False converted to int. + # - Remove unnecessary parameters + params = dict((k, v) for k, v in self.module.params.items() if v is not None and k in self.create_update_fields) + params.update(dict((k, int(v)) for k, v in params.items() if isinstance(v, bool))) + return params + + def create_disk(self, disk, vmid, vm, vm_config): + create = self.module.params['create'] + if create == 'disabled' and disk not in vm_config: + # NOOP + return False, "Disk %s not found in VM %s and creation was disabled in parameters." % (disk, vmid) + + if (create == 'regular' and disk not in vm_config) or (create == 'forced'): + # CREATE + attributes = self.get_create_attributes() + import_string = attributes.pop('import_from', None) + + if import_string: + config_str = "%s:%s,import-from=%s" % (self.module.params["storage"], "0", import_string) + else: + config_str = "%s:%s" % (self.module.params["storage"], self.module.params["size"]) + + for k, v in attributes.items(): + config_str += ',%s=%s' % (k, v) + + create_disk = {self.module.params["disk"]: config_str} + self.proxmox_api.nodes(vm['node']).qemu(vmid).config.set(**create_disk) + return True, "Disk %s created in VM %s" % (disk, vmid) + + if create in ['disabled', 'regular'] and disk in vm_config: + # UPDATE + disk_config = disk_conf_str_to_dict(vm_config[disk]) + config_str = disk_config["volume"] + attributes = self.get_create_attributes() + # 'import_from' fails on disk updates + attributes.pop('import_from', None) + + for k, v in attributes.items(): + config_str += ',%s=%s' % (k, v) + + # Now compare old and new config to detect if changes are needed + for option in ['size', 'storage_name', 'volume', 'volume_name']: + attributes.update({option: disk_config[option]}) + # Values in params are numbers, but strings are needed to compare with disk_config + attributes = dict((k, str(v)) for k, v in attributes.items()) + if disk_config == attributes: + return False, "Disk %s is up to date in VM %s" % (disk, vmid) + + update_disk = {self.module.params["disk"]: config_str} + self.proxmox_api.nodes(vm['node']).qemu(vmid).config.set(**update_disk) + return True, "Disk %s updated in VM %s" % (disk, vmid) + + def move_disk(self, disk, vmid, vm, vm_config): + params = dict() + params['disk'] = disk + params['vmid'] = vmid + params['bwlimit'] = self.module.params['bwlimit'] + params['storage'] = self.module.params['target_storage'] + params['target-disk'] = self.module.params['target_disk'] + params['target-vmid'] = self.module.params['target_vmid'] + params['format'] = self.module.params['format'] + params['delete'] = 1 if self.module.params.get('delete_moved', False) else 0 + # Remove not defined args + params = dict((k, v) for k, v in params.items() if v is not None) + + if params.get('storage', False): + disk_config = disk_conf_str_to_dict(vm_config[disk]) + if params['storage'] == disk_config['storage_name']: + return False + + taskid = self.proxmox_api.nodes(vm['node']).qemu(vmid).move_disk.post(**params) + timeout = self.module.params['timeout'] + while timeout: + status_data = self.proxmox_api.nodes(vm['node']).tasks(taskid).status.get() + if status_data['status'] == 'stopped' and status_data['exitstatus'] == 'OK': + return True + if timeout <= 0: + self.module.fail_json( + msg='Reached timeout while waiting for moving VM disk. Last line in task before timeout: %s' % + self.proxmox_api.nodes(vm['node']).tasks(taskid).log.get()[:1]) + + sleep(1) + timeout -= 1 + return True + + +def main(): + module_args = proxmox_auth_argument_spec() + disk_args = dict( + # Proxmox native parameters + aio=dict(type='str', choices=['native', 'threads', 'io_uring']), + backup=dict(type='bool'), + bps_max_length=dict(type='int'), + bps_rd_max_length=dict(type='int'), + bps_wr_max_length=dict(type='int'), + cache=dict(type='str', choices=['none', 'writethrough', 'writeback', 'unsafe', 'directsync']), + cyls=dict(type='int'), + detect_zeroes=dict(type='bool'), + discard=dict(type='str', choices=['ignore', 'on']), + format=dict(type='str', choices=['raw', 'cow', 'qcow', 'qed', 'qcow2', 'vmdk', 'cloop']), + heads=dict(type='int'), + import_from=dict(type='str'), + iops=dict(type='int'), + iops_max=dict(type='int'), + iops_max_length=dict(type='int'), + iops_rd=dict(type='int'), + iops_rd_max=dict(type='int'), + iops_rd_max_length=dict(type='int'), + iops_wr=dict(type='int'), + iops_wr_max=dict(type='int'), + iops_wr_max_length=dict(type='int'), + iothread=dict(type='bool'), + mbps=dict(type='float'), + mbps_max=dict(type='float'), + mbps_rd=dict(type='float'), + mbps_rd_max=dict(type='float'), + mbps_wr=dict(type='float'), + mbps_wr_max=dict(type='float'), + media=dict(type='str', choices=['cdrom', 'disk']), + queues=dict(type='int'), + replicate=dict(type='bool'), + rerror=dict(type='str', choices=['ignore', 'report', 'stop']), + ro=dict(type='bool'), + scsiblock=dict(type='bool'), + secs=dict(type='int'), + serial=dict(type='str'), + shared=dict(type='bool'), + snapshot=dict(type='bool'), + ssd=dict(type='bool'), + trans=dict(type='str', choices=['auto', 'lba', 'none']), + werror=dict(type='str', choices=['enospc', 'ignore', 'report', 'stop']), + wwn=dict(type='str'), + + # Disk moving relates parameters + bwlimit=dict(type='int'), + target_storage=dict(type='str'), + target_disk=dict(type='str'), + target_vmid=dict(type='int'), + delete_moved=dict(type='bool'), + timeout=dict(type='int', default='600'), + + # Module related parameters + name=dict(type='str'), + vmid=dict(type='int'), + disk=dict(type='str', required=True), + storage=dict(type='str'), + size=dict(type='str'), + state=dict(type='str', choices=['present', 'resized', 'detached', 'moved', 'absent'], + default='present'), + create=dict(type='str', choices=['disabled', 'regular', 'forced'], default='regular'), + ) + + module_args.update(disk_args) + + module = AnsibleModule( + argument_spec=module_args, + required_together=[('api_token_id', 'api_token_secret')], + required_one_of=[('name', 'vmid'), ('api_password', 'api_token_id')], + required_if=[ + ('create', 'forced', ['storage']), + ('state', 'resized', ['size']), + ], + required_by={ + 'target_disk': 'target_vmid', + 'mbps_max': 'mbps', + 'mbps_rd_max': 'mbps_rd', + 'mbps_wr_max': 'mbps_wr', + 'bps_max_length': 'mbps_max', + 'bps_rd_max_length': 'mbps_rd_max', + 'bps_wr_max_length': 'mbps_wr_max', + 'iops_max': 'iops', + 'iops_rd_max': 'iops_rd', + 'iops_wr_max': 'iops_wr', + 'iops_max_length': 'iops_max', + 'iops_rd_max_length': 'iops_rd_max', + 'iops_wr_max_length': 'iops_wr_max', + }, + supports_check_mode=False, + mutually_exclusive=[ + ('target_vmid', 'target_storage'), + ('mbps', 'mbps_rd'), + ('mbps', 'mbps_wr'), + ('iops', 'iops_rd'), + ('iops', 'iops_wr'), + ('import_from', 'size'), + ] + ) + + proxmox = ProxmoxDiskAnsible(module) + + disk = module.params['disk'] + # Verify disk name has appropriate name + disk_regex = compile(r'^([a-z]+)([0-9]+)$') + disk_bus = sub(disk_regex, r'\1', disk) + disk_number = int(sub(disk_regex, r'\2', disk)) + if disk_bus not in proxmox.supported_bus_num_ranges: + proxmox.module.fail_json(msg='Unsupported disk bus: %s' % disk_bus) + elif disk_number not in proxmox.supported_bus_num_ranges[disk_bus]: + bus_range = proxmox.supported_bus_num_ranges[disk_bus] + proxmox.module.fail_json(msg='Disk %s number not in range %s..%s ' % (disk, bus_range[0], bus_range[-1])) + + name = module.params['name'] + state = module.params['state'] + vmid = module.params['vmid'] or proxmox.get_vmid(name) + + # Ensure VM id exists and retrieve its config + vm = None + vm_config = None + try: + vm = proxmox.get_vm(vmid) + vm_config = proxmox.proxmox_api.nodes(vm['node']).qemu(vmid).config.get() + except Exception as e: + proxmox.module.fail_json(msg='Getting information for VM %s failed with exception: %s' % (vmid, str(e))) + + # Do not try to perform actions on missing disk + if disk not in vm_config and state in ['resized', 'moved']: + module.fail_json(vmid=vmid, msg='Unable to process missing disk %s in VM %s' % (disk, vmid)) + + if state == 'present': + try: + success, message = proxmox.create_disk(disk, vmid, vm, vm_config) + if success: + module.exit_json(changed=True, vmid=vmid, msg=message) + else: + module.exit_json(changed=False, vmid=vmid, msg=message) + except Exception as e: + module.fail_json(vmid=vmid, msg='Unable to create/update disk %s in VM %s: %s' % (disk, vmid, str(e))) + + elif state == 'detached': + try: + if disk_bus == 'unused': + module.exit_json(changed=False, vmid=vmid, msg='Disk %s already detached in VM %s' % (disk, vmid)) + if disk not in vm_config: + module.exit_json(changed=False, vmid=vmid, msg="Disk %s not present in VM %s config" % (disk, vmid)) + proxmox.proxmox_api.nodes(vm['node']).qemu(vmid).unlink.put(vmid=vmid, idlist=disk, force=0) + module.exit_json(changed=True, vmid=vmid, msg="Disk %s detached from VM %s" % (disk, vmid)) + except Exception as e: + module.fail_json(msg="Failed to detach disk %s from VM %s with exception: %s" % (disk, vmid, str(e))) + + elif state == 'moved': + try: + disk_config = disk_conf_str_to_dict(vm_config[disk]) + disk_storage = disk_config["storage_name"] + if proxmox.move_disk(disk, vmid, vm, vm_config): + module.exit_json(changed=True, vmid=vmid, + msg="Disk %s moved from VM %s storage %s" % (disk, vmid, disk_storage)) + else: + module.exit_json(changed=False, vmid=vmid, msg="Disk %s already at %s storage" % (disk, disk_storage)) + except Exception as e: + module.fail_json(msg="Failed to move disk %s in VM %s with exception: %s" % (disk, vmid, str(e))) + + elif state == 'resized': + try: + size = module.params['size'] + if not match(r'^\+?\d+(\.\d+)?[KMGT]?$', size): + module.fail_json(msg="Unrecognized size pattern for disk %s: %s" % (disk, size)) + disk_config = disk_conf_str_to_dict(vm_config[disk]) + actual_size = disk_config['size'] + if size == actual_size: + module.exit_json(changed=False, vmid=vmid, msg="Disk %s is already %s size" % (disk, size)) + proxmox.proxmox_api.nodes(vm['node']).qemu(vmid).resize.set(vmid=vmid, disk=disk, size=size) + module.exit_json(changed=True, vmid=vmid, msg="Disk %s resized in VM %s" % (disk, vmid)) + except Exception as e: + module.fail_json(msg="Failed to resize disk %s in VM %s with exception: %s" % (disk, vmid, str(e))) + + elif state == 'absent': + try: + if disk not in vm_config: + module.exit_json(changed=False, vmid=vmid, msg="Disk %s is already absent in VM %s" % (disk, vmid)) + proxmox.proxmox_api.nodes(vm['node']).qemu(vmid).unlink.put(vmid=vmid, idlist=disk, force=1) + module.exit_json(changed=True, vmid=vmid, msg="Disk %s removed from VM %s" % (disk, vmid)) + except Exception as e: + module.fail_json(vmid=vmid, msg='Unable to remove disk %s from VM %s: %s' % (disk, vmid, str(e))) + + +if __name__ == '__main__': + main() diff --git a/tests/integration/targets/proxmox/tasks/main.yml b/tests/integration/targets/proxmox/tasks/main.yml index fc7e084e90..437ccad60e 100644 --- a/tests/integration/targets/proxmox/tasks/main.yml +++ b/tests/integration/targets/proxmox/tasks/main.yml @@ -313,6 +313,202 @@ - results.vmid == {{ vmid }} - results.msg == "Nic net5 deleted on VM with vmid {{ vmid }}" +- name: Create new disk in VM + tags: ['create_disk'] + block: + - name: Add new disk (without force) to VM + proxmox_disk: + api_host: "{{ api_host }}" + api_user: "{{ user }}@{{ domain }}" + api_password: "{{ api_password | default(omit) }}" + api_token_id: "{{ api_token_id | default(omit) }}" + api_token_secret: "{{ api_token_secret | default(omit) }}" + vmid: "{{ vmid }}" + disk: "{{ disk }}" + storage: "{{ storage }}" + size: 1 + state: present + register: results + + - assert: + that: + - results is changed + - results.vmid == {{ vmid }} + - results.msg == "Disk {{ disk }} created in VM {{ vmid }}" + + - name: Try add disk again with same options (expect no-op) + proxmox_disk: + api_host: "{{ api_host }}" + api_user: "{{ user }}@{{ domain }}" + api_password: "{{ api_password | default(omit) }}" + api_token_id: "{{ api_token_id | default(omit) }}" + api_token_secret: "{{ api_token_secret | default(omit) }}" + vmid: "{{ vmid }}" + disk: "{{ disk }}" + storage: "{{ storage }}" + size: 1 + state: present + register: results + + - assert: + that: + - results is not changed + - results.vmid == {{ vmid }} + - results.msg == "Disk {{ disk }} is up to date in VM {{ vmid }}" + + - name: Add new disk replacing existing disk (detach old and leave unused) + proxmox_disk: + api_host: "{{ api_host }}" + api_user: "{{ user }}@{{ domain }}" + api_password: "{{ api_password | default(omit) }}" + api_token_id: "{{ api_token_id | default(omit) }}" + api_token_secret: "{{ api_token_secret | default(omit) }}" + vmid: "{{ vmid }}" + disk: "{{ disk }}" + storage: "{{ storage }}" + size: 2 + create: forced + state: present + register: results + + - assert: + that: + - results is changed + - results.vmid == {{ vmid }} + - results.msg == "Disk {{ disk }} created in VM {{ vmid }}" + +- name: Update existing disk in VM + tags: ['update_disk'] + block: + - name: Update disk configuration + proxmox_disk: + api_host: "{{ api_host }}" + api_user: "{{ user }}@{{ domain }}" + api_password: "{{ api_password | default(omit) }}" + api_token_id: "{{ api_token_id | default(omit) }}" + api_token_secret: "{{ api_token_secret | default(omit) }}" + vmid: "{{ vmid }}" + disk: "{{ disk }}" + backup: false + ro: true + aio: native + state: present + register: results + + - assert: + that: + - results is changed + - results.vmid == {{ vmid }} + - results.msg == "Disk {{ disk }} updated in VM {{ vmid }}" + +- name: Grow existing disk in VM + tags: ['grow_disk'] + block: + - name: Increase disk size + proxmox_disk: + api_host: "{{ api_host }}" + api_user: "{{ user }}@{{ domain }}" + api_password: "{{ api_password | default(omit) }}" + api_token_id: "{{ api_token_id | default(omit) }}" + api_token_secret: "{{ api_token_secret | default(omit) }}" + vmid: "{{ vmid }}" + disk: "{{ disk }}" + size: +1G + state: resized + register: results + + - assert: + that: + - results is changed + - results.vmid == {{ vmid }} + - results.msg == "Disk {{ disk }} resized in VM {{ vmid }}" + +- name: Detach disk and leave it unused + tags: ['detach_disk'] + block: + - name: Detach disk + proxmox_disk: + api_host: "{{ api_host }}" + api_user: "{{ user }}@{{ domain }}" + api_password: "{{ api_password | default(omit) }}" + api_token_id: "{{ api_token_id | default(omit) }}" + api_token_secret: "{{ api_token_secret | default(omit) }}" + vmid: "{{ vmid }}" + disk: "{{ disk }}" + state: detached + register: results + + - assert: + that: + - results is changed + - results.vmid == {{ vmid }} + - results.msg == "Disk {{ disk }} detached from VM {{ vmid }}" + +- name: Move disk to another storage or another VM + tags: ['move_disk'] + block: + - name: Move disk to another storage inside same VM + proxmox_disk: + api_host: "{{ api_host }}" + api_user: "{{ user }}@{{ domain }}" + api_password: "{{ api_password | default(omit) }}" + api_token_id: "{{ api_token_id | default(omit) }}" + api_token_secret: "{{ api_token_secret | default(omit) }}" + vmid: "{{ vmid }}" + disk: "{{ disk }}" + target_storage: "{{ target_storage }}" + format: "{{ target_format }}" + state: moved + register: results + + - assert: + that: + - results is changed + - results.vmid == {{ vmid }} + - results.msg == "Disk {{ disk }} moved from VM {{ vmid }} storage {{ results.storage }}" + + - name: Move disk to another VM (same storage) + proxmox_disk: + api_host: "{{ api_host }}" + api_user: "{{ user }}@{{ domain }}" + api_password: "{{ api_password | default(omit) }}" + api_token_id: "{{ api_token_id | default(omit) }}" + api_token_secret: "{{ api_token_secret | default(omit) }}" + vmid: "{{ vmid }}" + disk: "{{ disk }}" + target_vmid: "{{ target_vm }}" + target_disk: "{{ target_disk }}" + state: moved + register: results + + - assert: + that: + - results is changed + - results.vmid == {{ vmid }} + - results.msg == "Disk {{ disk }} moved from VM {{ vmid }} storage {{ results.storage }}" + + +- name: Remove disk permanently + tags: ['remove_disk'] + block: + - name: Remove disk + proxmox_disk: + api_host: "{{ api_host }}" + api_user: "{{ user }}@{{ domain }}" + api_password: "{{ api_password | default(omit) }}" + api_token_id: "{{ api_token_id | default(omit) }}" + api_token_secret: "{{ api_token_secret | default(omit) }}" + vmid: "{{ target_vm }}" + disk: "{{ target_disk }}" + state: absent + register: results + + - assert: + that: + - results is changed + - results.vmid == {{ target_vm }} + - results.msg == "Disk {{ target_disk }} removed from VM {{ target_vm }}" + - name: VM stop tags: [ 'stop' ] block: