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
This commit is contained in:
Krzysztof Rudnicki 2026-08-22 20:47:56 +02:00
parent 4c024a24f0
commit a2d672f7fd
21 changed files with 1832 additions and 0 deletions

7
vmbox/.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
# VM state lives in ~/.local/share/vmbox, never in the repo: these are
# multi-GB disk images and live sockets. Listed here only as a guard against
# an accidental copy landing in the tree.
*.qcow2
*.iso
*.sock
*.log

View File

@ -0,0 +1,26 @@
# vmbox hooks. The `files: ^vmbox/` scoping below is REQUIRED by
# meta/scripts/check_subproject_scope.sh -- without that literal line every
# hook here silently passes instead of running.
repos:
- repo: https://github.com/koalaman/shellcheck-precommit
rev: v0.10.0
hooks:
- id: shellcheck
files: ^vmbox/
args: [--severity=warning, --external-sources]
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.9
hooks:
- id: ruff
files: ^vmbox/
- id: ruff-format
files: ^vmbox/
- repo: local
hooks:
- id: file-length-cap
name: enforce 250-line cap
entry: bash -c 'for f in "$@"; do n=$(wc -l < "$f"); if [ "$n" -gt 250 ]; then echo "$f: $n lines exceeds the 250-line cap"; exit 1; fi; done' --
language: system
files: ^vmbox/

103
vmbox/README.md Normal file
View File

@ -0,0 +1,103 @@
# vmbox — a disposable Arch VM sandbox
Run destructive system scripts — shutdown installers, `chattr +i` hosts guards,
pacman hooks — against a throwaway Arch guest, and **verify the outcome from the
host**. Break it as thoroughly as you like; reset takes under a second.
```bash
./install.sh # installs deps (libisoburn), links `vm` onto PATH
vm build # builds the golden image, once (~10 min)
vm new demo # instant: a ~200 KB overlay on the sealed base
vm run demo 'sudo systemctl poweroff'
vm reset demo # back to pristine
```
## Why this exists
Much of `testsAndMisc/linux_configuration` cannot be tested on the real machine.
The repo's own CI says so — its Ubuntu job runs only the "side-effect-free"
tests and notes the rest *"drive pacman, /etc, systemd or adb and cannot run on
an Ubuntu runner without root and an Arch container"*. Locally they probe the
**live** system (`test_results.log` asserts `PASS: /etc/hosts is immutable`).
Three things make testing them on the host genuinely unsafe:
- **`chattr +i` immutability** — 15 files make `/etc/hosts` and friends
immutable. A bad test is not simply revertable; `rm` fails with EPERM.
- **Auto-poweroff** — the shutdown installers write systemd units that power the
machine off on a schedule.
- **Pacman hooks** — `boot_recovery/install.sh` installs pre/post-transaction
hooks and touches mkinitcpio. Blast radius is "your next update".
## The hard part: a successful shutdown kills your observer
Running a shutdown script over SSH returns a broken pipe. That is
**indistinguishable** from a crash, a hang, or a network blip — ssh returns 255
for all of them. So "did it actually shut down?" is answered from the host,
using two artefacts that outlive the VM:
| Channel | What it proves |
|---|---|
| QMP event log (`events.jsonl`) | *That* the machine stopped, and who stopped it |
| Serial console log | *How far* the shutdown sequence got |
A persistent recorder attaches at launch, **before** anything runs in the guest.
This ordering matters: attach afterwards and QEMU has already exited, the stream
is closed, and there is no verdict to read.
### Five outcomes, all host-observable
| Observation | Verdict | Exit |
|---|---|---|
| `SHUTDOWN guest:true` + serial reaches `reboot: Power down` | clean poweroff | 0 |
| `SHUTDOWN guest:true`, serial truncated | **dirty** — stopped mid-sequence | 3 |
| `SHUTDOWN reason=guest-reset` | **rebooted**, did not power off | 4 |
| `GUEST_PANICKED` | **crashed** | 5 |
| no event, liveness probe fails | **hung** | 6 |
`guest:true` alone is not enough for "shuts down *completely*" — `poweroff -f`
and a script that kills systemd mid-sequence both produce it. The serial tail is
the discriminator, and it is asserted by grep, not read by eye.
> A `POWERDOWN` event is **not** proof of anything: it means the ACPI request was
> delivered. With no OS booted, QEMU emits `POWERDOWN` and then runs forever.
## Design notes
- **Reset is a backing-file trick.** The golden image is built once and sealed
(`chmod 444` + a sha256 sidecar verified on every launch). Each sandbox is a
thin qcow2 overlay, so `vm reset` is `rm` + recreate. Booting the base itself
would silently invalidate every overlay, so the launcher refuses to.
- **No root anywhere.** User-mode networking with a per-VM forwarded SSH port;
VM-to-VM traffic rides a socket multicast segment. No bridge, no libvirt, no
daemon.
- **The guest clock is pinned** (`--rtc`) and re-applied on *every* launch. The
shutdown installers gate on a 21:00–05:00 window, so an unpinned clock makes
their tests pass or fail depending on the time of day. NTP is masked in the
image, or it would quietly undo the pin.
- **The guest gets its own clone** of the repos from a read-only mount. Real
credentials are never shared; the guest carries sentinel values instead.
## Commands
| Command | Effect |
|---|---|
| `vm build [--force]` | Build/rebuild the golden image |
| `vm new <name> [--rtc <ts>]` | Create a sandbox |
| `vm run <name> <cmd...>` | Run a command, print the verdict |
| `vm ssh <name>` | Interactive shell (starts the VM if needed) |
| `vm screenshot <name> [out.png]` | Capture the screen (locker/X11 tests) |
| `vm reset <name>` | Wipe back to pristine |
| `vm rm <name>` / `vm list` | Delete / list sandboxes |
## Out of scope
A VM cannot cover everything: adb/phone tests (~36 files), GPU monitors, the
i2c RGB in `setup_night_lockdown.sh`, and the Nextcloud/SearXNG suites (whose
fixtures assume a Debian layout) all stay on the host or stay untested.
## Tests
`bats tests/test_vmbox.bats` — host-side unit tests for name validation, meta
handling, serial rotation and the full verdict table. They do not boot a VM;
booting is covered by the destructive end-to-end demo above.

87
vmbox/bin/vm Executable file
View File

@ -0,0 +1,87 @@
#!/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 "$@"

39
vmbox/guest/dummy-creds.sh Executable file
View File

@ -0,0 +1,39 @@
#!/bin/bash
# ============================================================================
# Provision SENTINEL credentials inside a sandbox.
#
# The guest must never hold real secrets: these repos reach live GitHub and
# Firebase, and a "test" run of an installer that syncs or pushes would hit
# production. Values are deliberately greppable, so if one ever shows up in a
# log or a request it is obvious where it came from.
# ============================================================================
set -euo pipefail
readonly SENTINEL="DUMMY-NOT-A-REAL-TOKEN-vmbox"
install -d -m 700 "$HOME/.config/crdt-sync"
cat > "$HOME/.config/crdt-sync/config.json" <<JSON
{
"_comment": "vmbox sandbox credentials. Not real. Never sync with these.",
"github_token": "$SENTINEL",
"firebase_api_key": "$SENTINEL",
"firebase_db_url": "https://vmbox-sandbox.invalid/"
}
JSON
chmod 600 "$HOME/.config/crdt-sync/config.json"
# A throwaway ssh key so anything expecting one finds it, without ever
# granting access to a real host.
if [[ ! -f "$HOME/.ssh/id_ed25519" ]]; then
install -d -m 700 "$HOME/.ssh"
ssh-keygen -t ed25519 -N '' -q -C 'vmbox-sandbox-throwaway' \
-f "$HOME/.ssh/id_ed25519"
fi
git config --global user.name "vmbox sandbox"
git config --global user.email "sandbox@vmbox.invalid"
# Any push attempt should fail loudly rather than reach a real remote.
git config --global url."https://vmbox-blocked.invalid/".insteadOf "https://github.com/"
echo "sandbox credentials provisioned (all sentinel values)"

171
vmbox/guest/provision.sh Normal file
View File

