utils/vmbox/bin/vm
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

88 lines
3.6 KiB
Bash
Executable File

#!/bin/bash
# ============================================================================
# vm -- disposable Arch VM sandbox.
#
# Runs destructive system scripts (shutdown installers, chattr +i guards,
# pacman hooks) against a throwaway Arch guest, and verifies the outcome from
# the HOST -- a guest that powers itself off cannot report its own success.
# ============================================================================
set -euo pipefail
VMBOX_BIN_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd -P)"
readonly VMBOX_BIN_DIR
# shellcheck source=../lib/common.sh
source "$VMBOX_BIN_DIR/../lib/common.sh"
usage() {
cat <<'USAGE'
Usage: vm <command> [args]
build Build the golden base image (once, ~10 min)
new <name> [--rtc <ts>] Create a sandbox (instant; thin overlay)
run <name> <command...> Run a command in the sandbox, report the verdict
ssh <name> [args...] Interactive shell in the sandbox
repo <name> <path> [--worktree]
Copy a host repo in (clone=HEAD, --worktree=uncommitted)
screenshot <name> [out.png] Capture the guest screen (for locker tests)
reset <name> Wipe back to pristine (sub-second)
rm <name> Delete the sandbox entirely
list List sandboxes and their state
--rtc <ts> Pin the guest clock, e.g. 2026-08-22T20:55:00. Stored in the
VM's meta and re-applied on EVERY launch: the shutdown scripts
are gated on a 21:00-05:00 window, so an unpinned clock makes
their tests nondeterministic.
Examples:
vm new demo --rtc 2026-08-22T20:55:00
vm run demo 'sudo systemctl poweroff' # -> verdict: clean poweroff
vm reset demo # back to pristine
USAGE
}
# vm repo <name> <path> [--worktree]
cmd_repo() {
local name="${1:-}" src="${2:-}" mode=clone
[[ -n "$name" && -n "$src" ]] || die "usage: vm repo <name> <host-path> [--worktree]"
shift 2
[[ "${1:-}" == "--worktree" ]] && mode=worktree
repo_sync "$name" "$src" "$mode"
}
cmd_list() {
[[ -d "$VMBOX_VMS_DIR" ]] || { echo "no sandboxes yet (create one: vm new <name>)"; return 0; }
local found=0 d name state rtc
printf '%-20s %-12s %s\n' "NAME" "STATE" "RTC"
for d in "$VMBOX_VMS_DIR"/*/; do
[[ -d "$d" ]] || continue
found=1
name="$(basename "$d")"
if vm_is_running "$name"; then state="running"; else state="stopped"; fi
rtc="$(meta_get "$name" rtc 2>/dev/null || echo '-')"
printf '%-20s %-12s %s\n' "$name" "$state" "${rtc:--}"
done
(( found )) || echo "(none)"
}
main() {
local cmd="${1:-}"
[[ $# -gt 0 ]] && shift || true
case "$cmd" in
build) require_host_deps; source "$VMBOX_LIB_DIR/base.sh"; base_build "$@" ;;
new) require_host_deps; source "$VMBOX_LIB_DIR/overlay.sh"; overlay_new "$@" ;;
reset) source "$VMBOX_LIB_DIR/overlay.sh"; overlay_reset "$@" ;;
rm) source "$VMBOX_LIB_DIR/overlay.sh"; overlay_rm "$@" ;;
run) require_host_deps; source "$VMBOX_LIB_DIR/run.sh"; run_in_vm "$@" ;;
ssh) source "$VMBOX_LIB_DIR/ssh.sh"; vm_ssh_interactive "$@" ;;
screenshot) source "$VMBOX_LIB_DIR/screenshot.sh"; vm_screenshot "$@" ;;
repo) source "$VMBOX_LIB_DIR/repo.sh"; cmd_repo "$@" ;;
list|ls) cmd_list ;;
-h|--help|help|'') usage ;;
*) die "unknown command '$cmd' (try: vm --help)" ;;
esac
}
main "$@"