From e2fcc0ab3c909a9b29d125634a15eaedacc685b3 Mon Sep 17 00:00:00 2001 From: Daniel Hofer Date: Tue, 19 May 2026 12:01:39 +0200 Subject: [PATCH] Support for remote VM system disks Allows fetching VM system disks from remote locations. A secure hash is required. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Daniel Hofer --- roles/deploy_vms_cluster/README.md | 38 ++++++------ roles/deploy_vms_cluster/tasks/main.yml | 77 ++++++++++++++++++++++--- 2 files changed, 90 insertions(+), 25 deletions(-) diff --git a/roles/deploy_vms_cluster/README.md b/roles/deploy_vms_cluster/README.md index 8f9c1714b..320c92bc7 100644 --- a/roles/deploy_vms_cluster/README.md +++ b/roles/deploy_vms_cluster/README.md @@ -23,24 +23,26 @@ No requirement. The role uses the "VMs" group of the inventory. All members of this group will be deployed according to member's variable described below. The Ansible member inventory host name will be used as VM name. -| *item* variables | Required | Type | Default | Comments | -|--------------------|----------|-------------|---------------------------------------|-----------------------------------------------------------------------| -| vm_disk | No | String | vms_disks_directory + item + ".qcow2" | Path to the VM disk in `qcow2` format on the the Ansible machine | -| vm_template | No | String | | Path of the VM Libvirt XML templated file on the ansible machine | -| xml_path | No | String | vms_disks_directory + item + ".qcow2" | Path of the VM Libvirt XML file (non-template) on the ansible machine | -| force | No | Bool | false | Replace the VM if a VM with the same name is already deployed | -| enable | No | Bool | true | Enable and start the VM after creating it | -| nostart | No | Bool | false | Don't start the VM once deployed | -| live_migration | No | Bool | false | Set to true to enable livemigration support for this VM | -| migrate_to_timeout | No | Integer | | Maximum time given to a guest to live migrate (in seconds) | -| migration_downtime | No | Integer | | Allowed downtime when live migrating (in milliseconds) | -| priority | No | Integer | | Priority of resource in pacemaker | -| pinned_host | No | String | | Pin the VM on the given host. Fail if not possible | -| preferred_host | No | String | | Deploy the VM on the given host in priority if possible | -| crm_config_cmd | No | String list | | List of `crm config` to run when enabling this guest | -| colocated_vms | No | String list | | VM list to be be colocated with the new VM | -| strong_colocation | No | Bool | false | Enable strong colocation on colocated_vms. The VM will will not be started when the constraint is not fulfilled | -| disk_bus | No | String | | Disk bus type to use for the VM's disk (virtio, sata, scsi, ide, etc.) | +| *item* variables | Required | Type | Default | Comments | +|--------------------|----------|-------------|---------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| vm_disk | No | String | vms_disks_directory + item + ".qcow2" | Path to the VM disk in `qcow2` format on the the Ansible machine. Mutually exclusive with `vm_remote_disk`. | +| vm_remote_disk | No | String | | URL (`http`/`https`/`ftp`/`ftps`) of the VM disk, fetched directly on the target. Requires `vm_disk_checksum`. Mutually exclusive with `vm_disk`. | +| vm_disk_checksum | No | Dict | | Dict `{: , ...}` to verify the system disk after staging. Required for `vm_remote_disk`, optional for `vm_disk`. May list multiple algorithms; the strongest available is used (preference: `sha512` > `sha384` > `sha256`). | +| vm_template | No | String | | Path of the VM Libvirt XML templated file on the ansible machine | +| xml_path | No | String | vms_disks_directory + item + ".qcow2" | Path of the VM Libvirt XML file (non-template) on the ansible machine | +| force | No | Bool | false | Replace the VM if a VM with the same name is already deployed | +| enable | No | Bool | true | Enable and start the VM after creating it | +| nostart | No | Bool | false | Don't start the VM once deployed | +| live_migration | No | Bool | false | Set to true to enable livemigration support for this VM | +| migrate_to_timeout | No | Integer | | Maximum time given to a guest to live migrate (in seconds) | +| migration_downtime | No | Integer | | Allowed downtime when live migrating (in milliseconds) | +| priority | No | Integer | | Priority of resource in pacemaker | +| pinned_host | No | String | | Pin the VM on the given host. Fail if not possible | +| preferred_host | No | String | | Deploy the VM on the given host in priority if possible | +| crm_config_cmd | No | String list | | List of `crm config` to run when enabling this guest | +| colocated_vms | No | String list | | VM list to be be colocated with the new VM | +| strong_colocation | No | Bool | false | Enable strong colocation on colocated_vms. The VM will will not be started when the constraint is not fulfilled | +| disk_bus | No | String | | Disk bus type to use for the VM's disk (virtio, sata, scsi, ide, etc.) | Here is an example of the structure for the VM inventory: diff --git a/roles/deploy_vms_cluster/tasks/main.yml b/roles/deploy_vms_cluster/tasks/main.yml index d58e9eda1..25d28b2c1 100644 --- a/roles/deploy_vms_cluster/tasks/main.yml +++ b/roles/deploy_vms_cluster/tasks/main.yml @@ -59,16 +59,79 @@ - name: Provision cluster VM (create if missing or forced) when: deploy_vms_cluster_presencevm.status == "Undefined" or (hostvars[item].force is defined and hostvars[item].force) vars: - vm_file: "{{ hostvars[item].vm_disk | default( deploy_vms_cluster_vms_disks_directory ~ '/' ~ item ~ '.qcow2') }}" + vm_local_disk: "{{ hostvars[item].vm_disk | default(deploy_vms_cluster_vms_disks_directory ~ '/' ~ item ~ '.qcow2') }}" + vm_remote_disk: "{{ hostvars[item].vm_remote_disk | default('') }}" vm_file_dest: "{{ deploy_vms_cluster_qcow2tmpuploadfolder + '/os.qcow2' }}" + vm_disk_checksum: "{{ hostvars[item].vm_disk_checksum | default({}) }}" + vm_disk_checksum_preference: ['sha512', 'sha384', 'sha256'] + vm_disk_checksum_unknown: "{{ (vm_disk_checksum.keys() | list) | difference(vm_disk_checksum_preference) }}" + vm_disk_checksum_algo: "{{ (vm_disk_checksum_preference | intersect(vm_disk_checksum.keys() | list)) | first | default('') }}" + vm_disk_checksum_hash: "{{ vm_disk_checksum[vm_disk_checksum_algo] | default('') }}" + vm_disk_checksum_present: "{{ (vm_disk_checksum_algo | length > 0) and (vm_disk_checksum_hash | length > 0) }}" block: - - name: "Copy system disk on target for {{ item }}" # noqa: risky-file-permissions - ansible.builtin.copy: - src: "{{ vm_file }}" - dest: "{{ vm_file_dest }}" - vars: - ansible_remote_tmp: "{{ deploy_vms_cluster_qcow2tmpuploadfolder | default(omit) }}" + - name: "Validate VM disk source for {{ item }}" + ansible.builtin.fail: + msg: >- + For '{{ item }}', set either vm_disk (local path on control node) or + vm_remote_disk (URL fetched on target), not both. + when: + - hostvars[item].vm_disk is defined + - hostvars[item].vm_remote_disk is defined + - name: "Reject unsupported vm_disk_checksum keys for {{ item }}" + ansible.builtin.fail: + msg: >- + hostvars[{{ item }}].vm_disk_checksum contains unsupported keys: {{ vm_disk_checksum_unknown }}. + Supported algorithms: {{ vm_disk_checksum_preference | join(', ') }} (strongest used). + when: vm_disk_checksum_unknown | length > 0 + - name: "Validate vm_disk_checksum shape for {{ item }}" + ansible.builtin.fail: + msg: >- + hostvars[{{ item }}].vm_disk_checksum must contain at least one supported + algorithm key ({{ vm_disk_checksum_preference | join(', ') }}) with a + non-empty hex digest. Got: {{ vm_disk_checksum }}. + when: + - vm_disk_checksum | length > 0 + - not vm_disk_checksum_present + - name: "Stage system disk for {{ item }}" when: deploy_vms_cluster_disk_copy | bool + block: + - name: "Require checksum for remote VM disk ({{ item }})" + ansible.builtin.fail: + msg: >- + hostvars[{{ item }}].vm_disk_checksum is required when using vm_remote_disk + (e.g. {sha256: ''}). + when: + - vm_remote_disk | length > 0 + - not vm_disk_checksum_present + - name: "Download system disk from URL for {{ item }}" + ansible.builtin.get_url: + url: "{{ vm_remote_disk }}" + dest: "{{ vm_file_dest }}" + mode: "0644" + checksum: "{{ vm_disk_checksum_algo }}:{{ vm_disk_checksum_hash }}" + when: vm_remote_disk | length > 0 + - name: "Copy system disk on target for {{ item }}" # noqa: risky-file-permissions + ansible.builtin.copy: + src: "{{ vm_local_disk }}" + dest: "{{ vm_file_dest }}" + vars: + ansible_remote_tmp: "{{ deploy_vms_cluster_qcow2tmpuploadfolder | default(omit) }}" + when: vm_remote_disk | length == 0 + - name: "Stat system disk for checksum verification ({{ item }})" + ansible.builtin.stat: + path: "{{ vm_file_dest }}" + checksum_algorithm: "{{ vm_disk_checksum_algo }}" + get_checksum: true + register: deploy_vms_cluster_disk_stat + when: vm_disk_checksum_present + - name: "Verify system disk checksum for {{ item }}" + ansible.builtin.assert: + that: deploy_vms_cluster_disk_stat.stat.checksum == vm_disk_checksum_hash + fail_msg: >- + Checksum mismatch for {{ vm_file_dest }} on {{ inventory_hostname }}: + expected {{ vm_disk_checksum_algo }}:{{ vm_disk_checksum_hash }}, + got {{ vm_disk_checksum_algo }}:{{ deploy_vms_cluster_disk_stat.stat.checksum | default('') }}. + when: vm_disk_checksum_present - name: "Create {{ item }}" cluster_vm: name: "{{ item }}"