@ -0,0 +1,171 @@
#!/bin/bash
# ============================================================================
# Runs INSIDE the guest during `vm build`, as root, exactly once.
# Installs the test surface (Xorg/i3, dev tools), wires serial+autologin,
# then leaves the image ready to be sealed.
# ============================================================================
set -euo pipefail
GUEST_USER="${1:-arch}"
echo "=== vmbox provision: starting ==="
# The cloud image ships an empty package DB; -Syu also picks up any security
# fixes since the image was cut.
pacman -Syu --noconfirm --needed \
base-devel git openssh sudo python python-pip \
xorg-server xorg-xinit xorg-xrandr xorg-xset i3-wm xterm dmenu \
strace jq bats shellcheck kcov rsync inetutils \
iproute2 nftables which less vim
# --- serial console -------------------------------------------------------
# The verdict logic reads the serial log to tell a clean poweroff from one
# that died mid-sequence, so the guest must actually WRITE its console there.
# We keep the VGA device for screenshots, so we cannot rely on -nographic's
# implicit redirect: request ttyS0 explicitly on the kernel cmdline.
if [[ -f /etc/default/grub ]]; then
sed -i 's/^GRUB_CMDLINE_LINUX_DEFAULT="\(.*\)"/GRUB_CMDLINE_LINUX_DEFAULT="\1 console=tty0 console=ttyS0,115200"/' \
/etc/default/grub
grub-mkconfig -o /boot/grub/grub.cfg 2>/dev/null || true
fi
systemctl enable serial-getty@ttyS0.service
# --- ssh ------------------------------------------------------------------
# `systemctl enable sshd` alone is NOT enough on this image: systemd's
# ssh-generator provides a socket-activated AF_UNIX unit, so the enable can
# no-op and leave nothing listening on TCP:22. The guest then boots perfectly
# and every ssh gets "Connection reset by peer". Force the real service on and
# verify the symlink exists, failing the build loudly if it does not.
systemctl enable sshd.service
systemctl disable sshd.socket 2>/dev/null || true
if [[ ! -e /etc/systemd/system/multi-user.target.wants/sshd.service ]]; then
echo "provision: FAILED to enable sshd.service -- guest would be unreachable" >&2
exit 1
fi
# Host keys: sshd is "enabled" but FAILS TO START without them, which looks
# identical to a network fault from the host side. Generate now and assert.
ssh-keygen -A
ls /etc/ssh/ssh_host_*_key >/dev/null 2>&1 || {
echo "provision: FAILED to generate sshd host keys" >&2
exit 1
}
echo "provision: sshd.service enabled with $(ls /etc/ssh/ssh_host_*_key | wc -l) host keys"
# --- systemd --user session ----------------------------------------------
# Several target scripts use `systemctl --user`, which needs a real session
# bus. Lingering gives the user one without an interactive login.
loginctl enable-linger "$GUEST_USER" 2>/dev/null || true
# --- serial console autologin -------------------------------------------
# Debuggability: when ssh is broken, the serial console is the ONLY way in.
# Without autologin here an unreachable guest can only be diagnosed by
# inferring from absent log lines, which is exactly how a single sshd fault
# turned into several blind image rebuilds.
install -d -m 755 /etc/systemd/system/serial-getty@ttyS0.service.d
cat > /etc/systemd/system/serial-getty@ttyS0.service.d/autologin.conf <<SUNIT
[Service]
ExecStart=
ExecStart=-/sbin/agetty --autologin root --keep-baud 115200,57600,38400,9600 %I \$TERM
SUNIT
# --- host repo share ------------------------------------------------------
# Mounted read-only; the guest clones out of it rather than working in it.
install -d -m 755 /mnt/hostrepo
if ! grep -q hostrepo /etc/fstab 2>/dev/null; then
echo 'hostrepo /mnt/hostrepo 9p trans=virtio,version=9p2000.L,ro,nofail 0 0' >> /etc/fstab
fi
# --- X11 autologin + i3 ---------------------------------------------------
# Locker tests need a real X session on tty1. Autologin then startx.
install -d -m 755 /etc/systemd/system/getty@tty1.service.d
cat > /etc/systemd/system/getty@tty1.service.d/autologin.conf <<UNIT
[Service]
ExecStart=
ExecStart=-/sbin/agetty --autologin ${GUEST_USER} --noclear %I \$TERM
UNIT
cat > "/home/$GUEST_USER/.xinitrc" <<'XINIT'
exec i3
XINIT
chown "$GUEST_USER:$GUEST_USER" "/home/$GUEST_USER/.xinitrc"
# startx on tty1 login only, so `vm ssh` sessions do not try to start X.
cat > "/home/$GUEST_USER/.bash_profile" <<'PROFILE'
[[ -f ~/.bashrc ]] && . ~/.bashrc
if [[ -z ${DISPLAY:-} && $(tty) == /dev/tty1 ]]; then
exec startx -- -keeptty >~/.xsession.log 2>&1
fi
PROFILE
chown "$GUEST_USER:$GUEST_USER" "/home/$GUEST_USER/.bash_profile"
# i3 without a config prompts a wizard that blocks the session; ship a minimal one.
install -d -m 755 "/home/$GUEST_USER/.config/i3"
cat > "/home/$GUEST_USER/.config/i3/config" <<'I3'
set $mod Mod4
font pango:monospace 10
bindsym $mod+Return exec xterm
bindsym $mod+d exec dmenu_run
bindsym $mod+Shift+q kill
# Marker window so screenshot tests have something deterministic to match.
exec --no-startup-id xterm -T vmbox-ready -e 'echo VMBOX READY; exec bash'
I3
chown -R "$GUEST_USER:$GUEST_USER" "/home/$GUEST_USER/.config"
# --- static IP on the VM-to-VM segment ------------------------------------
# The multicast segment has no DHCP (the user-mode NIC serves the other
# interface), so without a static address sandboxes cannot reach each other.
# The last octet is injected per-VM at launch via the vmbox.peer kernel arg.
cat > /usr/local/bin/vmbox-peer-ip <<'PEER'
#!/bin/bash
# Assign the peer-network address from the vmbox.peer=<n> kernel argument.
set -euo pipefail
n=$(sed -n 's/.*vmbox\.peer=\([0-9]\+\).*/\1/p' /proc/cmdline)
[[ -n "$n" ]] || exit 0
subnet=$(sed -n 's/.*vmbox\.subnet=\([0-9.]\+\).*/\1/p' /proc/cmdline)
[[ -n "$subnet" ]] || subnet=10.77.0
# The peer NIC is the one WITHOUT a default route (user-mode NIC has it).
for i in /sys/class/net/e*; do
dev=$(basename "$i")
# Skip the primary NIC: it carries ssh and already has DHCP + a default
# route. Touching it is how the sandbox becomes unreachable.
ip route show default dev "$dev" | grep -q . && continue
ip -4 addr show dev "$dev" | grep -q 'inet ' && continue
ip addr add "${subnet}.${n}/24" dev "$dev" 2>/dev/null || true
ip link set "$dev" up
break
done
PEER
chmod 755 /usr/local/bin/vmbox-peer-ip
cat > /etc/systemd/system/vmbox-peer-ip.service <<'PSVC'
[Unit]
Description=vmbox peer network address
After=network.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/vmbox-peer-ip
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
PSVC
systemctl enable vmbox-peer-ip.service
systemctl enable systemd-networkd systemd-resolved 2>/dev/null || true
# --- keep the pinned clock pinned -----------------------------------------
# The host passes -rtc base=<ts> so time-gated scripts (the shutdown installers
# key off a 21:00-05:00 window) are deterministic. systemd-timesyncd reaches
# the network on boot and silently overwrites that with real time, which
# re-introduces exactly the nondeterminism -rtc exists to remove.
systemctl disable systemd-timesyncd 2>/dev/null || true
systemctl mask systemd-timesyncd 2>/dev/null || true
# Do NOT write a /etc/systemd/network/*.network file here: an en* match
# also catches the primary NIC and outranks the cloud image's own DHCP
# config, which breaks sshd's network and makes the sandbox unreachable.
# (Learned the hard way -- it cost a full rebuild.) Masking the unit above is
# sufficient to keep the pinned clock; UseNTP only matters if timesyncd runs.
# cloud-init also writes an NTP config on some images; neutralise it.
rm -f /etc/systemd/timesyncd.conf.d/*.conf 2>/dev/null || true
echo "=== vmbox provision: done ==="

59
vmbox/install.sh Executable file
View File

@ -0,0 +1,59 @@
#!/bin/bash
# ============================================================================
# vmbox installer: installs host dependencies and links `vm` onto PATH.
#
# Deliberately NOT run as root: vmbox is designed to need no root at all
# (user-mode + multicast networking, qcow2 overlays, no bridge, no libvirt).
# Only the pacman step escalates, and only when something is missing.
# ============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
readonly SCRIPT_DIR
readonly LINK_DIR="${LINK_DIR:-$HOME/.local/bin}"
# xorriso ships in libisoburn -- there is no package named "xorriso".
readonly -a PACKAGES=(qemu-base libisoburn openssh python)
if [[ $EUID -eq 0 ]]; then
echo "Do not run install.sh as root: it links into \$HOME and would" >&2
echo "create root-owned files there. It escalates on its own if needed." >&2
exit 1
fi
echo "==> Checking host dependencies ..."
missing=()
for pkg in "${PACKAGES[@]}"; do
pacman -Qq "$pkg" >/dev/null 2>&1 || missing+=("$pkg")
done
if (( ${#missing[@]} )); then
echo "==> Installing: ${missing[*]}"
sudo pacman -S --needed --noconfirm "${missing[@]}"
else
echo " all present: ${PACKAGES[*]}"
fi
# KVM is what makes this usable rather than glacial; a missing /dev/kvm is a
# hard stop, not a warning, because the fallback is ~20x slower.
[[ -c /dev/kvm ]] || {
echo "error: /dev/kvm missing -- enable virtualization (SVM/VT-x) in firmware" >&2
exit 1
}
[[ -r /dev/kvm && -w /dev/kvm ]] || {
echo "error: /dev/kvm not read/writable by $USER -- add yourself to the 'kvm' group" >&2
exit 1
}
echo "==> Linking vm -> $LINK_DIR/vm"
install -d -m 755 "$LINK_DIR"
ln -sfn "$SCRIPT_DIR/bin/vm" "$LINK_DIR/vm"
case ":$PATH:" in
*":$LINK_DIR:"*) ;;
*) echo " note: $LINK_DIR is not on your PATH" ;;
esac
echo
echo "Installed. Next: vm build (builds the golden image, once)"

169
vmbox/lib/base.sh Normal file
View File

@ -0,0 +1,169 @@
#!/bin/bash
# ============================================================================
# vmbox: golden base image build.
#
# Downloads the signed Arch cloud image, provisions it once, then SEALS it:
# chmod 444 + a sha256 sidecar. Every sandbox is a thin qcow2 overlay on this
# file, so booting the base even once would silently corrupt every overlay
# derived from it -- hence the seal and the launch-time verification.
# ============================================================================
# shellcheck source=common.sh
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
# shellcheck source=seed.sh
source "$(dirname "${BASH_SOURCE[0]}")/seed.sh"
# shellcheck source=smoke.sh
source "$(dirname "${BASH_SOURCE[0]}")/smoke.sh"
readonly CLOUD_URL="https://geo.mirror.pkgbuild.com/images/latest"
readonly CLOUD_IMG="Arch-Linux-x86_64-cloudimg.qcow2"
readonly BASE_DISK_SIZE="${BASE_DISK_SIZE:-20G}"
readonly BUILD_SSH_PORT="${BUILD_SSH_PORT:-2222}"
base_fetch() {
# Separate statements: within a single `local a=.. b="$a.."`, the later
# reference is unbound under `set -u` because the assignment is not done yet.
local dest="$VMBOX_BASE_DIR/cloudimg.qcow2"
local tmp="$dest.tmp"
local sums="$dest.SHA256"
[[ -f "$dest" ]] && { log "Cloud image present, skipping download"; return 0; }
install -d -m 755 "$VMBOX_BASE_DIR"
log "Downloading Arch cloud image (~531 MB)"
curl -fL --progress-bar -o "$tmp" "$CLOUD_URL/$CLOUD_IMG"
curl -fsSL -o "$sums" "$CLOUD_URL/$CLOUD_IMG.SHA256"
local expected actual
expected="$(awk '{print $1}' "$sums")"
actual="$(sha256sum "$tmp" | awk '{print $1}')"
[[ "$expected" == "$actual" ]] ||
die "checksum mismatch -- refusing to use image (expected $expected, got $actual)"
mv "$tmp" "$dest"
ok "cloud image verified"
}
# Boot the working copy with the seed ISO and an ssh forward, provision over
# ssh (real exit codes, unlike driving a shell over the serial console), then
# shut down cleanly.
base_provision() {
local disk="$1" work seed serial qpid
work="$(mktemp -d)"
seed="$work/seed.iso"
serial="$VMBOX_BASE_DIR/build.log"
rm -f "$serial"
seed_ensure_key
seed_build_iso "$seed" "$work"
log "Booting image for provisioning"
qemu-system-x86_64 \
-machine q35,accel=kvm -cpu host -smp 4 -m 4096 \
-drive file="$disk",if=virtio,format=qcow2 \
-drive file="$seed",if=virtio,format=raw,readonly=on \
-netdev "user,id=net0,hostfwd=tcp:127.0.0.1:${BUILD_SSH_PORT}-:22" \
-device virtio-net-pci,netdev=net0 \
-display none \
-chardev file,id=ser0,path="$serial",append=on -serial chardev:ser0 \
-no-reboot >/dev/null 2>&1 &
qpid=$!
# Always reap the build VM, including on failure or Ctrl-C. The pid and
# workdir are baked into the trap body: a RETURN trap fires after the
# function's locals are gone, so referencing $qpid there is unbound.
# shellcheck disable=SC2064 # expanding NOW is intended: see comment above
trap "kill $qpid 2>/dev/null || true; rm -rf '$work'" RETURN
_base_wait_ssh "$qpid" || die "guest never became reachable -- see $serial"
log "Provisioning guest (installs Xorg/i3 + dev tools; several minutes)"
_base_scp "$VMBOX_GUEST_DIR/provision.sh" "/tmp/provision.sh"
_base_ssh "chmod +x /tmp/provision.sh && sudo /tmp/provision.sh '$VMBOX_GUEST_USER'" ||
die "provisioning failed -- see $serial"
# Disable cloud-init so per-VM boots skip datasource probing entirely
# (it adds startup delay and would re-run on every overlay).
#
# Do NOT run `cloud-init clean` here: on this image cloud-init is what
# brings sshd up, and cleaning strips that alongside its own state,
# producing a sealed image whose guests boot fine but are unreachable.
# provision.sh enables sshd.service independently, which is what makes
# disabling cloud-init safe at all.
_base_ssh "sudo touch /etc/cloud/cloud-init.disabled" || true
# Assert the sealed image will actually be reachable. A base that boots but
# refuses ssh is the single most expensive failure here: it looks like a
# network bug and costs a full rebuild to find.
_base_ssh "test -e /etc/systemd/system/multi-user.target.wants/sshd.service" ||
die "sshd.service is not enabled in the image -- guests would be unreachable"
ok "verified: sshd.service will start on boot"
log "Shutting the build VM down cleanly"
_base_ssh "sudo systemctl poweroff" >/dev/null 2>&1 || true
local waited=0
while kill -0 "$qpid" 2>/dev/null && (( waited < 120 )); do
sleep 2; waited=$(( waited + 2 ))
done
kill -0 "$qpid" 2>/dev/null && { warn "build VM did not stop; forcing"; kill "$qpid"; }
ok "provisioning complete"
}
_base_ssh_opts() {
printf '%s' "-i $VMBOX_SSH_KEY -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o LogLevel=ERROR -o ConnectTimeout=5 -p $BUILD_SSH_PORT"
}
_base_ssh() {
# shellcheck disable=SC2046 # word splitting of ssh opts is intended
ssh $(_base_ssh_opts) "${VMBOX_GUEST_USER}@127.0.0.1" "$@"
}
_base_scp() {
scp -i "$VMBOX_SSH_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o LogLevel=ERROR -P "$BUILD_SSH_PORT" "$1" "${VMBOX_GUEST_USER}@127.0.0.1:$2"
}
# ssh is its own port prober, so no nc/socat needed (neither is installed).
_base_wait_ssh() {
local qpid="$1" waited=0
log "Waiting for guest ssh (first boot runs cloud-init)"
while (( waited < 300 )); do
kill -0 "$qpid" 2>/dev/null || return 1
if _base_ssh -o BatchMode=yes true 2>/dev/null; then
ok "guest reachable after ${waited}s"
return 0
fi
sleep 5; waited=$(( waited + 5 ))
done
return 1
}
# Make the base read-only and record its hash. The hash is the guard that
# actually catches corruption; chmod alone can be bypassed.
base_seal() {
local disk="$1"
log "Sealing base image"
mv "$disk" "$VMBOX_BASE_IMG"
chmod 444 "$VMBOX_BASE_IMG"
sha256sum "$VMBOX_BASE_IMG" | awk '{print $1}' > "$VMBOX_BASE_SHA"
ok "base sealed: $VMBOX_BASE_IMG ($(du -h "$VMBOX_BASE_IMG" | cut -f1))"
}
base_build() {
require_host_deps
if [[ -f "$VMBOX_BASE_IMG" && "${1:-}" != "--force" ]]; then
die "base image already exists -- rebuild with: vm build --force"
fi
base_fetch
local disk="$VMBOX_BASE_DIR/building.qcow2"
rm -f "$disk"
cp --reflink=auto "$VMBOX_BASE_DIR/cloudimg.qcow2" "$disk"
qemu-img resize -q "$disk" "$BASE_DISK_SIZE"
base_provision "$disk"
base_seal "$disk"
base_smoke_test
echo
ok "Done. Create a sandbox with: vm new demo"
}

133
vmbox/lib/common.sh Normal file
View File

@ -0,0 +1,133 @@
#!/bin/bash
# ============================================================================
# vmbox: shared paths, logging and validation.
# Sourced by every other lib/ file and by bin/vm. Defines no side effects.
# ============================================================================
# Guard against double-sourcing: each lib sources common.sh unconditionally.
# shellcheck disable=SC2034 # every path/const below is consumed by the
# sibling lib/*.sh files that source this one, which shellcheck cannot see.
[[ -n "${VMBOX_COMMON_SOURCED:-}" ]] && return 0
readonly VMBOX_COMMON_SOURCED=1
# Repo root is the parent of lib/, resolved through symlinks so the CLI works
# when bin/vm is symlinked onto PATH by install.sh.
VMBOX_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
readonly VMBOX_LIB_DIR
# Go up one real directory rather than stripping a "/lib" suffix: the suffix
# form silently yields the wrong root when sourced via a relative path.
VMBOX_ROOT="$(cd "$VMBOX_LIB_DIR/.." && pwd -P)"
readonly VMBOX_ROOT
readonly VMBOX_GUEST_DIR="$VMBOX_ROOT/guest"
# State lives OUTSIDE the repo: these are multi-GB disk images and live
# sockets, and the repo has a pre-commit hook that blocks binaries.
readonly VMBOX_HOME="${VMBOX_HOME:-$HOME/.local/share/vmbox}"
readonly VMBOX_BASE_DIR="$VMBOX_HOME/base"
readonly VMBOX_VMS_DIR="$VMBOX_HOME/vms"
readonly VMBOX_BASE_IMG="$VMBOX_BASE_DIR/base.qcow2"
readonly VMBOX_BASE_SHA="$VMBOX_BASE_DIR/base.qcow2.sha256"
readonly VMBOX_SSH_KEY="$VMBOX_BASE_DIR/id_ed25519"
# Base SSH port. Each VM gets VMBOX_SSH_PORT_BASE + its index.
readonly VMBOX_SSH_PORT_BASE="${VMBOX_SSH_PORT_BASE:-2300}"
# Shared multicast segment so sandboxes can reach each other (no root, no bridge).
readonly VMBOX_MCAST="${VMBOX_MCAST:-230.0.0.1:12345}"
# Guest static subnet on the second NIC (the mcast segment has no DHCP).
readonly VMBOX_GUEST_SUBNET="${VMBOX_GUEST_SUBNET:-10.77.0}"
readonly VMBOX_GUEST_USER="${VMBOX_GUEST_USER:-arch}"
# Colours only when stdout is a terminal, so logs stay clean when piped.
if [[ -t 1 ]]; then
readonly C_RED=$'\033[0;31m' C_GRN=$'\033[0;32m' C_YLW=$'\033[0;33m'
readonly C_BLU=$'\033[0;34m' C_OFF=$'\033[0m'
else
readonly C_RED='' C_GRN='' C_YLW='' C_BLU='' C_OFF=''
fi
log() { printf '%s==>%s %s\n' "$C_BLU" "$C_OFF" "$*"; }
ok() { printf '%s ok %s %s\n' "$C_GRN" "$C_OFF" "$*"; }
warn() { printf '%s warn%s %s\n' "$C_YLW" "$C_OFF" "$*" >&2; }
die() { printf '%serror%s %s\n' "$C_RED" "$C_OFF" "$*" >&2; exit 1; }
# VM names become directory names and qemu -name values; keep them boring.
validate_vm_name() {
local name="${1:-}"
[[ -n "$name" ]] || die "vm name is required"
[[ "$name" =~ ^[a-zA-Z0-9][a-zA-Z0-9_-]{0,31}$ ]] ||
die "invalid vm name '$name' (allowed: alphanumeric, _ and -, max 32 chars)"
printf '%s' "$name"
}
vm_dir() { printf '%s/%s' "$VMBOX_VMS_DIR" "$1"; }
vm_overlay() { printf '%s/%s/overlay.qcow2' "$VMBOX_VMS_DIR" "$1"; }
vm_meta() { printf '%s/%s/meta' "$VMBOX_VMS_DIR" "$1"; }
vm_qmp_sock() { printf '%s/%s/qmp.sock' "$VMBOX_VMS_DIR" "$1"; }
# Separate control channel: the recorder occupies the main QMP socket, and a
# QMP socket accepts a single client at a time.
vm_qmp_ctl() { printf '%s/%s/qmp.sock.ctl' "$VMBOX_VMS_DIR" "$1"; }
vm_events() { printf '%s/%s/events.jsonl' "$VMBOX_VMS_DIR" "$1"; }
vm_pidfile() { printf '%s/%s/vm.pid' "$VMBOX_VMS_DIR" "$1"; }
# Serial logs are per-boot: a relaunch must never overwrite the log a verdict
# is about to be read from. See vm_next_serial().
vm_serial() { printf '%s/%s/serial.%s.log' "$VMBOX_VMS_DIR" "$1" "$2"; }
vm_exists() { [[ -d "$(vm_dir "$1")" ]]; }
require_vm() {
vm_exists "$1" || die "no such vm: '$1' (create it with: vm new $1)"
}
# meta is a flat key=value file; values must not contain newlines.
meta_get() {
local name="$1" key="$2" line
line="$(grep -m1 "^${key}=" "$(vm_meta "$name")" 2>/dev/null)" || return 1
printf '%s' "${line#*=}"
}
meta_set() {
local name="$1" key="$2" value="$3" meta
meta="$(vm_meta "$name")"
[[ "$value" == *$'\n'* ]] && die "meta value for '$key' must be single-line"
touch "$meta"
# Rewrite in place: delete any existing key, then append the new value.
grep -v "^${key}=" "$meta" > "$meta.tmp" 2>/dev/null || true
printf '%s=%s\n' "$key" "$value" >> "$meta.tmp"
mv "$meta.tmp" "$meta"
}
# Highest existing serial.N.log + 1, so each boot writes its own file.
vm_next_serial() {
local name="$1" n=0 f
# -e guards the unmatched-glob case; without it an empty dir yields either
# a literal "serial.*.log" (bash) or a hard error (zsh).
for f in "$(vm_dir "$name")"/serial.*.log; do
[[ -e "$f" ]] || continue
local base="${f##*/serial.}"
base="${base%.log}"
[[ "$base" =~ ^[0-9]+$ ]] && (( base >= n )) && n=$(( base + 1 ))
done
printf '%d' "$n"
}
# A VM is running if its pidfile names a live qemu process. Stale pidfiles are
# normal: a guest that powers itself off leaves one behind.
vm_is_running() {
local pid
pid="$(cat "$(vm_pidfile "$1")" 2>/dev/null)" || return 1
[[ -n "$pid" ]] || return 1
[[ -r "/proc/$pid/cmdline" ]] || return 1
grep -qa vmbox-"$1" "/proc/$pid/cmdline" 2>/dev/null
}
require_host_deps() {
local missing=()
command -v qemu-system-x86_64 >/dev/null || missing+=(qemu-base)
command -v qemu-img >/dev/null || missing+=(qemu-img)
command -v xorrisofs >/dev/null || missing+=(libisoburn)
command -v python3 >/dev/null || missing+=(python)
(( ${#missing[@]} == 0 )) ||
die "missing host dependencies: ${missing[*]} -- run: $VMBOX_ROOT/install.sh"
}

104
vmbox/lib/launch.sh Normal file
View File

@ -0,0 +1,104 @@
#!/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
}

121
vmbox/lib/overlay.sh Normal file
View File

@ -0,0 +1,121 @@
#!/bin/bash
# ============================================================================
# vmbox: sandbox lifecycle -- create, reset, delete.
#
# A sandbox is a thin qcow2 overlay on the sealed base image. Creating one is
# instant and costs ~200 KB; resetting is `rm` + recreate, so it is
# sub-second no matter how thoroughly the guest was destroyed.
# ============================================================================
# shellcheck source=common.sh
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
# The base must never be written to: qcow2 overlays record their backing file's
# state, so booting the base once silently invalidates EVERY overlay on it.
# chmod 444 is the lock; this hash is what actually detects a breach.
overlay_verify_base() {
[[ -f "$VMBOX_BASE_IMG" ]] ||
die "no base image -- build it first with: vm build"
[[ -f "$VMBOX_BASE_SHA" ]] ||
die "base image has no checksum sidecar -- rebuild with: vm build --force"
local expected actual
expected="$(cat "$VMBOX_BASE_SHA")"
actual="$(sha256sum "$VMBOX_BASE_IMG" | awk '{print $1}')"
[[ "$expected" == "$actual" ]] || die \
"BASE IMAGE MODIFIED -- every existing sandbox is now unreliable.
expected $expected
actual $actual
Rebuild with: vm build --force"
}
# Allocate the lowest free index; it determines the ssh port and peer IP.
_overlay_next_index() {
local used=() d idx i
for d in "$VMBOX_VMS_DIR"/*/; do
[[ -d "$d" ]] || continue
idx="$(meta_get "$(basename "$d")" index 2>/dev/null)" || continue
[[ -n "$idx" ]] && used+=("$idx")
done
for (( i = 1; i < 200; i++ )); do
local taken=0 u
for u in ${used+"${used[@]}"}; do [[ "$u" == "$i" ]] && taken=1 && break; done
(( taken )) || { printf '%d' "$i"; return 0; }
done
die "no free sandbox slots (200 in use?)"
}
overlay_new() {
local name rtc="" arg
name="$(validate_vm_name "${1:-}")"; shift || true
while [[ $# -gt 0 ]]; do
arg="$1"
case "$arg" in
--rtc) rtc="${2:-}"; [[ -n "$rtc" ]] || die "--rtc needs a timestamp"; shift 2 ;;
*) die "unknown option for 'vm new': $arg" ;;
esac
done
vm_exists "$name" && die "sandbox '$name' already exists (reset it with: vm reset $name)"
overlay_verify_base
local dir index
dir="$(vm_dir "$name")"
install -d -m 755 "$dir"
index="$(_overlay_next_index)"
meta_set "$name" index "$index"
meta_set "$name" ssh_port "$(( VMBOX_SSH_PORT_BASE + index ))"
meta_set "$name" created "$(date -Is)"
# Stored, not passed once: every `vm run` is a fresh qemu process, so the
# clock must be re-applied on each launch or boot #2 drifts to wall-clock.
[[ -n "$rtc" ]] && meta_set "$name" rtc "$rtc"
_overlay_create_disk "$name"
ok "sandbox '$name' ready (ssh port $(( VMBOX_SSH_PORT_BASE + index )), peer ${VMBOX_GUEST_SUBNET}.${index})"
}
_overlay_create_disk() {
local name="$1" overlay
overlay="$(vm_overlay "$name")"
rm -f "$overlay"
qemu-img create -q -f qcow2 -F qcow2 -b "$VMBOX_BASE_IMG" "$overlay"
# Belt and braces: assert the chain really points at the sealed base.
qemu-img info --backing-chain "$overlay" | grep -q "$VMBOX_BASE_IMG" ||
die "overlay backing chain does not reference the base image"
}
overlay_reset() {
local name
name="$(validate_vm_name "${1:-}")"
require_vm "$name"
overlay_verify_base
vm_is_running "$name" && _overlay_stop "$name"
# Discard everything the guest did: the overlay holds 100% of its writes.
_overlay_create_disk "$name"
rm -f "$(vm_dir "$name")"/serial.*.log "$(vm_events "$name")" "$(vm_pidfile "$name")"
ok "sandbox '$name' reset to pristine"
}
_overlay_stop() {
local name="$1" pid waited=0
pid="$(cat "$(vm_pidfile "$name")" 2>/dev/null)" || return 0
log "Stopping running sandbox '$name'"
kill "$pid" 2>/dev/null || true
while kill -0 "$pid" 2>/dev/null && (( waited < 20 )); do
sleep 1; waited=$(( waited + 1 ))
done
kill -9 "$pid" 2>/dev/null || true
}
overlay_rm() {
local name
name="$(validate_vm_name "${1:-}")"
require_vm "$name"
vm_is_running "$name" && _overlay_stop "$name"
rm -rf "$(vm_dir "$name")"
ok "sandbox '$name' deleted"
}

84
vmbox/lib/qmp.py Executable file
View File

@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""One-shot QMP client for vmbox: issue a command and print the reply.
Used for actions that need a round trip rather than passive event recording --
notably `screendump`, which is how locker/X11 tests get a screenshot without
any VNC or SPICE viewer installed on the host.
Usage:
qmp.py <qmp.sock> screendump <out.png>
qmp.py <qmp.sock> status
"""
from __future__ import annotations
import json
import os
import socket
import sys
from typing import Any, TextIO
def _open(path: str) -> tuple[socket.socket, TextIO]:
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.settimeout(20)
sock.connect(path)
stream = sock.makefile("rw", encoding="utf-8", newline="\n")
stream.readline() # greeting
_command(stream, "qmp_capabilities")
return sock, stream
def _command(stream: TextIO, name: str, **args: Any) -> dict[str, Any]:
"""Send one command and return its reply, skipping any events in between."""
payload: dict[str, Any] = {"execute": name}
if args:
payload["arguments"] = args
stream.write(json.dumps(payload) + "\n")
stream.flush()
while True:
line = stream.readline()
if not line:
raise SystemExit(f"qmp: connection closed during '{name}'")
msg = json.loads(line)
if "return" in msg or "error" in msg:
return msg
def main() -> int:
if len(sys.argv) < 3:
print(__doc__, file=sys.stderr)
return 2
path, action = sys.argv[1], sys.argv[2]
if not os.path.exists(path):
print(f"qmp: no such socket: {path} (is the sandbox running?)", file=sys.stderr)
return 1
sock, stream = _open(path)
try:
if action == "screendump":
if len(sys.argv) < 4:
print("qmp: screendump needs an output path", file=sys.stderr)
return 2
out = os.path.abspath(sys.argv[3])
# PNG is native to qemu's screendump; no ImageMagick needed.
reply = _command(stream, "screendump", filename=out, format="png")
elif action == "status":
reply = _command(stream, "query-status")
else:
print(f"qmp: unknown action '{action}'", file=sys.stderr)
return 2
if "error" in reply:
print(f"qmp: {reply['error'].get('desc', reply['error'])}", file=sys.stderr)
return 1
print(json.dumps(reply.get("return", {})))
return 0
finally:
stream.close()
sock.close()
if __name__ == "__main__":
sys.exit(main())

104
vmbox/lib/recorder.py Executable file
View File

@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""Persistent QMP event recorder for a vmbox sandbox.
Started at VM launch, before anything runs inside the guest, and appends every
QMP event to a JSONL file. This ordering is the whole point: a guest that
powers itself off kills QEMU, so a prober attached *after* the fact finds a
closed socket and no event at all. Recording from launch means the verdict is
read from a file that outlives the VM.
Usage: recorder.py <qmp.sock> <events.jsonl>
"""
from __future__ import annotations
import json
import socket
import sys
import time
from typing import Any, TextIO
def connect(path: str, timeout: float = 30.0) -> socket.socket:
"""Connect to the QMP unix socket, retrying until QEMU creates it."""
deadline = time.time() + timeout
last_err: OSError | None = None
while time.time() < deadline:
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
sock.connect(path)
return sock
except OSError as exc:
last_err = exc
sock.close()
time.sleep(0.1)
raise SystemExit(f"recorder: could not connect to {path}: {last_err}")
def negotiate(stream: TextIO) -> None:
"""Complete the QMP capability handshake; events only flow afterwards."""
stream.readline() # greeting
stream.write(json.dumps({"execute": "qmp_capabilities"}) + "\n")
stream.flush()
for _ in range(20):
line = stream.readline()
if not line:
return
msg = json.loads(line)
if "return" in msg or "error" in msg:
return
def record(stream: TextIO, out: TextIO) -> None:
"""Append each event as one JSON line, flushing so readers see it at once."""
while True:
line = stream.readline()
if not line: # QEMU exited: the guest is gone, which is itself a signal.
_emit(out, {"event": "_RECORDER_EOF", "timestamp": _now()})
return
try:
msg: dict[str, Any] = json.loads(line)
except json.JSONDecodeError:
continue
if "event" in msg:
_emit(out, msg)
def _now() -> dict[str, int]:
now = time.time()
return {"seconds": int(now), "microseconds": int((now % 1) * 1_000_000)}
def _emit(out: TextIO, msg: dict[str, Any]) -> None:
out.write(json.dumps(msg) + "\n")
out.flush()
def main() -> int:
if len(sys.argv) != 3:
print(__doc__, file=sys.stderr)
return 2
qmp_path, events_path = sys.argv[1], sys.argv[2]
sock = connect(qmp_path)
# newline='\n' keeps QMP's line protocol intact regardless of locale.
stream = sock.makefile("rw", encoding="utf-8", newline="\n")
with open(events_path, "a", encoding="utf-8") as out:
negotiate(stream)
_emit(out, {"event": "_RECORDER_READY", "timestamp": _now()})
try:
record(stream, out)
except OSError as exc:
_emit(
out,
{
"event": "_RECORDER_ERROR",
"data": {"error": str(exc)},
"timestamp": _now(),
},
)
return 0
if __name__ == "__main__":
sys.exit(main())

64
vmbox/lib/repo.sh Normal file
View File

@ -0,0 +1,64 @@
#!/bin/bash
# ============================================================================
# vmbox: get a host repo into a sandbox.
#
# Two modes, because they answer different questions:
# clone (default) -- reproducible: tests exactly what is committed at HEAD
# worktree -- tests what is on disk right now, uncommitted edits and
# all, which is usually what "does this script work?" means
#
# The host side is always mounted READ-ONLY, so nothing the guest does can
# reach the real repo.
# ============================================================================
# shellcheck source=common.sh
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
source "$(dirname "${BASH_SOURCE[0]}")/ssh.sh"
readonly GUEST_MOUNT="/mnt/hostrepo"
# $1 vm name, $2 host repo path, $3 mode (clone|worktree)
repo_sync() {
local name="$1" src="$2" mode="${3:-clone}" repo_name
require_vm "$name"
[[ -d "$src" ]] || die "no such directory: $src"
repo_name="$(basename "$src")"
vm_is_running "$name" || die "sandbox '$name' is not running"
# Warn about a dirty tree: another session's uncommitted work is a real
# hazard, and it silently changes what a `clone` actually tests.
if [[ -d "$src/.git" ]] && ! git -C "$src" diff --quiet 2>/dev/null; then
warn "host repo has uncommitted changes"
[[ "$mode" == clone ]] &&
warn " mode=clone tests HEAD, NOT those edits (use --worktree for them)"
fi
case "$mode" in
clone) _repo_clone "$name" "$src" "$repo_name" ;;
worktree) _repo_rsync "$name" "$src" "$repo_name" ;;
*) die "unknown repo mode '$mode' (use: clone | worktree)" ;;
esac
}
_repo_clone() {
local name="$1" src="$2" repo_name="$3"
[[ -d "$src/.git" ]] || die "$src is not a git repo (use --worktree instead)"
log "Cloning $repo_name into '$name' (HEAD, reproducible)"
# --no-hardlinks: never link into the read-only host mount.
vm_ssh_exec "$name" \
"rm -rf ~/$repo_name && git clone --no-hardlinks -q $GUEST_MOUNT/$repo_name ~/$repo_name" ||
die "clone failed"
local head
head="$(vm_ssh_exec "$name" "git -C ~/$repo_name rev-parse --short HEAD" 2>/dev/null)"
ok "$repo_name cloned at $head (committed state)"
}
_repo_rsync() {
local name="$1" src="$2" repo_name="$3"
log "Copying $repo_name into '$name' (working tree, including uncommitted edits)"
vm_ssh_exec "$name" \
"rsync -a --exclude .git $GUEST_MOUNT/$repo_name/ ~/$repo_name/" ||
die "rsync failed"
ok "$repo_name copied (working-tree state)"
}

119
vmbox/lib/run.sh Normal file
View File

@ -0,0 +1,119 @@
#!/bin/bash
# ============================================================================
# vmbox: run a command in a sandbox and report what happened to the machine.
#
# The hard case is a command that powers the guest off (the whole point of the
# tool). ssh cannot report that: the connection dies with the machine and
# returns 255, the same value it returns for a crash or a dropped link. So the
# exit code is written to disk INSIDE the guest, and recovered afterwards from
# the overlay -- which survives the poweroff -- while the verdict comes from
# host-side QMP/serial evidence.
# ============================================================================
# shellcheck source=common.sh
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
source "$(dirname "${BASH_SOURCE[0]}")/launch.sh"
source "$(dirname "${BASH_SOURCE[0]}")/ssh.sh"
source "$(dirname "${BASH_SOURCE[0]}")/verdict.sh"
readonly RC_PATH="/var/tmp/vmbox.rc"
readonly VMBOX_RUN_TIMEOUT="${VMBOX_RUN_TIMEOUT:-600}"
run_in_vm() {
local name
name="$(validate_vm_name "${1:-}")"; shift || true
require_vm "$name"
[[ $# -gt 0 ]] || die "usage: vm run <name> <command...>"
local serial_n serial events
if vm_is_running "$name"; then
serial_n="$(( $(vm_next_serial "$name") - 1 ))"
(( serial_n < 0 )) && serial_n=0
else
serial_n="$(launch_vm "$name")"
vm_wait_ssh "$name" 150 || die "sandbox '$name' did not become reachable"
fi
serial="$(vm_serial "$name" "$serial_n")"
events="$(vm_events "$name")"
# Record where the verdict should start reading, so a previous boot's
# shutdown event is never mistaken for this run's outcome.
local events_before=0
[[ -f "$events" ]] && events_before=$(wc -l < "$events")
log "Running in '$name': $*"
_run_exec "$name" "$@"
local ssh_rc=$?
_run_settle "$name" "$serial"
_run_finish "$name" "$serial" "$events" "$events_before" "$ssh_rc"
}
# stdin is closed: several target installers prompt, and an inherited terminal
# would make them block forever instead of taking their default.
_run_exec() {
local name="$1"; shift
local cmd="$*"
vm_ssh_exec "$name" \
"sh -c '{ $cmd; } ; echo \$? | sudo tee $RC_PATH >/dev/null' </dev/null" \
</dev/null
}
# Give a guest that is shutting down time to finish writing its serial log.
_run_settle() {
local name="$1" serial="$2" waited=0
while vm_is_running "$name" && (( waited < 30 )); do
grep -qa "$POWEROFF_MARKER" "$serial" 2>/dev/null && break
sleep 1; waited=$(( waited + 1 ))
done
# Let qemu actually exit after the marker appears.
waited=0
while vm_is_running "$name" && (( waited < 15 )); do
grep -qa "$POWEROFF_MARKER" "$serial" 2>/dev/null || break
sleep 1; waited=$(( waited + 1 ))
done
}
_run_finish() {
local name="$1" serial="$2" events="$3" events_before="$4" ssh_rc="$5"
# Only consider events produced by THIS run.
local scoped="$events.scoped"
if [[ -f "$events" ]]; then
tail -n +$(( events_before + 1 )) "$events" > "$scoped" 2>/dev/null || : > "$scoped"
else
: > "$scoped"
fi
echo
local vrc=0
verdict_report "$name" "$serial" "$scoped" || vrc=$?
rm -f "$scoped"
# If the guest stopped, recover the real exit code from the overlay.
if ! vm_is_running "$name"; then
local rc
rc="$(_run_recover_rc "$name")"
if [[ -n "$rc" ]]; then
log "command exit code (recovered from the stopped guest): $rc"
return "$rc"
fi
log "no exit code recorded (the command never returned before the machine stopped)"
return "$vrc"
fi
if (( ssh_rc == 255 )); then
warn "ssh reported 255 but the guest is still running -- connection issue, not a shutdown"
fi
return "$ssh_rc"
}
# Boot the sandbox again just far enough to read the recorded exit code.
_run_recover_rc() {
local name="$1" rc=""
launch_vm "$name" >/dev/null 2>&1 || return 0
if vm_wait_ssh "$name" 120; then
rc="$(vm_ssh_exec "$name" "cat $RC_PATH 2>/dev/null" 2>/dev/null | tr -dc '0-9')"
fi
printf '%s' "$rc"
}

29
vmbox/lib/screenshot.sh Normal file
View File

@ -0,0 +1,29 @@
#!/bin/bash
# ============================================================================
# vmbox: capture the guest screen.
#
# Screenshots come from qemu's own framebuffer via QMP screendump, so no VNC
# or SPICE viewer is needed. This is how locker/i3 tests are verified -- and
# it is safer than testing on the host, where a fullscreen locker can hijack
# input and steal focus from whatever the user is doing.
# ============================================================================
# shellcheck source=common.sh
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
vm_screenshot() {
local name out
name="$(validate_vm_name "${1:-}")"
out="${2:-$PWD/${name}.png}"
require_vm "$name"
vm_is_running "$name" ||
die "sandbox '$name' is not running (start it with: vm ssh $name)"
python3 "$VMBOX_LIB_DIR/qmp.py" "$(vm_qmp_ctl "$name")" screendump "$out" >/dev/null ||
die "screendump failed"
[[ -s "$out" ]] || die "screendump produced an empty file"
ok "screenshot: $out ($(du -h "$out" | cut -f1))"
printf '%s' "$out"
}

62
vmbox/lib/seed.sh Normal file
View File

@ -0,0 +1,62 @@
#!/bin/bash
# ============================================================================
# vmbox: cloud-init NoCloud seed ISO generation.
#
# The seed ISO is how the golden image gets its user, SSH key and provisioning
# script. It is used ONLY during `vm build`; the sealed base disables
# cloud-init, so per-VM boots never touch it.
# ============================================================================
# shellcheck source=common.sh
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
# Generate the throwaway keypair the host uses to reach guests. This key is
# sandbox-only and is never the user's real ~/.ssh key.
seed_ensure_key() {
[[ -f "$VMBOX_SSH_KEY" ]] && return 0
log "Generating sandbox SSH key (throwaway, sandbox-only)"
install -d -m 700 "$VMBOX_BASE_DIR"
ssh-keygen -t ed25519 -N '' -q -C 'vmbox-sandbox-key' -f "$VMBOX_SSH_KEY"
ok "key: $VMBOX_SSH_KEY"
}
# Write user-data + meta-data and pack them into a CIDATA volume.
# $1 = output ISO path, $2 = directory to build in.
seed_build_iso() {
local iso="$1" workdir="$2" pubkey
pubkey="$(cat "${VMBOX_SSH_KEY}.pub")"
# NOPASSWD sudo is load-bearing, not a convenience: without it every
# `vm run` that uses sudo blocks forever on an invisible password prompt.
cat > "$workdir/user-data" <<UD
#cloud-config
users:
- name: ${VMBOX_GUEST_USER}
groups: [wheel]
shell: /bin/bash
sudo: ["ALL=(ALL) NOPASSWD:ALL"]
lock_passwd: false
ssh_authorized_keys:
- ${pubkey}
# Password login on the serial console, for debugging a guest whose sshd is
# broken. Harmless: the VM is unreachable from outside the host.
chpasswd:
expire: false
list: |
${VMBOX_GUEST_USER}:vmbox
root:vmbox
ssh_pwauth: true
preserve_hostname: false
UD
cat > "$workdir/meta-data" <<MD
instance-id: vmbox-golden
local-hostname: vmbox
MD
xorrisofs -output "$iso" -volid CIDATA -joliet -rational-rock \
-quiet "$workdir/user-data" "$workdir/meta-data"
[[ -s "$iso" ]] || die "seed ISO generation produced an empty file"
}

49
vmbox/lib/smoke.sh Normal file
View File

@ -0,0 +1,49 @@
#!/bin/bash
# ============================================================================
# vmbox: post-seal verification.
#
# Split out of base.sh to stay under the 250-line cap.
# ============================================================================
# shellcheck source=common.sh
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
# Boot a real overlay off the freshly sealed base and require that ssh answers.
#
# This is the gate that matters. Every check inside the build VM only proves
# state at seal time; the failures that actually shipped all lived in the gap
# between "correct in the build VM" and "works on a fresh overlay boot". This
# catches missing host keys, a no-op systemctl enable, a clobbered NIC and
# whatever the next cause turns out to be, WITHOUT needing to know which.
base_smoke_test() {
log "Smoke-testing the sealed image (boots a throwaway sandbox)"
source "$VMBOX_LIB_DIR/overlay.sh"
source "$VMBOX_LIB_DIR/launch.sh"
source "$VMBOX_LIB_DIR/ssh.sh"
local smoke="__smoke"
vm_exists "$smoke" && overlay_rm "$smoke" >/dev/null 2>&1
overlay_new "$smoke" >/dev/null
local failed=0
launch_vm "$smoke" >/dev/null
if vm_wait_ssh "$smoke" 180 && vm_ssh_exec "$smoke" true 2>/dev/null; then
ok "smoke test passed: a fresh sandbox boots and accepts ssh"
else
failed=1
warn "SMOKE TEST FAILED -- the sealed image boots but is unreachable."
warn "Serial log: $(vm_serial "$smoke" 0)"
warn "Debug it with the serial console (root autologin is enabled there)."
fi
# On failure KEEP the sandbox: deleting it would destroy the serial log
# this very message points at, which is the only evidence available when
# ssh is the thing that is broken.
if (( failed == 0 )); then
overlay_rm "$smoke" >/dev/null 2>&1 || true
else
warn "keeping sandbox '__smoke' so its logs survive; remove it with: vm rm __smoke"
die "refusing to ship an unreachable base image"
fi
}

83
vmbox/lib/ssh.sh Normal file
View File

@ -0,0 +1,83 @@
#!/bin/bash
# ============================================================================
# vmbox: ssh access to a sandbox.
#
# Guest host keys are deliberately NOT verified and NOT recorded: a sandbox is
# reset constantly, so a real known_hosts entry would collide on every reset.
# Isolation comes from the VM boundary, not from ssh key continuity.
# ============================================================================
# shellcheck source=common.sh
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
vm_ssh_port() {
local port
port="$(meta_get "$1" ssh_port)" || die "sandbox '$1' has no ssh port in meta"
printf '%s' "$port"
}
_ssh_base_args() {
local port="$1"
printf '%s\0' \
-i "$VMBOX_SSH_KEY" \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
-o GlobalKnownHostsFile=/dev/null \
-o LogLevel=ERROR \
-o ConnectTimeout=5 \
-o ServerAliveInterval=5 \
-p "$port"
}
# Run a command in the guest. Returns the guest's exit code, EXCEPT when the
# connection dies -- ssh reports 255 for a dropped link, a killed sshd and a
# successful poweroff alike, which is exactly why the verdict logic never
# trusts this return value on its own.
vm_ssh_exec() {
local name="$1"; shift
local port; port="$(vm_ssh_port "$name")"
local -a args=()
mapfile -d '' -t args < <(_ssh_base_args "$port")
ssh "${args[@]}" "${VMBOX_GUEST_USER}@127.0.0.1" "$@"
}
vm_ssh_interactive() {
local name
name="$(validate_vm_name "${1:-}")"; shift || true
require_vm "$name"
if ! vm_is_running "$name"; then
log "Sandbox '$name' is not running; starting it"
source "$VMBOX_LIB_DIR/launch.sh"
launch_vm "$name"
vm_wait_ssh "$name" 120 || die "sandbox '$name' did not become reachable"
fi
local port; port="$(vm_ssh_port "$name")"
local -a args=()
mapfile -d '' -t args < <(_ssh_base_args "$port")
# -t forces a pty so interactive tools (and sudo prompts) behave.
ssh -t "${args[@]}" "${VMBOX_GUEST_USER}@127.0.0.1" "$@"
}
vm_scp_to() {
local name="$1" src="$2" dest="$3" port
port="$(vm_ssh_port "$name")"
scp -i "$VMBOX_SSH_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o LogLevel=ERROR -P "$port" -r "$src" "${VMBOX_GUEST_USER}@127.0.0.1:$dest"
}
# Poll until the guest answers. ssh is its own port prober, so this needs no
# nc/socat (neither is installed on this host).
vm_wait_ssh() {
local name="$1" timeout="${2:-120}" waited=0
while (( waited < timeout )); do
if vm_is_running "$name" && vm_ssh_exec "$name" -o BatchMode=yes true 2>/dev/null; then
return 0
fi
# A qemu that has exited means the guest will never answer.
vm_is_running "$name" || { (( waited > 3 )) && return 1; }
sleep 2; waited=$(( waited + 2 ))
done
return 1
}

105
vmbox/lib/verdict.sh Normal file
View File

@ -0,0 +1,105 @@
#!/bin/bash
# ============================================================================
# vmbox: classify what happened to a guest, from the HOST.
#
# A guest cannot report its own poweroff: the connection carrying the report
# dies with it, and ssh returns 255 for a clean shutdown, a crash, a killed
# sshd and a network blip alike. So the verdict is read from two host-side
# artefacts that outlive the VM -- the QMP event log and the serial log.
# ============================================================================
# shellcheck source=common.sh
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
# The line the kernel prints as the very last thing on a complete poweroff.
readonly POWEROFF_MARKER='reboot: Power down'
# Read the last SHUTDOWN/panic event out of events.jsonl.
# Echoes one of: guest-shutdown | guest-reset | host-* | panic | none
verdict_last_event() {
local events="$1"
[[ -s "$events" ]] || { printf 'none'; return 0; }
python3 - "$events" <<'PY'
import json, sys
kind = "none"
with open(sys.argv[1], encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line:
continue
try:
msg = json.loads(line)
except json.JSONDecodeError:
continue
event = msg.get("event", "")
if event == "GUEST_PANICKED":
kind = "panic"
elif event == "SHUTDOWN":
data = msg.get("data", {})
reason = data.get("reason", "")
# `guest` distinguishes a guest-initiated stop from one we caused.
kind = reason if reason else ("guest-shutdown" if data.get("guest") else "host")
print(kind)
PY
}
# Did the guest reach the end of its shutdown sequence, or die partway?
# This is what separates "shut down" from "shut down COMPLETELY".
verdict_serial_complete() {
local serial="$1"
[[ -s "$serial" ]] || return 1
grep -qa "$POWEROFF_MARKER" "$serial"
}
# Liveness probe, so a merely SLOW script (an installer running pacman -Syu)
# is not reported as hung.
verdict_is_alive() {
local name="$1" serial="$2" before after
vm_is_running "$name" || return 1
before=$(stat -c %s "$serial" 2>/dev/null || echo 0)
sleep 3
after=$(stat -c %s "$serial" 2>/dev/null || echo 0)
[[ "$after" != "$before" ]] && return 0
source "$VMBOX_LIB_DIR/ssh.sh"
vm_ssh_exec "$name" -o BatchMode=yes -o ConnectTimeout=3 true 2>/dev/null
}
# Print the human-readable verdict and return a distinct exit code per outcome:
# 0 clean poweroff 3 dirty 4 rebooted 5 panicked 6 hung 7 still running
verdict_report() {
local name="$1" serial="$2" events="$3" kind
kind="$(verdict_last_event "$events")"
case "$kind" in
guest-shutdown)
if verdict_serial_complete "$serial"; then
ok "VERDICT: clean poweroff -- guest powered itself off and reached '$POWEROFF_MARKER'"
return 0
fi
warn "VERDICT: DIRTY shutdown -- the VM stopped, but the serial log never reached"
warn " '$POWEROFF_MARKER'; the sequence was cut short (see $serial)"
return 3
;;
guest-reset)
warn "VERDICT: REBOOTED, did not power off -- the guest reset instead of halting"
return 4
;;
panic)
warn "VERDICT: KERNEL PANIC -- the guest crashed rather than shutting down"
return 5
;;
host*|none)
if vm_is_running "$name"; then
ok "VERDICT: still running (no shutdown event)"
return 7
fi
warn "VERDICT: stopped without a guest shutdown event (host-initiated or killed)"
return 6
;;
*)
warn "VERDICT: unrecognised shutdown reason '$kind'"
return 6
;;
esac
}

114
vmbox/tests/test_vmbox.bats Normal file
View File

@ -0,0 +1,114 @@
#!/usr/bin/env bats
# Host-side unit tests for vmbox's pure functions. These deliberately do NOT
# boot a VM: booting is covered by the destructive end-to-end demo in README.
# Everything here runs against a temporary VMBOX_HOME.
setup() {
VMBOX_TEST_HOME="$(mktemp -d)"
export VMBOX_HOME="$VMBOX_TEST_HOME"
REPO="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
export REPO
# shellcheck source=../lib/common.sh
source "$REPO/lib/common.sh"
}
teardown() {
[[ -n "${VMBOX_TEST_HOME:-}" && -d "$VMBOX_TEST_HOME" ]] && rm -rf "$VMBOX_TEST_HOME"
}
@test "validate_vm_name accepts ordinary names" {
run validate_vm_name "demo"
[ "$status" -eq 0 ]
[ "$output" = "demo" ]
run validate_vm_name "test-1_x"
[ "$status" -eq 0 ]
}
@test "validate_vm_name rejects path traversal and spaces" {
run validate_vm_name "../escape"
[ "$status" -ne 0 ]
run validate_vm_name "bad name"
[ "$status" -ne 0 ]
run validate_vm_name ""
[ "$status" -ne 0 ]
}
@test "meta_set overwrites rather than duplicating a key" {
mkdir -p "$(vm_dir demo)"
meta_set demo rtc "2026-01-01T00:00:00"
meta_set demo rtc "2026-08-22T20:55:00"
run bash -c "grep -c '^rtc=' '$(vm_meta demo)'"
[ "$output" = "1" ]
run meta_get demo rtc
[ "$output" = "2026-08-22T20:55:00" ]
}
@test "vm_next_serial starts at 0 and continues past the highest index" {
mkdir -p "$(vm_dir demo)"
run vm_next_serial demo
[ "$output" = "0" ]
touch "$(vm_dir demo)/serial.0.log" "$(vm_dir demo)/serial.7.log"
run vm_next_serial demo
[ "$output" = "8" ]
}
@test "vm_is_running is false for a stale pidfile" {
mkdir -p "$(vm_dir demo)"
echo "999999" > "$(vm_pidfile demo)"
run vm_is_running demo
[ "$status" -ne 0 ]
}
@test "verdict classifies a guest poweroff as clean only with the serial marker" {
source "$REPO/lib/verdict.sh"
mkdir -p "$(vm_dir demo)"
local ev="$(vm_dir demo)/events.jsonl"
printf '%s\n' '{"event":"SHUTDOWN","data":{"guest":true,"reason":"guest-shutdown"}}' > "$ev"
run verdict_last_event "$ev"
[ "$output" = "guest-shutdown" ]
# Truncated serial log -> the machine stopped, but not cleanly.
local ser="$(vm_dir demo)/serial.0.log"
printf 'Stopping some unit...\n' > "$ser"
run verdict_serial_complete "$ser"
[ "$status" -ne 0 ]
printf 'reboot: Power down\n' >> "$ser"
run verdict_serial_complete "$ser"
[ "$status" -eq 0 ]
}
@test "verdict distinguishes reset, panic and host-initiated stops" {
source "$REPO/lib/verdict.sh"
mkdir -p "$(vm_dir demo)"
local ev="$(vm_dir demo)/events.jsonl"
printf '%s\n' '{"event":"SHUTDOWN","data":{"guest":true,"reason":"guest-reset"}}' > "$ev"
run verdict_last_event "$ev"
[ "$output" = "guest-reset" ]
printf '%s\n' '{"event":"GUEST_PANICKED","data":{}}' > "$ev"
run verdict_last_event "$ev"
[ "$output" = "panic" ]
printf '%s\n' '{"event":"SHUTDOWN","data":{"guest":false,"reason":"host-qmp-quit"}}' > "$ev"
run verdict_last_event "$ev"
[ "$output" = "host-qmp-quit" ]
: > "$ev"
run verdict_last_event "$ev"
[ "$output" = "none" ]
}
@test "verdict reads the LAST event, so an earlier boot does not win" {
source "$REPO/lib/verdict.sh"
mkdir -p "$(vm_dir demo)"
local ev="$(vm_dir demo)/events.jsonl"
{
printf '%s\n' '{"event":"SHUTDOWN","data":{"guest":true,"reason":"guest-reset"}}'
printf '%s\n' '{"event":"SHUTDOWN","data":{"guest":true,"reason":"guest-shutdown"}}'
} > "$ev"
run verdict_last_event "$ev"
[ "$output" = "guest-shutdown" ]
}