diff --git a/ansible/requirements.yml b/ansible/requirements.yml index 4920387..2408bc7 100644 --- a/ansible/requirements.yml +++ b/ansible/requirements.yml @@ -19,5 +19,11 @@ collections: # Required by the molecule docker driver, not by any role. - name: community.docker version: ">=5.0.0" + + # tests/proxmox/create.yml and reset.yml. The proxmox_* modules left + # community.general for this collection and are removed from it at 15.0.0, + # which the floor above will reach. + - name: community.proxmox + version: ">=1.0.0" - name: ansible.posix version: ">=2.0.0" diff --git a/ansible/tests/.env.sample b/ansible/tests/.env.sample index 195f3d4..c1d3fd5 100644 --- a/ansible/tests/.env.sample +++ b/ansible/tests/.env.sample @@ -7,10 +7,18 @@ # ============================================================================= # Your Proxmox server connection details PROXMOX_HOST=proxmox.example.com -PROXMOX_USER=root@pam -PROXMOX_PASSWORD= # Optional - leave empty if using SSH keys PROXMOX_NODE=pve -PROXMOX_API_VERIFY_SSL=false +PROXMOX_API_VERIFY_SSL=true + +# An API token, the same one hyperi-infra's tools use: user@realm!name plus +# its secret, made under Datacenter > Permissions > API Tokens. The playbooks +# in tests/proxmox/ authenticate with these two and nothing else. +PROXMOX_TOKEN_ID=hyperi-vm@pve!cli-token +PROXMOX_TOKEN_SECRET= + +# root@pam password. Only the older provision.yml still reads these. +PROXMOX_USER=root@pam +PROXMOX_PASSWORD= # Fedora Test VM PROXMOX_FEDORA_VMID=2005 @@ -28,6 +36,50 @@ PROXMOX_UBUNTU_USER=dfe PROXMOX_UBUNTU_PASSWORD=dfe # Optional - omit from inventory if using SSH keys PROXMOX_UBUNTU_SSH_PORT=22 +# ----------------------------------------------------------------------------- +# TEST VMS (tests/proxmox/create.yml and reset.yml) +# ----------------------------------------------------------------------------- +# Snapshot-reset testing: create.yml clones a base template into the range +# below, sizes it, brings it current, stops it and snapshots it. reset.yml +# rolls any VM in the range back to that snapshot. delete.yml removes one. +# All three refuse a vmid outside the range, so a typo cannot roll back, +# overwrite or delete a real machine. +PROXMOX_TEST_VMID_MIN=8100 +PROXMOX_TEST_VMID_MAX=8199 + +# The base (not desktop) template: a clean cloud image with cloud-init and +# nothing else. The desktop template bakes hyperi-developer in, which is the +# thing under test. +PROXMOX_TEST_TEMPLATE_VMID=9041 + +# Size and placement of a VM create.yml makes, matched to the lab's other test +# machines. STORAGE is where the full clone lands; the disk is grown to DISK +# and cloud-init grows the root filesystem at boot. Leave one empty to keep +# the template's value. +PROXMOX_TEST_CORES=8 +PROXMOX_TEST_MEMORY=16384 +PROXMOX_TEST_DISK=100G +PROXMOX_TEST_STORAGE= + +# The cloud-init search domain. The lab DNS registers . for +# every DHCP lease, which is how a VM made without -e ip is reached. +PROXMOX_TEST_DOMAIN=example.com + +# Static addressing, used when create.yml is given -e ip=/. +PROXMOX_TEST_GATEWAY=192.0.2.1 +PROXMOX_TEST_NAMESERVER= + +# Login that create.yml sets through cloud-init, and that reset.yml writes +# into the inventory. A VM made some other way passes its own with +# -e test_user= -e test_password= -e snapshot=. +PROXMOX_TEST_USER=hyperi +PROXMOX_TEST_PASSWORD= + +# The private key Ansible connects with; its .pub is what cloud-init installs +# for that user. Cloud images ship sshd with password login off, so the +# password above serves the console and sudo only. An absolute path. +PROXMOX_TEST_SSH_KEY=/home/you/.ssh/devex-ssh + # ----------------------------------------------------------------------------- # CLONE-TEST (emulates the auto desktop image cycle: clone -> test -> delete) # ----------------------------------------------------------------------------- diff --git a/ansible/tests/common/setup_env.yml b/ansible/tests/common/setup_env.yml index fa42e7c..8cbe692 100644 --- a/ansible/tests/common/setup_env.yml +++ b/ansible/tests/common/setup_env.yml @@ -30,12 +30,20 @@ Press Enter when done editing... when: not env_file_stat.stat.exists +# bash, not /bin/sh: on Debian and Ubuntu /bin/sh is dash, which has no +# `source`. The failure was silent -- the pipeline's exit status is jq's -- so +# `env` returned whatever PROXMOX_* the ambient shell already carried, and a +# token from the operator's environment quietly stood in for the file's. +# `set -e` and pipefail make a missing or unreadable .env fail the task. - name: Load environment variables from .env (shell format) ansible.builtin.shell: | + set -euo pipefail set -a source "{{ tests_dir }}/.env" set +a env | grep -E '^(PROXMOX|ANSIBLE)_' | jq -R 'split("=") | {(.[0]): .[1:] | join("=")}' | jq -s 'add' + args: + executable: /bin/bash register: env_raw changed_when: false diff --git a/ansible/tests/proxmox/README.md b/ansible/tests/proxmox/README.md new file mode 100644 index 0000000..976d87f --- /dev/null +++ b/ansible/tests/proxmox/README.md @@ -0,0 +1,61 @@ +# Snapshot-reset testing against Proxmox VMs + +Run hyperi-developer against a real VM from a known clean state, as many times +as needed, by hand. Not CI: it needs a Proxmox endpoint and credentials. + +Configuration is the `TEST VMS` block in `tests/.env` (copy +`tests/.env.sample`). Authentication is the same Proxmox API token +hyperi-infra's tools use -- `PROXMOX_TOKEN_ID` and `PROXMOX_TOKEN_SECRET`, +never a root password. Login to the VM itself is by key: cloud-init installs +the `.pub` of `PROXMOX_TEST_SSH_KEY` for the test user, because cloud images +ship sshd with password login off. Everything here runs from `ansible/`. + +The modules need `proxmoxer` on the machine running the playbook, for the +Python that Ansible uses: `sudo apt install python3-proxmoxer` on Debian and +Ubuntu, or pip inside whatever venv runs Ansible. Then +`ansible-galaxy collection install -r requirements.yml` for `community.proxmox`. + +## Make a clean VM once + + ansible-playbook tests/proxmox/create.yml -e vm_name=ubuntu-test.example.com \ + -e vmid=8101 -e ip=192.0.2.51/24 + +Clones the base template, sizes it from `.env` (cores, memory, disk, +storage), gives it a static address or DHCP, boots it once to bring every +package current, **stops it**, and snapshots it as `clean`. hyperi-developer +never runs here: the snapshot is the state every test starts from. + +The VM name is what Proxmox and the lab DNS see, so name a static VM by its +FQDN. Leave off `-e vmid` for the lowest free id in the range and `-e ip` for +DHCP, where the lab DNS names the VM `.`. + +The base template, not the desktop one: the desktop template bakes +hyperi-developer in, which is the thing under test. + +## Test + + ansible-playbook tests/proxmox/reset.yml -e vmid=8101 + ansible-playbook -i tests/proxmox/inventory_proxmox.yml playbooks/main.yml --tags developer-rust + ansible-playbook tests/proxmox/reset.yml -e vmid=8101 -e start=false + +`reset.yml` stops the VM, rolls it back to `clean`, starts it, waits for sshd, +and writes `inventory_proxmox.yml` for it. The last line is the resting state: +a test VM is off except while a test runs, so finish by rolling it back again +and leaving it stopped. + +A VM made some other way -- the Fedora box, say -- passes its own snapshot +name and login: `-e snapshot=initial_build -e test_user=dfe -e test_password=dfe`. + +## Remove + + ansible-playbook tests/proxmox/delete.yml -e vmid=8102 + +## Safety + +All three playbooks refuse a vmid outside `PROXMOX_TEST_VMID_MIN..MAX`. A +rollback or delete discards everything on the machine, so that range is the +only place it can happen. + +`provision.yml` and `test_all.yml` predate this and target a fixed +Fedora+Ubuntu pair; `test_all.yml` also installs from a branch that no longer +exists. Prefer the playbooks above. diff --git a/ansible/tests/proxmox/create.yml b/ansible/tests/proxmox/create.yml new file mode 100644 index 0000000..f7eb189 --- /dev/null +++ b/ansible/tests/proxmox/create.yml @@ -0,0 +1,247 @@ +--- +# Create a clean test VM from a base template and snapshot it, so reset.yml +# can return it to this exact state before every hyperi-developer run. +# +# The VM is cloned, sized, booted once to bring its packages current, then +# STOPPED and snapshotted. hyperi-developer never runs here: the snapshot IS +# the "before" state. +# +# ansible-playbook tests/proxmox/create.yml -e vm_name=ubuntu-test.example.com \ +# -e vmid=8101 -e ip=192.0.2.51/24 +# ansible-playbook tests/proxmox/create.yml -e vm_name=scratch +# +# Without -e ip the VM takes DHCP. Size, storage, gateway and nameserver come +# from the TEST VMS block in tests/.env -- see tests/.env.sample. + +- name: Clone a base template into the test range + hosts: localhost + connection: local + gather_facts: false + vars: + snapshot: clean + vmid: "" + ip: "" + module_defaults: + community.proxmox.proxmox_vm_info: &api + api_host: "{{ env.PROXMOX_HOST }}" + # A token, never a password. PROXMOX_TOKEN_ID is user@realm!name as + # hyperi-infra stores it; the modules take the two halves separately. + api_user: "{{ env.PROXMOX_TOKEN_ID.split('!')[0] }}" + api_token_id: "{{ env.PROXMOX_TOKEN_ID.split('!')[1] }}" + api_token_secret: "{{ env.PROXMOX_TOKEN_SECRET }}" + validate_certs: "{{ env.PROXMOX_API_VERIFY_SSL | default('true') | bool }}" + community.proxmox.proxmox_kvm: *api + community.proxmox.proxmox_disk: *api + community.proxmox.proxmox_snap: *api + tasks: + - name: Require a VM name + ansible.builtin.assert: + that: vm_name is defined and vm_name | length > 0 + fail_msg: "pass -e vm_name=" + quiet: true + + - name: Load tests/.env + ansible.builtin.include_tasks: + file: ../common/setup_env.yml + + # A static VM is reached by its address, so nothing here waits on DNS. A + # DHCP VM is reached by the name the lab DNS registers for its lease: the + # VM name with the domain appended, unless it is already a FQDN. + - name: Resolve the address Ansible reaches the VM on + ansible.builtin.set_fact: + vm_host: >- + {{ ip | regex_replace('/.*$', '') if ip | length > 0 else + (vm_name if '.' in vm_name else vm_name ~ '.' ~ env.PROXMOX_TEST_DOMAIN) }} + + - name: List the VMs on the node + community.proxmox.proxmox_vm_info: + node: "{{ env.PROXMOX_NODE }}" + register: proxmox_existing + + # A VM of this name already on the node is a run that stopped partway: the + # clone succeeded and something after it did not. Finish it rather than + # clone a second one beside it. + - name: Adopt a VM of this name if one exists + ansible.builtin.set_fact: + proxmox_adopted: >- + {{ proxmox_existing.proxmox_vms | selectattr('name', 'eq', vm_name) + | map(attribute='vmid') | map('int') | list }} + proxmox_used: "{{ proxmox_existing.proxmox_vms | map(attribute='vmid') | map('int') | list }}" + + # difference() is a set operation and returns its result unordered, so the + # sort is what makes "the lowest free id" true. + - name: Pick the vmid + ansible.builtin.set_fact: + vmid: >- + {{ proxmox_adopted[0] if proxmox_adopted | length > 0 else + (vmid if vmid | string | length > 0 else + (range(env.PROXMOX_TEST_VMID_MIN | int, env.PROXMOX_TEST_VMID_MAX | int + 1) | list + | difference(proxmox_used) | sort | first)) }} + + # A vmid passed by hand is held to the same range, so a typo cannot land + # on a real machine. + - name: Refuse a vmid outside the test range or already in use + ansible.builtin.assert: + that: + - vmid | int >= env.PROXMOX_TEST_VMID_MIN | int + - vmid | int <= env.PROXMOX_TEST_VMID_MAX | int + - proxmox_adopted | length > 0 or vmid | int not in proxmox_used + fail_msg: >- + vmid {{ vmid }} is outside {{ env.PROXMOX_TEST_VMID_MIN }}-{{ env.PROXMOX_TEST_VMID_MAX }} + or already in use + quiet: true + + - name: Clone the base template + community.proxmox.proxmox_kvm: + node: "{{ env.PROXMOX_NODE }}" + clone: "{{ env.PROXMOX_TEST_TEMPLATE_VMID }}" + vmid: "{{ env.PROXMOX_TEST_TEMPLATE_VMID }}" + newid: "{{ vmid }}" + name: "{{ vm_name }}" + full: true + storage: "{{ env.PROXMOX_TEST_STORAGE | default(omit, true) }}" + timeout: 600 + state: present + when: proxmox_adopted | length == 0 + + # The base template carries no user, no network and the smallest size that + # boots. A key as well as a password: cloud images ship sshd with password + # login off, so the password serves the console and sudo, and the key is + # how Ansible gets in. Applied to an adopted VM too -- it is idempotent, + # and cloud-init picks a changed config up at the next boot. + - name: Set the size, cloud-init user, key and network + community.proxmox.proxmox_kvm: + node: "{{ env.PROXMOX_NODE }}" + vmid: "{{ vmid }}" + cores: "{{ env.PROXMOX_TEST_CORES | default(omit, true) }}" + memory: "{{ env.PROXMOX_TEST_MEMORY | default(omit, true) }}" + ostype: l26 + ciuser: "{{ env.PROXMOX_TEST_USER }}" + cipassword: "{{ env.PROXMOX_TEST_PASSWORD }}" + sshkeys: "{{ lookup('file', env.PROXMOX_TEST_SSH_KEY ~ '.pub') }}" + nameservers: "{{ [env.PROXMOX_TEST_NAMESERVER] if env.PROXMOX_TEST_NAMESERVER | default('') | length > 0 else omit }}" + searchdomains: ["{{ env.PROXMOX_TEST_DOMAIN }}"] + ipconfig: + ipconfig0: "{{ 'ip=' ~ ip ~ ',gw=' ~ env.PROXMOX_TEST_GATEWAY if ip | length > 0 else 'ip=dhcp' }}" + update: true + register: proxmox_cloudinit + + - name: Read the disk size + community.proxmox.proxmox_vm_info: + node: "{{ env.PROXMOX_NODE }}" + vmid: "{{ vmid }}" + config: current + register: proxmox_config + + # Grown, never shrunk: the API refuses a shrink, so an adopted VM already + # at size is left alone. cloud-init grows the root filesystem at boot. + - name: Grow the disk + community.proxmox.proxmox_disk: + vmid: "{{ vmid }}" + disk: scsi0 + size: "{{ env.PROXMOX_TEST_DISK }}" + state: resized + when: + - env.PROXMOX_TEST_DISK | default('') | length > 0 + - proxmox_config.proxmox_vms[0].config.scsi0 is not search('size=' ~ env.PROXMOX_TEST_DISK ~ '(,|$)') + register: proxmox_disk + + # A changed cloud-init config applies only at boot, so a running adopted VM + # is bounced. A fresh clone is already stopped and this is a no-op. + - name: Stop the VM so a cloud-init change applies at boot + community.proxmox.proxmox_kvm: + node: "{{ env.PROXMOX_NODE }}" + vmid: "{{ vmid }}" + state: stopped + force: true + timeout: 300 + when: proxmox_cloudinit is changed or proxmox_disk is changed + + - name: Start the VM + community.proxmox.proxmox_kvm: + node: "{{ env.PROXMOX_NODE }}" + vmid: "{{ vmid }}" + state: started + + # Gated on sshd answering. The timeout is a backstop for a VM that never + # came up, not a timer to race. + - name: Wait for SSH + ansible.builtin.wait_for: + host: "{{ vm_host }}" + port: 22 + timeout: 600 + + - name: Register the VM for the upgrade play + ansible.builtin.add_host: + name: "{{ vm_host }}" + groups: new_vm + ansible_user: "{{ env.PROXMOX_TEST_USER }}" + ansible_ssh_private_key_file: "{{ env.PROXMOX_TEST_SSH_KEY }}" + ansible_become_password: "{{ env.PROXMOX_TEST_PASSWORD }}" + # A fresh clone has a fresh host key every time. IdentitiesOnly: the + # client would otherwise offer every agent key first and exhaust + # sshd's MaxAuthTries before reaching this one. + ansible_ssh_common_args: -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o IdentitiesOnly=yes + +- name: Bring the base image current + hosts: new_vm + become: true + gather_facts: true + tasks: + # sshd answers before cloud-init has finished its own package work on the + # first boot, and the two would fight over the dpkg lock. This blocks on + # cloud-init's own completion signal. rc 2 is "done with recoverable + # errors", which a fresh image reports for warnings that do not matter here. + - name: Wait for cloud-init to finish + ansible.builtin.command: cloud-init status --wait + register: proxmox_cloud_init + changed_when: false + failed_when: proxmox_cloud_init.rc not in [0, 2] + + - name: Upgrade every package (Debian family) + ansible.builtin.apt: + update_cache: true + upgrade: dist + when: ansible_facts['os_family'] == 'Debian' + + # The whole point of this task is "everything to latest", which is the one + # case the package-latest rule exists to question. + - name: Upgrade every package (RedHat family) # noqa: package-latest + ansible.builtin.dnf: + name: "*" + state: latest + when: ansible_facts['os_family'] == 'RedHat' + +- name: Stop and snapshot the clean state + hosts: localhost + connection: local + gather_facts: false + # Play vars do not cross plays; -e still overrides both. + vars: + snapshot: clean + module_defaults: + community.proxmox.proxmox_kvm: *api + community.proxmox.proxmox_snap: *api + tasks: + # Stopped first. A snapshot of a running VM without vmstate is a crash + # image, and every rollback to it is then a hard reset. + - name: Stop the VM + community.proxmox.proxmox_kvm: + node: "{{ env.PROXMOX_NODE }}" + vmid: "{{ vmid }}" + state: stopped + force: true + timeout: 300 + + - name: Snapshot the clean state + community.proxmox.proxmox_snap: + vmid: "{{ vmid }}" + snapname: "{{ snapshot }}" + vmstate: false + state: present + + - name: Report + ansible.builtin.debug: + msg: >- + {{ vm_name }} is vmid {{ vmid }}, stopped, snapshot '{{ snapshot }}'. + Reset it with: ansible-playbook tests/proxmox/reset.yml -e vmid={{ vmid }} diff --git a/ansible/tests/proxmox/delete.yml b/ansible/tests/proxmox/delete.yml new file mode 100644 index 0000000..e3cd842 --- /dev/null +++ b/ansible/tests/proxmox/delete.yml @@ -0,0 +1,58 @@ +--- +# Delete a test VM and its disks. Refuses a vmid outside the test range. +# +# ansible-playbook tests/proxmox/delete.yml -e vmid=8102 + +- name: Delete a test VM + hosts: localhost + connection: local + gather_facts: false + module_defaults: + community.proxmox.proxmox_vm_info: &api + api_host: "{{ env.PROXMOX_HOST }}" + # A token, never a password. PROXMOX_TOKEN_ID is user@realm!name as + # hyperi-infra stores it; the modules take the two halves separately. + api_user: "{{ env.PROXMOX_TOKEN_ID.split('!')[0] }}" + api_token_id: "{{ env.PROXMOX_TOKEN_ID.split('!')[1] }}" + api_token_secret: "{{ env.PROXMOX_TOKEN_SECRET }}" + validate_certs: "{{ env.PROXMOX_API_VERIFY_SSL | default('true') | bool }}" + community.proxmox.proxmox_kvm: *api + tasks: + - name: Require a vmid + ansible.builtin.assert: + that: vmid is defined and vmid | string | length > 0 + fail_msg: "pass -e vmid=" + quiet: true + + - name: Load tests/.env + ansible.builtin.include_tasks: + file: ../common/setup_env.yml + + - name: Refuse a vmid outside the test range + ansible.builtin.include_tasks: + file: tasks/require_test_vmid.yml + + - name: Look up the VM + community.proxmox.proxmox_vm_info: + node: "{{ env.PROXMOX_NODE }}" + vmid: "{{ vmid }}" + register: proxmox_vm + + - name: Refuse a vmid that does not exist + ansible.builtin.assert: + that: proxmox_vm.proxmox_vms | length == 1 + fail_msg: "no VM with vmid {{ vmid }} on {{ env.PROXMOX_NODE }}" + quiet: true + + # force: a running VM is stopped first rather than refused. + - name: Delete the VM + community.proxmox.proxmox_kvm: + node: "{{ env.PROXMOX_NODE }}" + vmid: "{{ vmid }}" + state: absent + force: true + timeout: 300 + + - name: Report + ansible.builtin.debug: + msg: "vmid {{ vmid }} ({{ proxmox_vm.proxmox_vms[0].name }}) deleted." diff --git a/ansible/tests/proxmox/reset.yml b/ansible/tests/proxmox/reset.yml new file mode 100644 index 0000000..f98865a --- /dev/null +++ b/ansible/tests/proxmox/reset.yml @@ -0,0 +1,145 @@ +--- +# Return a test VM to its clean snapshot, start it, and write an inventory for +# it, so hyperi-developer can be run against a known state and then the run +# thrown away. +# +# ansible-playbook tests/proxmox/reset.yml -e vmid=8101 +# ansible-playbook -i tests/proxmox/inventory_proxmox.yml playbooks/main.yml --tags developer-rust +# ansible-playbook tests/proxmox/reset.yml -e vmid=8101 -e start=false +# +# A test VM is off except while a test runs. The last line returns it to the +# snapshot and leaves it stopped. +# +# Snapshot name, login user and password default to the TEST VMS block in +# tests/.env and can be overridden per VM: +# +# ansible-playbook tests/proxmox/reset.yml -e vmid=8100 -e snapshot=initial_build \ +# -e test_user=dfe -e test_password=dfe + +- name: Reset a test VM to its clean snapshot + hosts: localhost + connection: local + gather_facts: false + vars: + start: true + module_defaults: + community.proxmox.proxmox_vm_info: &api + api_host: "{{ env.PROXMOX_HOST }}" + # A token, never a password. PROXMOX_TOKEN_ID is user@realm!name as + # hyperi-infra stores it; the modules take the two halves separately. + api_user: "{{ env.PROXMOX_TOKEN_ID.split('!')[0] }}" + api_token_id: "{{ env.PROXMOX_TOKEN_ID.split('!')[1] }}" + api_token_secret: "{{ env.PROXMOX_TOKEN_SECRET }}" + validate_certs: "{{ env.PROXMOX_API_VERIFY_SSL | default('true') | bool }}" + community.proxmox.proxmox_kvm: *api + community.proxmox.proxmox_snap: *api + tasks: + - name: Require a vmid + ansible.builtin.assert: + that: vmid is defined and vmid | string | length > 0 + fail_msg: "pass -e vmid=" + quiet: true + + - name: Load tests/.env + ansible.builtin.include_tasks: + file: ../common/setup_env.yml + + - name: Resolve the per-VM settings + ansible.builtin.set_fact: + snapshot: "{{ snapshot | default('clean', true) }}" + test_user: "{{ test_user | default(env.PROXMOX_TEST_USER, true) }}" + test_password: "{{ test_password | default(env.PROXMOX_TEST_PASSWORD, true) }}" + + - name: Refuse a vmid outside the test range + ansible.builtin.include_tasks: + file: tasks/require_test_vmid.yml + + - name: Look up the VM + community.proxmox.proxmox_vm_info: + node: "{{ env.PROXMOX_NODE }}" + vmid: "{{ vmid }}" + config: current + register: proxmox_vm + + - name: Refuse a vmid that does not exist + ansible.builtin.assert: + that: proxmox_vm.proxmox_vms | length == 1 + fail_msg: "no VM with vmid {{ vmid }} on {{ env.PROXMOX_NODE }}" + quiet: true + + # A static VM is reached by its address, so nothing here waits on DNS. A + # DHCP VM is reached by the name the lab DNS registers for its lease: the + # VM name with the domain appended, unless it is already a FQDN. + - name: Resolve the address Ansible reaches the VM on + ansible.builtin.set_fact: + vm_host: >- + {{ proxmox_static[0] if proxmox_static | length > 0 else + (proxmox_name if '.' in proxmox_name else proxmox_name ~ '.' ~ env.PROXMOX_TEST_DOMAIN) }} + vars: + proxmox_name: "{{ proxmox_vm.proxmox_vms[0].name }}" + proxmox_static: "{{ proxmox_vm.proxmox_vms[0].config.ipconfig0 | default('') | regex_findall('ip=([0-9.]+)/') }}" + + # Stopped before rollback. A rollback of a running VM to a snapshot taken + # without vmstate is a hard reset, and the stop makes the ordering explicit + # rather than incidental. + - name: Stop the VM + community.proxmox.proxmox_kvm: + node: "{{ env.PROXMOX_NODE }}" + vmid: "{{ vmid }}" + state: stopped + force: true + timeout: 300 + + - name: Roll back to the snapshot + community.proxmox.proxmox_snap: + vmid: "{{ vmid }}" + snapname: "{{ snapshot }}" + state: rollback + + - name: Start the VM + community.proxmox.proxmox_kvm: + node: "{{ env.PROXMOX_NODE }}" + vmid: "{{ vmid }}" + state: started + when: start | bool + + - name: Wait for SSH + ansible.builtin.wait_for: + host: "{{ vm_host }}" + port: 22 + timeout: 600 + when: start | bool + + - name: Drop the inventory of a VM left stopped + ansible.builtin.file: + path: "{{ playbook_dir }}/inventory_proxmox.yml" + state: absent + when: not start | bool + + # Overwritten on every reset, and gitignored: it carries the login. + - name: Write the inventory + when: start | bool + ansible.builtin.copy: + dest: "{{ playbook_dir }}/inventory_proxmox.yml" + mode: '0600' + content: | + [test_vm] + {{ vm_host }} + + [test_vm:vars] + ansible_user={{ test_user }} + ansible_ssh_private_key_file={{ env.PROXMOX_TEST_SSH_KEY }} + ansible_become_password={{ test_password }} + # A rolled-back VM presents the same host key every time, but a + # recreated one does not, and this file is thrown away with it. + # IdentitiesOnly: the client would otherwise offer every agent key + # first and exhaust sshd's MaxAuthTries before reaching this one. + ansible_ssh_common_args=-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o IdentitiesOnly=yes + + - name: Report + ansible.builtin.debug: + msg: >- + vmid {{ vmid }} ({{ proxmox_vm.proxmox_vms[0].name }}) is back at + '{{ snapshot }}' and + {{ 'running at ' ~ vm_host ~ '. Next: ansible-playbook -i tests/proxmox/inventory_proxmox.yml playbooks/main.yml' + if start | bool else 'stopped.' }} diff --git a/ansible/tests/proxmox/tasks/require_test_vmid.yml b/ansible/tests/proxmox/tasks/require_test_vmid.yml new file mode 100644 index 0000000..a026d65 --- /dev/null +++ b/ansible/tests/proxmox/tasks/require_test_vmid.yml @@ -0,0 +1,12 @@ +--- +# The playbooks that include this roll back or delete a VM. The declared test +# range is the only place they may do that. +- name: Refuse a vmid outside the test range + ansible.builtin.assert: + that: + - vmid | int >= env.PROXMOX_TEST_VMID_MIN | int + - vmid | int <= env.PROXMOX_TEST_VMID_MAX | int + fail_msg: >- + vmid {{ vmid }} is outside the test range + {{ env.PROXMOX_TEST_VMID_MIN }}-{{ env.PROXMOX_TEST_VMID_MAX }} + quiet: true