aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-20 23:40:56 -0500
committerCraig Jennings <c@cjennings.net>2026-07-20 23:40:56 -0500
commit866d327f0bad3a0730801a22654bac45e02ee14b (patch)
tree4e73da36583178812fc7c36622ae3f6ec161606d /tests
parentc076a79dddecf5680d738477402e50ee2ac09c0f (diff)
downloadarchsetup-866d327f0bad3a0730801a22654bac45e02ee14b.tar.gz
archsetup-866d327f0bad3a0730801a22654bac45e02ee14b.zip
fix(test): harden four VM-framework paths from the S5 audit
init_vm_paths suffixed the disk and NVRAM by FS_PROFILE but left PID_FILE, MONITOR_SOCK, and SERIAL_LOG shared. A concurrent btrfs and zfs run read each other's PID file, so the second run's stop logic could kill the first run's VM. All runtime paths now carry the suffix. kill_qemu sent kill -9 and returned without waiting, so the force-kill fallback could run qemu-img snapshot against a qcow2 the dying qemu still held locked (the restore failed silently behind || true, leaving the base image dirty). It now reaps or polls the process to death before returning. debug-vm.sh hardcoded the btrfs base-disk name, so FS_PROFILE=zfs booted the wrong base or fatalled. It now takes DISK_PATH from init_vm_paths. Both runners reported a completion-marker grep as "ArchSetup Exit Code," but the installer runs detached with set -e off, so its real exit status was never captured -- a run that errored mid-way and still reached its last line reported exit 0. The flag is now ARCHSETUP_COMPLETED with the report labeled honestly; Testinfra stays the pass/fail authority.
Diffstat (limited to 'tests')
-rw-r--r--tests/vm-framework/test_vm_utils.py130
1 files changed, 130 insertions, 0 deletions
diff --git a/tests/vm-framework/test_vm_utils.py b/tests/vm-framework/test_vm_utils.py
new file mode 100644
index 0000000..579955b
--- /dev/null
+++ b/tests/vm-framework/test_vm_utils.py
@@ -0,0 +1,130 @@
+"""Tests for the VM test framework's vm-utils helpers.
+
+Two robustness bugs under test:
+
+1. init_vm_paths suffixed DISK_PATH and OVMF_VARS by FS_PROFILE but left
+ PID_FILE, MONITOR_SOCK, and SERIAL_LOG unsuffixed, so a concurrent btrfs
+ and zfs run shared them -- the second run's stop/is-running logic read the
+ first run's PID and could kill the other VM. All runtime paths now carry
+ the profile suffix.
+2. kill_qemu sent kill -9 and deleted the PID file without waiting for the
+ process to die, so a force-kill fallback could run qemu-img snapshot
+ against a qcow2 the dying qemu still held locked. kill_qemu now waits for
+ the process to be dead (or reaped) before returning.
+
+Method: sed-extract init_vm_paths / kill_qemu / _cleanup_qemu_files from
+lib/vm-utils.sh and drive them with temp dirs and real background processes.
+
+Run from repo root:
+ python3 -m unittest tests.vm-framework.test_vm_utils
+"""
+
+import os
+import subprocess
+import tempfile
+import textwrap
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+VM_UTILS = os.path.join(REPO_ROOT, "scripts", "testing", "lib", "vm-utils.sh")
+
+EXTRACT = (
+ "source <(sed -n '/^init_vm_paths() {{/,/^}}/p;"
+ "/^kill_qemu() {{/,/^}}/p;"
+ "/^_cleanup_qemu_files() {{/,/^}}/p' \"{lib}\")"
+).format(lib=VM_UTILS)
+
+
+def run(body):
+ script = f"fatal() {{ echo \"FATAL: $*\"; exit 1; }}\nwarn() {{ :; }}\n{EXTRACT}\n{body}\n"
+ return subprocess.run(
+ ["bash", "-c", script], capture_output=True, text=True, timeout=30,
+ )
+
+
+class InitVmPaths(unittest.TestCase):
+ def paths_for(self, profile):
+ with tempfile.TemporaryDirectory() as d:
+ body = textwrap.dedent(f"""\
+ FS_PROFILE={profile}
+ init_vm_paths "{d}"
+ echo "DISK=$DISK_PATH"
+ echo "VARS=$OVMF_VARS"
+ echo "PID=$PID_FILE"
+ echo "MON=$MONITOR_SOCK"
+ echo "SER=$SERIAL_LOG"
+ """)
+ return run(body).stdout
+
+ def test_zfs_profile_suffixes_all_runtime_paths(self):
+ out = self.paths_for("zfs")
+ self.assertIn("archsetup-base-zfs.qcow2", out)
+ self.assertIn("OVMF_VARS-zfs.fd", out)
+ # The bug: these three shared one name across profiles.
+ self.assertIn("PID=", out)
+ self.assertRegex(out, r"PID=.*-zfs",
+ "PID_FILE must carry the profile suffix")
+ self.assertRegex(out, r"MON=.*-zfs",
+ "MONITOR_SOCK must carry the profile suffix")
+ self.assertRegex(out, r"SER=.*-zfs",
+ "SERIAL_LOG must carry the profile suffix")
+
+ def test_btrfs_profile_keeps_legacy_unsuffixed_names(self):
+ out = self.paths_for("btrfs")
+ self.assertIn("DISK=", out)
+ self.assertIn("archsetup-base.qcow2", out)
+ self.assertNotIn("-btrfs", out)
+
+ def test_invalid_profile_fatals(self):
+ out = self.paths_for("ext4")
+ self.assertIn("FATAL", out)
+
+
+class KillQemu(unittest.TestCase):
+ def test_process_is_dead_before_return(self):
+ # Contract pin for the snapshot-restore race: when kill_qemu returns,
+ # the process must be dead or reaped -- state Z or /proc entry gone --
+ # so a following qemu-img call can't hit a still-held qcow2 lock.
+ with tempfile.TemporaryDirectory() as d:
+ body = textwrap.dedent(f"""\
+ PID_FILE="{d}/qemu.pid"
+ MONITOR_SOCK="{d}/qemu-monitor.sock"
+ sleep 60 & victim=$!
+ echo "$victim" > "$PID_FILE"
+ kill_qemu
+ state=$(awk '{{print $3}}' "/proc/$victim/stat" 2>/dev/null || echo GONE)
+ echo "STATE=$state"
+ [ -f "$PID_FILE" ] && echo "PIDFILE=present" || echo "PIDFILE=removed"
+ """)
+ r = run(body)
+ self.assertRegex(r.stdout, r"STATE=(Z|GONE)",
+ f"process still live after kill_qemu: {r.stdout}")
+ self.assertIn("PIDFILE=removed", r.stdout)
+
+ def test_stale_pid_file_is_cleaned_quietly(self):
+ with tempfile.TemporaryDirectory() as d:
+ body = textwrap.dedent(f"""\
+ PID_FILE="{d}/qemu.pid"
+ MONITOR_SOCK="{d}/qemu-monitor.sock"
+ echo 99999999 > "$PID_FILE"
+ kill_qemu
+ echo "RC=$?"
+ [ -f "$PID_FILE" ] && echo "PIDFILE=present" || echo "PIDFILE=removed"
+ """)
+ r = run(body)
+ self.assertIn("RC=0", r.stdout)
+ self.assertIn("PIDFILE=removed", r.stdout)
+
+ def test_no_pid_file_is_a_noop(self):
+ with tempfile.TemporaryDirectory() as d:
+ body = textwrap.dedent(f"""\
+ PID_FILE="{d}/qemu.pid"
+ MONITOR_SOCK="{d}/qemu-monitor.sock"
+ kill_qemu
+ echo "RC=$?"
+ """)
+ self.assertIn("RC=0", run(body).stdout)
+
+
+if __name__ == "__main__":
+ unittest.main()