utils/vmbox/lib/launch.sh
Krzysztof kuhy Rudnicki a2d672f7fd vmbox: disposable Arch VM sandbox for destructive system tests
Much of testsAndMisc/linux_configuration cannot be tested on the host: 15
files use `chattr +i` (so a bad test is not simply revertable), the shutdown
installers write units that power the machine off, and boot_recovery installs
pacman hooks. The repo's own CI says as much -- its Ubuntu job runs only the
side-effect-free tests, noting the rest "drive pacman, /etc, systemd or adb".

vmbox runs them in a throwaway Arch guest and verifies the result from the
HOST, which is the part that is not obvious: a successful shutdown kills the
connection carrying the report, and ssh returns 255 for a clean poweroff, a
crash and a dropped link alike. So the verdict comes from two artefacts that
outlive the VM -- a QMP event log written by a recorder attached at launch
(attach later and qemu has already exited), and the serial console log.

Five outcomes are distinguished: clean poweroff (SHUTDOWN guest:true plus a
serial tail reaching "reboot: Power down"), dirty stop, guest-reset (rebooted
instead of powering off), GUEST_PANICKED, and hung. A POWERDOWN event proves
nothing -- it means the request was delivered, not that anything acted on it.

Reset is a qcow2 backing-file trick: the golden image is built once and sealed
(chmod 444 plus a sha256 sidecar verified on every launch, since booting the
base would silently invalidate every overlay), and each sandbox is a thin
overlay, so reset is rm + recreate. No root anywhere -- user-mode networking
with per-VM forwarded ssh ports, and a socket multicast segment for VM-to-VM.

The guest clock is pinned and re-applied on every launch, because the shutdown
installers gate on a 21:00-05:00 window; NTP is masked in the image or it
silently undoes the pin. Repos are cloned from a read-only share scoped to an
explicit directory, never $HOME, and credentials are sentinel values.

`vm build` ends by booting a throwaway sandbox off the sealed image and
refusing to ship if ssh does not answer. Several distinct faults produced an
identical "boots fine, connection reset" symptom; checks inside the build VM
only prove state at seal time, so the gate has to be an end-to-end boot.

Tests: 8 bats cases over the verdict table and name/meta/serial handling;
shellcheck and ruff clean; every file within the 250-line cap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
2026-08-22 20:47:56 +02:00

105 lines
4.2 KiB
Bash

#!/bin/bash
# ============================================================================
# vmbox: assemble the qemu command line and start a sandbox.
#
# Every observation channel the verdict logic depends on is wired here:
# - QMP socket + a persistent event recorder (started BEFORE the guest can
# act, so a fast poweroff cannot race it)
# - per-boot serial log (append, never truncate)
# - pvpanic, so a kernel panic is distinguishable from a clean stop
# ============================================================================
# shellcheck source=common.sh
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
readonly VMBOX_MEM="${VMBOX_MEM:-4096}"
readonly VMBOX_SMP="${VMBOX_SMP:-4}"
# $1 = vm name. Echoes the serial-log index it started.
launch_vm() {
local name="$1"
require_vm "$name"
vm_is_running "$name" && { log "sandbox '$name' is already running"; return 0; }
source "$VMBOX_LIB_DIR/overlay.sh"
overlay_verify_base
local overlay port index rtc serial_n serial qmp share
overlay="$(vm_overlay "$name")"
port="$(meta_get "$name" ssh_port)"
index="$(meta_get "$name" index)"
rtc="$(meta_get "$name" rtc 2>/dev/null || true)"
serial_n="$(vm_next_serial "$name")"
serial="$(vm_serial "$name" "$serial_n")"
qmp="$(vm_qmp_sock "$name")"
# Symlink the repos you want testable into here; nothing else is exposed.
share="${VMBOX_SHARE:-$VMBOX_HOME/share}"
install -d -m 755 "$share"
# Refuse to boot the base itself -- doing so would corrupt every overlay.
[[ "$(realpath "$overlay")" == "$(realpath "$VMBOX_BASE_IMG")" ]] &&
die "refusing to boot the base image directly"
rm -f "$qmp"
# shellcheck disable=SC2054 # commas are qemu option syntax, not separators
local -a args=(
-name "vmbox-$name"
-machine q35,accel=kvm -cpu host
-smp "$VMBOX_SMP" -m "$VMBOX_MEM"
-drive file="$overlay",if=virtio,format=qcow2
-netdev "user,id=net0,hostfwd=tcp:127.0.0.1:${port}-:22"
-device virtio-net-pci,netdev=net0
# Shared segment so sandboxes can reach each other. No root, no bridge.
-netdev "socket,id=net1,mcast=${VMBOX_MCAST}"
-device "virtio-net-pci,netdev=net1,mac=52:54:00:be:ef:$(printf '%02x' "$index")"
# Repos under test, mounted PHYSICALLY read-only. Scoped to an
# explicit share dir -- NEVER $HOME, which would hand the guest
# ~/.ssh, ~/.claude and ~/.config/crdt-sync. The guest clones out of
# this into its own home before touching anything.
-virtfs "local,path=$share,mount_tag=hostrepo,security_model=mapped-xattr,readonly=on"
-display none
-device virtio-vga
-chardev file,id=ser0,path="$serial",append=on
-serial chardev:ser0
-qmp "unix:${qmp},server=on,wait=off"
# A QMP socket serves ONE client, and the recorder holds that one for
# the VM's whole life. Screenshots and status queries therefore need
# their own channel or they block until the recorder exits.
-qmp "unix:${qmp}.ctl,server=on,wait=off"
-device pvpanic
# A guest *reset* becomes a SHUTDOWN(guest-reset) event, which is how a
# script that reboots instead of powering off is caught.
-no-reboot
-action panic=pause
-pidfile "$(vm_pidfile "$name")"
)
[[ -n "$rtc" ]] && args+=(-rtc "base=$rtc")
_launch_qemu "$name" "$serial" "$qmp" args
printf '%s' "$serial_n"
}
_launch_qemu() {
local name="$1" serial="$2" qmp="$3"
local -n argref="$4"
qemu-system-x86_64 "${argref[@]}" >/dev/null 2>>"$serial.qemu-stderr" &
disown
# Wait for the QMP socket, then attach the recorder BEFORE anything runs in
# the guest: attaching after the fact loses the SHUTDOWN event entirely
# (qemu exits, the stream closes, and there is no verdict to read).
local waited=0
while [[ ! -S "$qmp" ]] && (( waited < 100 )); do
sleep 0.1; waited=$(( waited + 1 ))
done
[[ -S "$qmp" ]] || die "qemu did not create a QMP socket -- see $serial.qemu-stderr"
nohup python3 "$VMBOX_LIB_DIR/recorder.py" "$qmp" "$(vm_events "$name")" \
>/dev/null 2>&1 &
disown
}