From ed1e02951414852a4cc858fa3511850f76e9121a Mon Sep 17 00:00:00 2001 From: Derek Date: Fri, 4 Sep 2026 08:43:53 +1000 Subject: [PATCH] fix(tests): snapshot-reset harness for Proxmox VMs Three playbooks under tests/proxmox/, driven by hand, not CI. create.yml clones the base template into the test range, sizes it to the lab's other test machines, gives it a static address or DHCP, boots it once to bring every package current, then stops and snapshots the VM as `clean`. hyperi-developer never runs there: the snapshot is the state every test starts from. reset.yml rolls a VM back to that snapshot and starts it, or with start=false leaves it stopped, which is the resting state of a test VM. delete.yml removes one. All three refuse a vmid outside the range, which is the safety mechanism .env.sample had promised and nothing had implemented. A static VM is reached by its address and a DHCP VM by the name the lab DNS registers for it, so nothing waits on a DNS entry that may not exist yet. The free-vmid picker now sorts: difference() is a set operation and its first element was arbitrary. Authentication is the API token hyperi-infra's tools use, not a root password, and the modules are community.proxmox: the community.general proxmox_* modules are deprecated and removed at 15.0.0, which the existing floor reaches. Login to the VM is by key, because cloud images ship sshd with password login off. The shared loader in tests/common/setup_env.yml ran `source .env` under /bin/sh, which is dash on Debian and Ubuntu and has no `source`. The failure was silent - the pipeline's exit status is jq's - so `env` returned whatever PROXMOX_* the operator's shell already carried, and a token from the environment quietly stood in for the file's. It now runs under bash with set -euo pipefail. provision.yml, which shares the loader, only ever worked by the same accident. create.yml adopts a VM of the same name so a run that stopped partway is finished rather than doubled. Proven against Proxmox: a VM created, rolled back, started, converged and returned to its snapshot stopped. --- ansible/requirements.yml | 6 + ansible/tests/.env.sample | 58 +++- ansible/tests/common/setup_env.yml | 8 + ansible/tests/proxmox/README.md | 61 +++++ ansible/tests/proxmox/create.yml | 247 ++++++++++++++++++ ansible/tests/proxmox/delete.yml | 58 ++++ ansible/tests/proxmox/reset.yml | 145 ++++++++++ .../tests/proxmox/tasks/require_test_vmid.yml | 12 + 8 files changed, 592 insertions(+), 3 deletions(-) create mode 100644 ansible/tests/proxmox/README.md create mode 100644 ansible/tests/proxmox/create.yml create mode 100644 ansible/tests/proxmox/delete.yml create mode 100644 ansible/tests/proxmox/reset.yml create mode 100644 ansible/tests/proxmox/tasks/require_test_vmid.yml 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