utils/vmbox/README.md
Krzysztof kuhy Rudnicki d5ba2360e4
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
crdt_sync_dart tests / test (push) Has been cancelled
crdt_sync_flutter tests / test (push) Has been cancelled
crdt-sync pre-commit / pre-commit (push) Has been cancelled
crdt-sync tests / test (push) Has been cancelled
ts_config tests / test (push) Has been cancelled
ts_core tests / test (push) Has been cancelled
web_ui tests / test (push) Has been cancelled
vmbox: split test_vmbox.bats (429 lines) under the 250-line cap: shared fixture in test_helper.bash, verdict and shim tests in their own files
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 09:20:50 +02:00

208 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 (~100s)
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
Every row below was measured against a real Arch guest, not inferred:
| Guest command | QMP event | Verdict | Exit |
|---|---|---|---|
| `systemctl poweroff` | `SHUTDOWN {guest:true, guest-shutdown}` + serial reaches `reboot: Power down` | clean poweroff | 0 |
| a stop that dies mid-sequence | `SHUTDOWN {guest:true}`, serial truncated | **dirty** | 3 |
| `systemctl reboot` | `SHUTDOWN {guest:true, guest-reset}` | **rebooted**, did not power off | 4 |
| `echo c > /proc/sysrq-trigger` | `GUEST_PANICKED` | **crashed** | 5 |
| a script that wedges | no event, liveness probe fails | **hung** | 6 |
**ssh returned the identical `Connection closed by remote host` for the
poweroff, the reboot AND the panic.** That is the entire argument for reading
the verdict from the host rather than from the connection.
`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.
## Worked example: testing the real shutdown installer
```bash
vm share ~/src/digital-wellbeing # read-only bind, not a symlink
vm share ~/src/utils/guard-lib # the config guard it installs
vm new st --rtc 2026-08-22T20:55:00 # inside the 21:00-05:00 window
vm repo st ~/src/digital-wellbeing --worktree
vm repo st ~/src/utils/guard-lib --worktree
vm run st 'sudo bash ~/guard-lib/install.sh'
vm run st 'sudo MIDNIGHT_SHUTDOWN_CONFIRM=y bash ~/digital-wellbeing/setup_midnight_shutdown.sh enable'
vm run st 'sudo systemctl poweroff' # -> VERDICT: clean poweroff
vm reset st # back to pristine
```
The full night-lockdown flow (lightdm topology, a hanging `openrgb` shim, the
per-minute timer, mask/stop, black screen, morning unlock) is scripted in
`digital-wellbeing/tests/vmbox_night_lockdown.sh` and is the reference for
how `vm lightdm` and `vm shim` fit together.
Verified: the config lands at `/etc/shutdown-schedule.conf` under guard-lib's
immutable protection, and `shutdown-timer-monitor.service`,
`day-specific-shutdown.timer` and `shutdown-timer-monitor-watchdog.timer` all
come up enabled -- while the host's own copy of that file kept its original
timestamp and `/etc/hosts` kept its `+i` flag.
Two things that installer taught us, both worth knowing before you run it:
- **It refuses to run unattended.** It ends on a confirmation prompt, and
`vm run` closes stdin on purpose (installers that prompt would otherwise
hang forever). Piping `echo y |` no longer works either -- with no terminal
it declines by design. Set `MIDNIGHT_SHUTDOWN_CONFIRM=y` to proceed.
- **It is subcommand-driven** (`enable` / `status`). Bare invocation prints
usage and exits 1 without changing anything.
## 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.
- **User-mode networking stalls concurrent TCP streams.** Measured 2026-09-14
(libslirp 4.9.4, qemu 11.1.1): 5 parallel 60 MB downloads, 2–4 freeze at
~10 MB and time out; one stream does 45 MB/s. The image therefore ships
`ParallelDownloads = 1` in pacman.conf — a 90-package `-Syu` that "ran at
500 KB/s" for minutes finished in 12 s single-stream. Guest-side TCP knobs
(window scaling, rmem) do not help; the real fix would be a tap/bridge NIC,
which needs root and is out of scope by design. Rebuild the base (`vm build`)
to pick this up; an already-built image lacks it.
- **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>] [--uefi]` | Create a sandbox (`--uefi`: OVMF + its own varstore) |
| `vm run <name> [--uefi\|--bios] <cmd...>` | Run a command, print the verdict (hibernate = exit 8) |
| `vm start <name>` | Boot and wait for ssh, nothing else |
| `vm wake <name> [--accel N] -- <cmd...>` | Run a command that arms the RTC and powers off / hibernates; power the guest on when its CMOS alarm fires |
| `vm hibernate-setup <name>` | Swap + `resume=` so `systemctl hibernate` works and resumes |
| `vm ssh <name>` | Interactive shell (starts the VM if needed) |
| `vm screenshot <name> [out.png]` | Capture the screen (locker/X11 tests) — verified: real 1280x800 PNG of the guest's i3 session, no viewer installed |
| `vm lightdm <name>` | Switch the sandbox to the host's login topology: lightdm autologin → i3, tty1 a plain getty. Reboots the guest and proves `lightdm.service` is active with an Xorg it owns |
| `vm shim <name> <tool> [exit[:N]\|hang\|sleep:S]` | Put a fake `<tool>` ahead of the real one on PATH (`/usr/local/sbin`). Every call is logged with an epoch to `/var/log/vmbox-shim/<tool>.log`, so the host can assert what ran and in what order. `hang` reproduces a tool that never returns |
| `vm reset <name>` | Wipe back to pristine |
| `vm rm <name>` / `vm list` | Delete / list sandboxes |
## Hardware the guest does not have: shim it
The guest has no RGB controllers, no NVIDIA card and no sound card, and the
golden image logs in with agetty+startx rather than a display manager. None
of that is a reason to test on the host. `vm lightdm` gives a sandbox the
host's login units (the night-lockdown scripts mask/stop `lightdm.service`
and `getty@.service`, which do not exist under startx), and `vm shim` stands
in for `openrgb`, `nvidia-smi`, `amixer` with a chosen behaviour and a call
log. The 2026-09-12 curfew failure -- openrgb 1.0-2 never returning, before
the teardown -- is `vm shim nl openrgb hang`, and the fix is proven by the
lockdown completing anyway.
Both mutate a live overlay only. The base image is never rebuilt for this:
`vm build --force` re-seals a new checksum while every existing sandbox still
backs onto the old file's *name*, which is silent qcow2 corruption, not a
loud refusal.
## The PC wakes itself: `vm wake` and UEFI mode
QEMU never powers a guest on from S5 or resumes it from S4 when the CMOS
alarm fires (measured, SeaBIOS and OVMF; only S3 wakes natively). `vm wake`
plays the firmware: it keeps the stopped guest's CMOS alive, reads the armed
alarm out of it over QMP, waits (optionally `--accel`erated) and powers the
guest on at that instant -- a cold boot after poweroff, a resume after
hibernate. `tests/rtcwake_e2e.sh` proves the arming, the survival and the
boot/resume units; that real firmware wakes stays a host-only question.
Details, measurements and limits: [DOCS-rtc-wake.md](DOCS-rtc-wake.md).
## Out of scope
A VM still cannot cover adb/phone tests (~36 files), real GPU monitors, and
the Nextcloud/SearXNG suites (whose fixtures assume a Debian layout); those
stay on the host or stay untested.
## Tests
`bats tests/` — host-side unit tests
for name validation, meta handling, serial rotation, the full verdict table,
firmware selection and the CMOS alarm decoder. They do not boot a VM; booting
is covered by the end-to-end demos (`tests/*_demo.sh`, `tests/rtcwake_e2e.sh`).
## Speed
Measured on this host, all with the cloud image already downloaded:
| | before | after |
|---|---|---|
| `vm run` (cold sandbox) | 41s | 14s |
| `vm run` (warm sandbox) | 30s | 3s |
Neither number was the guest being slow. `_run_settle` waited up to 30s for a
poweroff marker on EVERY run, including the majority that leave the guest
running, and the ssh readiness poll used a 5s connect timeout against a
forwarded port QEMU accepts before the guest is listening on it.
A cold `vm run` also waits for the guest's X session, because sshd now answers
before tty1's autologin has started Xorg; `VMBOX_X_WAIT=<seconds>` overrides it.
The build reads packages from the host's own `/var/cache/pacman/pkg`, mounted
read-only, rather than fetching 396 MiB over the internet. The guest keeps its
own writable cache, so a package the host lacks is downloaded as usual. See
`DOCS-session-results.md` for the build before/after.