mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 19:07:17 +01:00
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:
parent
4c024a24f0
commit
a2d672f7fd
7
vmbox/.gitignore
vendored
Normal file
7
vmbox/.gitignore
vendored
Normal 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
|
||||
26
vmbox/.pre-commit-config.yaml
Normal file
26
vmbox/.pre-commit-config.yaml
Normal 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
103
vmbox/README.md
Normal 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
87
vmbox/bin/vm
Executable 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
39
vmbox/guest/dummy-creds.sh
Executable 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
171
vmbox/guest/provision.sh
Normal 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
59
vmbox/install.sh
Executable 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
169
vmbox/lib/base.sh
Normal 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
133
vmbox/lib/common.sh
Normal 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
104
vmbox/lib/launch.sh
Normal 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
121
vmbox/lib/overlay.sh
Normal 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
84
vmbox/lib/qmp.py
Executable 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
104
vmbox/lib/recorder.py
Executable 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
64
vmbox/lib/repo.sh
Normal 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
119
vmbox/lib/run.sh
Normal 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
29
vmbox/lib/screenshot.sh
Normal 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
62
vmbox/lib/seed.sh
Normal 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
49
vmbox/lib/smoke.sh
Normal 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
83
vmbox/lib/ssh.sh
Normal 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
105
vmbox/lib/verdict.sh
Normal 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
114
vmbox/tests/test_vmbox.bats
Normal 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" ]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user