mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 19:07:17 +01:00
vmbox: UEFI sandboxes and 'vm wake' RTC-wake emulation for poweroff/hibernate; worktree copies skip gitignored files
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
file length / file-length (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
palette drift / palette (push) Has been cancelled
shared gate tests / gate unit tests (push) Has been cancelled
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
file length / file-length (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
palette drift / palette (push) Has been cancelled
shared gate tests / gate unit tests (push) Has been cancelled
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
aeac9caaa1
commit
694c013b6b
@ -21,6 +21,6 @@ repos:
|
||||
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' --
|
||||
entry: bash -c 'for f in "$@"; do n=$(wc -l < "$f"); if [ "$n" -gt 250 ]; then echo "$f has $n lines, over the 250-line cap"; exit 1; fi; done' --
|
||||
language: system
|
||||
files: ^vmbox/
|
||||
|
||||
178
vmbox/DOCS-rtc-wake.md
Normal file
178
vmbox/DOCS-rtc-wake.md
Normal file
@ -0,0 +1,178 @@
|
||||
# RTC wake emulation (`vm wake`) and UEFI mode
|
||||
|
||||
Tests the "PC wakes itself" flow (wake-alarm's `rtcwake -m no -t <epoch>` +
|
||||
hibernate or poweroff) in a sandbox:
|
||||
|
||||
```bash
|
||||
vm new rw --uefi # or without --uefi (SeaBIOS, default)
|
||||
VMBOX_MEM=2048 vm hibernate-setup rw # swap + resume= (overlay only)
|
||||
vm wake rw --accel 10 -- 'sudo rtcwake -m no -s 120 && sudo systemctl hibernate'
|
||||
bash tests/rtcwake_e2e.sh [--uefi] # the end-to-end proof, see below
|
||||
```
|
||||
|
||||
Run anything that boots a guest under
|
||||
`~/.claude/scripts/capped.sh` (`CAP_MEM=4G CAP_CPU_PCT=20`) with
|
||||
`VMBOX_MEM=2048`: a 4 GiB guest does not fit a 4 GiB cap.
|
||||
|
||||
## Why the firmware half is emulated
|
||||
|
||||
Measured on this host (qemu 11.1.1, edk2-ovmf 202608, q35, 2026-10-01). The
|
||||
guest armed an alarm, went down, and we waited well past it:
|
||||
|
||||
| Guest goes to | Firmware | CMOS after the alarm time | QEMU |
|
||||
|---|---|---|---|
|
||||
| S3 (`rtcwake -m mem`) | SeaBIOS | — | `SUSPEND`, then **`WAKEUP` 20 s later** — native |
|
||||
| S5 (`systemctl poweroff`) | SeaBIOS | REG_C = 0xB0 (alarm flag set) | stays `shutdown` |
|
||||
| S4 (`rtcwake -m disk`) | SeaBIOS | REG_C = 0xB0 | stays `shutdown` |
|
||||
| S5 | OVMF | REG_C = 0xB0 | stays `shutdown` |
|
||||
| S4 (`systemctl hibernate`) | OVMF | REG_C = 0xB0 | stays `shutdown` |
|
||||
|
||||
In every S4/S5 row the guest had also set `RTC_EN` in ACPI `PM1_EN`
|
||||
(0x0520 / 0x0720) and AIE in REG_B — everything a real chipset needs. QEMU
|
||||
only acts on a wakeup request while the machine is *suspended* (S3); a
|
||||
powered-off machine has no wake path at all. So `vm wake` plays the board:
|
||||
it reads the alarm, waits, and presses the power button.
|
||||
|
||||
## VM artefacts around a resume
|
||||
|
||||
All four were found by the e2e test, each broke a resume the guest itself
|
||||
had done correctly, and none exists on real hardware. A hibernate resume in
|
||||
QEMU is always a relaunch of the process, so any device state that lived
|
||||
only in the old process is gone when the guest's drivers pick up again:
|
||||
|
||||
- **kvmclock.** A KVM guest's persistent clock is kvmclock, i.e. the HOST's
|
||||
wall clock. A cold boot still follows the RTC (rtc_cmos `hctosys`), but a
|
||||
resume does not: powered on with its RTC at the alarm instant, a guest
|
||||
resumed **102 s before its own alarm**. Real hardware reads the CMOS on
|
||||
resume. `vm hibernate-setup` therefore adds `no-kvmclock`, and `vm wake`
|
||||
fails with exit 14 (`clock-not-from-rtc`) when the guest clock is behind
|
||||
the alarm it was powered on at, instead of passing silently.
|
||||
- **TCO watchdog.** Every resumed guest was reset ~20 s in
|
||||
(`WATCHDOG action=reset`; the native S3 wake hit the same ~34 s in).
|
||||
Measured on the ICH9 TCO block at 0x660: `TCO_TMR` (0x672) reads 0x0032
|
||||
after a cold boot — the driver's 30 s heartbeat at 0.6 s/tick — and
|
||||
0x0004, QEMU's 2.4 s reset default, right after a resume, while the
|
||||
resumed driver still reports `timeout=30`. A fresh QEMU process lost the
|
||||
value, the restored driver does not reprogram it, and its 15 s pings
|
||||
cannot beat a 2.4 s timer. `vm wake` powers on with
|
||||
`-action watchdog=none` (`VMBOX_WATCHDOG_ACTION`): expiries are still
|
||||
logged in `events.jsonl` (one per ping, every 15 s), but ignored. The
|
||||
guest's own watchdog setup is untouched.
|
||||
- **9p host share.** Observed: after a resume every access to
|
||||
`/mnt/hostrepo` blocked, and the next poweroff sat 4.5 min in "A stop job
|
||||
is running for /mnt/hostrepo"; unmounting before and remounting after
|
||||
fails with "no channels available for device hostrepo", and unbinding the
|
||||
device after the resume hangs. Inferred, not verified: 9pnet_virtio does
|
||||
not re-create its virtqueue on restore. What works (measured):
|
||||
`hibernate-setup`'s system-sleep hook unmounts and unbinds the device
|
||||
before hibernating, then rebinds and remounts it after.
|
||||
- **Display (NOT fixed).** virtio-gpu fences never complete after a resume:
|
||||
Xorg — the resumed one and a freshly started one alike — sits in
|
||||
`dma_fence_default_wait`. Anything that needs X after a resume cannot be
|
||||
tested here. `guest/vmbox-x11.sh` now bounds its one X call
|
||||
(`i3 --get-socketpath`), which had hung every later `vm run`;
|
||||
`hibernate-setup` installs that copy, since older base images bake in the
|
||||
unbounded one.
|
||||
|
||||
## What `vm wake` does
|
||||
|
||||
1. Boots the sandbox if needed, then `set-action shutdown=pause` over QMP — the
|
||||
runtime form of `-no-shutdown`. Launch defaults are untouched; only this
|
||||
run's machine pauses instead of exiting when the guest goes down.
|
||||
2. Runs the guest command over ssh and waits (bounded,
|
||||
`VMBOX_WAKE_DOWN_TIMEOUT`, 240 s) for QMP `SHUTDOWN`. `SUSPEND_DISK` right
|
||||
before it means hibernate (ACPI S4); anything else (reboot, panic) is exit 13.
|
||||
3. Reads the **stopped** guest's CMOS through HMP port I/O
|
||||
(`lib/rtc_alarm.py`): alarm sec/min/hour (0x01/0x03/0x05), REG_B (AIE,
|
||||
BCD/binary, 12/24 h), `PM1_EN` at 0x602, and the RTC itself via
|
||||
`qom-get /machine rtc-time`. Register C is never read — reading clears it.
|
||||
QEMU's FADT has no day alarm ("alarms up to one day"), so the wake instant
|
||||
is the next time the RTC matches h:m:s — the hardware's own comparison.
|
||||
4. AIE off → `NO WAKE ARMED`, the machine is left off, exit 9.
|
||||
5. Waits `ceil(lead / accel)` real seconds (`--accel`, default 1; refuses with
|
||||
exit 12 beyond `--max-wait`, default 900 s).
|
||||
6. Quits QEMU and relaunches it with `-rtc base=<alarm instant>` (one-shot
|
||||
`VMBOX_RTC_ONCE`; the sandbox's `--rtc` pin is never rewritten), on the
|
||||
**same** RAM, SMP and firmware the guest went down on — read from the
|
||||
running QEMU's `/proc/<pid>/cmdline`, because a resume on different RAM
|
||||
discards the image. For S5 that is a cold boot; for S4 the kernel resumes
|
||||
from swap. Exit 11 if ssh does not answer within 180 s.
|
||||
7. Prints `RTCWAKE key=value` lines (below) and `WAKE: … after its alarm`;
|
||||
exit 14 if the guest's clock came back behind the alarm (see below).
|
||||
|
||||
Relaunching instead of resuming the paused process is faithful, not a
|
||||
shortcut: in S4/S5 only the disk and the CMOS survive on real hardware, and
|
||||
both are carried over (the overlay is the disk; the RTC base is the clock).
|
||||
|
||||
| Key | Meaning |
|
||||
|---|---|
|
||||
| `down` | `poweroff` or `hibernate` |
|
||||
| `rtc_at_down` | guest RTC when the harness read the CMOS |
|
||||
| `cmos_regs`, `aie`, `rtc_en` | raw registers and the two enable bits |
|
||||
| `armed_epoch`, `armed_utc` | decoded alarm (RTC read as UTC, like `rtcwake`) |
|
||||
| `lead_guest_s` | alarm − RTC at read time |
|
||||
| `accel`, `waited_real_s` | acceleration and the real wait |
|
||||
| `poweron_rtc` | RTC the machine was powered on with |
|
||||
| `back_after_real_s` | relaunch → ssh answering, wall clock |
|
||||
| `up_after_alarm_guest_s` | guest `date +%s` at first ssh − alarm |
|
||||
| `result` | `woke` (0), `not-armed` (9), `never-down` (10), `not-back` (11), `too-far` (12), `wrong-stop:*` (13), `clock-not-from-rtc` (14) |
|
||||
|
||||
## UEFI mode
|
||||
|
||||
`vm new <name> --uefi` stores `firmware=uefi` in meta and copies
|
||||
`OVMF_VARS.4m.fd` to the sandbox's `efivars.fd`; `vm run <name> --uefi …`
|
||||
overrides one launch (a BIOS sandbox gets its varstore on first UEFI boot).
|
||||
Paths come from edk2-ovmf's descriptor
|
||||
`/usr/share/qemu/firmware/60-edk2-ovmf-x86_64-4m.json`
|
||||
(`VMBOX_OVMF_CODE`/`VMBOX_OVMF_VARS` override). A missing OVMF is an error,
|
||||
never a silent BIOS boot.
|
||||
|
||||
- The cloud image boots both ways unmodified: its ESP (`/efi`) carries
|
||||
`EFI/BOOT/BOOTX64.EFI`, the only loader a fresh varstore tries.
|
||||
- The varstore persists across launches (systemd keeps `HibernateLocation`
|
||||
there) and is re-copied by `vm reset`.
|
||||
- `vm list` shows a `FW` column. The base image is unchanged.
|
||||
|
||||
## `vm hibernate-setup`
|
||||
|
||||
The base image has a 512 MiB btrfs swapfile and a systemd initramfs, but no
|
||||
`resume=`: a hibernated BIOS guest cold-boots and logs `PM: Image not found`.
|
||||
`guest/hibernate-setup.sh` grows `/swap/swapfile` to RAM + 512 MiB
|
||||
(`btrfs filesystem mkswapfile`), adds `resume=UUID=… resume_offset=…
|
||||
no-kvmclock` to `GRUB_CMDLINE_LINUX_DEFAULT`, regenerates `grub.cfg` (shared
|
||||
by the BIOS and EFI GRUB), reboots, and proves it: those args in
|
||||
`/proc/cmdline` and logind `CanHibernate` = `yes`. Overlay only — never
|
||||
`vm build --force`.
|
||||
|
||||
`vm run <name> 'sudo systemctl hibernate'` now reports `VERDICT: hibernated`
|
||||
(exit 8) instead of a DIRTY shutdown, and skips the exit-code recovery boot,
|
||||
which would have consumed the image.
|
||||
|
||||
## What this proves, and what only the host can
|
||||
|
||||
Proven in the VM (by `tests/rtcwake_e2e.sh`):
|
||||
|
||||
- the wake instant is armed correctly: the alarm decoded from the CMOS
|
||||
equals the guest's own `/sys/class/rtc/rtc0/wakealarm`;
|
||||
- it **survives the guest going down**, for poweroff and for hibernate — the
|
||||
value is read *after* the stop, with AIE and `RTC_EN` still set;
|
||||
- after a power-on at that instant the guest comes back: a boot-time unit
|
||||
runs after the alarm (poweroff), or the same boot resumes with a
|
||||
pre-hibernate process alive and the post-resume hook run (hibernate);
|
||||
- an alarm disabled before going down leaves the machine off (exit 9).
|
||||
|
||||
Not proven — host only:
|
||||
|
||||
- **that real firmware wakes at all.** That is the whole emulated part. The
|
||||
host's S4 wake was verified separately (wake-alarm
|
||||
`DOCS-wake-enforcement.md`); S5 stays unverified until tested on the host.
|
||||
- firmware quirks: a BIOS setting that disables RTC wake, an RTC kept in
|
||||
local time, a board that ignores `RTC_EN` from S5, a day-of-month alarm
|
||||
(QEMU has none, so alarms > 24 h ahead cannot be modelled);
|
||||
- boot/resume *duration* on real hardware, and the real disk/ESP layout;
|
||||
- anything graphical after a resume (the guest display is dead, above), and
|
||||
how real GPU/audio/USB drivers come back from S4;
|
||||
- the guest's wall clock after resume follows the RTC only because of
|
||||
`no-kvmclock`; on the host it is the firmware's CMOS, as it should be.
|
||||
|
||||
Report a green run as "passed in vmbox, not verified on the host".
|
||||
@ -131,8 +131,11 @@ Two things that installer taught us, both worth knowing before you run it:
|
||||
| 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 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 |
|
||||
@ -157,6 +160,17 @@ Both mutate a live overlay only. The base image is never rebuilt for this:
|
||||
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
|
||||
@ -165,9 +179,10 @@ stay on the host or stay untested.
|
||||
|
||||
## Tests
|
||||
|
||||
`bats tests/test_vmbox.bats` — 39 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.
|
||||
`bats tests/test_vmbox.bats tests/test_rtcwake.bats` — 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
|
||||
|
||||
|
||||
38
vmbox/bin/vm
38
vmbox/bin/vm
@ -19,8 +19,17 @@ 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
|
||||
new <name> [--rtc <ts>] [--uefi]
|
||||
Create a sandbox (instant; thin overlay)
|
||||
run <name> [--uefi|--bios] <command...>
|
||||
Run a command in the sandbox, report the verdict
|
||||
start <name> Boot the sandbox and wait until ssh answers
|
||||
wake <name> [--accel N] [--max-wait S] [--] <command...>
|
||||
Run a command that arms the RTC and powers off or
|
||||
hibernates; power the guest back on when its
|
||||
CMOS alarm fires (see DOCS-rtc-wake.md)
|
||||
hibernate-setup <name> [--swap-mb N]
|
||||
Swap + resume= so the guest can hibernate/resume
|
||||
ssh <name> [args...] Interactive shell in the sandbox
|
||||
scp <name> <src> <dest> Copy a host file into the sandbox
|
||||
share <host-path> Expose a host dir to sandboxes (read-only bind)
|
||||
@ -40,6 +49,8 @@ Usage: vm <command> [args]
|
||||
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.
|
||||
--uefi Boot with OVMF instead of SeaBIOS. On `new` it is stored in meta
|
||||
(with a per-sandbox varstore); on `run` it is this launch only.
|
||||
|
||||
Examples:
|
||||
vm new demo --rtc 2026-08-22T20:55:00
|
||||
@ -70,18 +81,30 @@ cmd_scp() {
|
||||
ok "copied $src -> $name:$dest"
|
||||
}
|
||||
|
||||
# Boot without running anything. VMBOX_START_TIMEOUT bounds the ssh wait.
|
||||
cmd_start() {
|
||||
local name
|
||||
name="$(validate_vm_name "${1:-}")"
|
||||
require_vm "$name"
|
||||
vm_is_running "$name" && { log "sandbox '$name' is already running"; return 0; }
|
||||
launch_vm "$name" >/dev/null
|
||||
vm_wait_ssh "$name" "${VMBOX_START_TIMEOUT:-150}" ||
|
||||
die "sandbox '$name' did not become reachable"
|
||||
ok "sandbox '$name' is up"
|
||||
}
|
||||
|
||||
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"
|
||||
local found=0 d name state rtc fw
|
||||
printf '%-20s %-12s %-6s %s\n' "NAME" "STATE" "FW" "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:--}"
|
||||
fw="$(meta_get "$name" firmware 2>/dev/null || echo bios)"
|
||||
printf '%-20s %-12s %-6s %s\n' "$name" "$state" "${fw:-bios}" "${rtc:--}"
|
||||
done
|
||||
(( found )) || echo "(none)"
|
||||
}
|
||||
@ -96,6 +119,11 @@ main() {
|
||||
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 "$@" ;;
|
||||
start) require_host_deps; source "$VMBOX_LIB_DIR/launch.sh"
|
||||
source "$VMBOX_LIB_DIR/ssh.sh"; cmd_start "$@" ;;
|
||||
wake) require_host_deps; source "$VMBOX_LIB_DIR/rtcwake.sh"; cmd_wake "$@" ;;
|
||||
hibernate-setup)
|
||||
require_host_deps; source "$VMBOX_LIB_DIR/hibernate.sh"; cmd_hibernate_setup "$@" ;;
|
||||
ssh) source "$VMBOX_LIB_DIR/ssh.sh"; vm_ssh_interactive "$@" ;;
|
||||
scp) source "$VMBOX_LIB_DIR/ssh.sh"; cmd_scp "$@" ;;
|
||||
screenshot) source "$VMBOX_LIB_DIR/screenshot.sh"; vm_screenshot "$@" ;;
|
||||
|
||||
121
vmbox/guest/hibernate-setup.sh
Normal file
121
vmbox/guest/hibernate-setup.sh
Normal file
@ -0,0 +1,121 @@
|
||||
#!/bin/bash
|
||||
# ============================================================================
|
||||
# Runs INSIDE a sandbox, as root, via `vm hibernate-setup`. Makes
|
||||
# `systemctl hibernate` both allowed and resumable, in the overlay only.
|
||||
#
|
||||
# The base image already has a 512 MiB btrfs swapfile and a systemd-based
|
||||
# initramfs (which resumes from `resume=` on its own -- no mkinitcpio hook).
|
||||
# What it lacks:
|
||||
# - swap big enough for the image (systemd refuses when swap < used RAM);
|
||||
# - resume=/resume_offset= on the kernel cmdline. Without them a BIOS guest
|
||||
# cold-boots and silently discards the image ("PM: Image not found").
|
||||
# UEFI guests can also resume via systemd's HibernateLocation variable,
|
||||
# but the cmdline works for both, so both get it.
|
||||
# - no-kvmclock. With kvmclock the kernel's persistent clock is the HOST's
|
||||
# wall clock, so a resumed guest ignores the RTC: `vm wake` powered a
|
||||
# guest on with its RTC at the alarm instant and it resumed 102 s BEFORE
|
||||
# its own alarm (measured 2026-10-01). A cold boot is unaffected (rtc_cmos
|
||||
# hctosys sets the clock), which is why only hibernate exposed it. Real
|
||||
# hardware reads the CMOS on resume; this makes the guest do the same.
|
||||
# - a sleep hook that unmounts the 9p host share before hibernating and
|
||||
# remounts it after. Its fids belong to the QEMU process that dies at S4;
|
||||
# after the relaunch every access to /mnt/hostrepo blocked, and the next
|
||||
# poweroff sat 4.5 min in "A stop job is running for /mnt/hostrepo"
|
||||
# (measured). Real machines have no 9p, so this only removes a VM artefact.
|
||||
# - the current /etc/profile.d/vmbox-x11.sh (if passed in as $2): the one
|
||||
# baked into older base images makes an unbounded X call, and X is
|
||||
# wedged after a resume, so every `vm run` after one hung.
|
||||
#
|
||||
# Usage: hibernate-setup.sh [swap-MiB [vmbox-x11.sh]] (0 = RAM + 512 MiB)
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
readonly SWAPFILE=/swap/swapfile
|
||||
readonly GRUB_DEFAULT=/etc/default/grub
|
||||
swap_mb="${1:-0}"
|
||||
x11_profile="${2:-}"
|
||||
|
||||
mem_mb() {
|
||||
local key value _
|
||||
while read -r key value _; do
|
||||
[[ "$key" == MemTotal: ]] && { printf '%d' $(( value / 1024 )); return 0; }
|
||||
done < /proc/meminfo
|
||||
return 1
|
||||
}
|
||||
|
||||
ensure_swap() {
|
||||
local want_mb="$1" have_mb=0
|
||||
[[ -f "$SWAPFILE" ]] && have_mb=$(( $(stat -c %s "$SWAPFILE") / 1048576 ))
|
||||
if (( have_mb < want_mb )); then
|
||||
echo "hibernate-setup: swapfile ${have_mb} MiB -> ${want_mb} MiB"
|
||||
swapoff "$SWAPFILE" 2>/dev/null || true
|
||||
rm -f "$SWAPFILE"
|
||||
install -d -m 700 "$(dirname "$SWAPFILE")"
|
||||
# mkswapfile sets NOCOW and allocates contiguously, which hibernation
|
||||
# to a btrfs swapfile requires.
|
||||
btrfs filesystem mkswapfile --size "${want_mb}m" "$SWAPFILE" >/dev/null
|
||||
fi
|
||||
swapon --show=NAME --noheadings | grep -qx "$SWAPFILE" || swapon "$SWAPFILE"
|
||||
grep -qE "^${SWAPFILE}[[:space:]]" /etc/fstab ||
|
||||
printf '%s none swap defaults 0 0\n' "$SWAPFILE" >> /etc/fstab
|
||||
}
|
||||
|
||||
set_resume_cmdline() {
|
||||
local uuid offset
|
||||
uuid="$(findmnt -no UUID -T "$SWAPFILE")"
|
||||
offset="$(btrfs inspect-internal map-swapfile -r "$SWAPFILE")"
|
||||
[[ -n "$uuid" && "$offset" =~ ^[0-9]+$ ]] ||
|
||||
{ echo "hibernate-setup: cannot locate $SWAPFILE (uuid='$uuid' offset='$offset')" >&2; exit 1; }
|
||||
# Idempotent: drop any previous resume tokens, then append the current ones.
|
||||
sed -i -E '/^GRUB_CMDLINE_LINUX_DEFAULT=/{
|
||||
s/ ?(resume(_offset)?=[^ "]*|no-kvmclock)//g
|
||||
s/"$/ resume=UUID='"$uuid"' resume_offset='"$offset"' no-kvmclock"/
|
||||
}' "$GRUB_DEFAULT"
|
||||
grep -q "resume_offset=$offset" "$GRUB_DEFAULT" ||
|
||||
{ echo "hibernate-setup: failed to edit $GRUB_DEFAULT" >&2; exit 1; }
|
||||
grub-mkconfig -o /boot/grub/grub.cfg >/dev/null 2>&1
|
||||
grep -q "resume_offset=$offset" /boot/grub/grub.cfg ||
|
||||
{ echo "hibernate-setup: grub.cfg lacks the resume args" >&2; exit 1; }
|
||||
echo "hibernate-setup: resume=UUID=$uuid resume_offset=$offset no-kvmclock (effective next boot)"
|
||||
}
|
||||
|
||||
install_9p_hook() {
|
||||
# Unmount AND unbind while the old QEMU still answers, rebind after
|
||||
# resume. Remounting alone fails ("no channels available for device
|
||||
# hostrepo") and unbinding after the resume hangs -- measured; the likely
|
||||
# cause, not verified, is that 9pnet_virtio does not rebuild its
|
||||
# virtqueue on restore. /run is tmpfs, i.e. in the hibernation image, so
|
||||
# the device name survives the trip.
|
||||
cat > /usr/lib/systemd/system-sleep/vmbox-9p <<'HOOK'
|
||||
#!/bin/sh
|
||||
# vmbox: the 9p share cannot survive QEMU being relaunched (see hibernate-setup.sh).
|
||||
drv=/sys/bus/virtio/drivers/9pnet_virtio
|
||||
state=/run/vmbox-9p-dev
|
||||
case "$1/$2" in
|
||||
pre/hibernate|pre/hybrid-sleep|pre/suspend-then-hibernate)
|
||||
mountpoint -q /mnt/hostrepo && ! timeout 10 umount /mnt/hostrepo && exit 0
|
||||
for d in "$drv"/virtio*; do
|
||||
[ "$(tr -d '\0' < "$d/mount_tag" 2>/dev/null)" = hostrepo ] || continue
|
||||
echo "${d##*/}" > "$drv/unbind" && echo "${d##*/}" > "$state"
|
||||
done ;;
|
||||
post/*)
|
||||
[ -s "$state" ] && cat "$state" > "$drv/bind" && rm -f "$state"
|
||||
mountpoint -q /mnt/hostrepo || timeout 10 mount /mnt/hostrepo ;;
|
||||
esac
|
||||
exit 0
|
||||
HOOK
|
||||
chmod 755 /usr/lib/systemd/system-sleep/vmbox-9p
|
||||
}
|
||||
|
||||
main() {
|
||||
[[ $EUID -eq 0 ]] || { echo "hibernate-setup: run as root" >&2; exit 1; }
|
||||
[[ "$swap_mb" =~ ^[0-9]+$ ]] || { echo "hibernate-setup: swap size must be MiB" >&2; exit 1; }
|
||||
(( swap_mb > 0 )) || swap_mb=$(( $(mem_mb) + 512 ))
|
||||
ensure_swap "$swap_mb"
|
||||
install_9p_hook
|
||||
[[ -z "$x11_profile" ]] || install -m 644 "$x11_profile" /etc/profile.d/vmbox-x11.sh
|
||||
set_resume_cmdline
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@ -54,6 +54,9 @@ fi
|
||||
# i3 tools look here when I3SOCK is unset; setting it explicitly avoids a
|
||||
# second round of X round-trips in tests that shell out repeatedly.
|
||||
if [ -n "${DISPLAY:-}" ] && [ -z "${I3SOCK:-}" ] && command -v i3 >/dev/null 2>&1; then
|
||||
I3SOCK="$(i3 --get-socketpath 2>/dev/null || true)"
|
||||
# Bounded: this is an X round-trip, and after a hibernate resume the
|
||||
# guest's virtio-gpu never completes a fence, so Xorg is wedged and an
|
||||
# unbounded call hung every later `vm run` (measured 2026-10-01).
|
||||
I3SOCK="$(timeout 2 i3 --get-socketpath 2>/dev/null || true)"
|
||||
if [ -n "$I3SOCK" ]; then export I3SOCK; else unset I3SOCK; fi
|
||||
fi
|
||||
|
||||
@ -14,7 +14,7 @@ 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)
|
||||
readonly -a PACKAGES=(qemu-base libisoburn openssh python jq edk2-ovmf)
|
||||
|
||||
if [[ $EUID -eq 0 ]]; then
|
||||
echo "Do not run install.sh as root: it links into \$HOME and would" >&2
|
||||
|
||||
93
vmbox/lib/firmware.sh
Normal file
93
vmbox/lib/firmware.sh
Normal file
@ -0,0 +1,93 @@
|
||||
#!/bin/bash
|
||||
# ============================================================================
|
||||
# vmbox: optional UEFI firmware (OVMF) for a sandbox.
|
||||
#
|
||||
# The default stays SeaBIOS: every existing sandbox and test was measured on
|
||||
# it, and nothing here changes a sandbox that did not opt in. UEFI is chosen
|
||||
# per sandbox (`vm new <name> --uefi`, stored in meta) or per launch
|
||||
# (`VMBOX_FIRMWARE=uefi|bios`, which `vm run <name> --uefi` sets).
|
||||
#
|
||||
# The cloud image boots both ways unmodified: it carries an ESP at /efi with
|
||||
# the removable-media loader EFI/BOOT/BOOTX64.EFI, which is the ONLY path a
|
||||
# fresh varstore tries -- so no boot entry has to be created first.
|
||||
# ============================================================================
|
||||
|
||||
# Sourced by both launch.sh and overlay.sh, and launch_vm sources overlay.sh
|
||||
# at call time -- so a second source must be a no-op, not a readonly error.
|
||||
[[ -n "${VMBOX_FIRMWARE_SOURCED:-}" ]] && return 0
|
||||
readonly VMBOX_FIRMWARE_SOURCED=1
|
||||
|
||||
# shellcheck source=common.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
|
||||
|
||||
# edk2-ovmf's own descriptor for the 4 MB non-secure-boot build on q35. Read
|
||||
# rather than hard-coded so a package layout change moves us with it.
|
||||
readonly VMBOX_OVMF_DESCRIPTOR="${VMBOX_OVMF_DESCRIPTOR:-/usr/share/qemu/firmware/60-edk2-ovmf-x86_64-4m.json}"
|
||||
|
||||
# Per-sandbox NVRAM. It must persist across launches: systemd stores the
|
||||
# hibernation resume location (HibernateLocation) here, so a fresh copy per
|
||||
# boot would silently turn every UEFI resume into a cold boot.
|
||||
vm_efivars() { printf '%s/%s/efivars.fd' "$VMBOX_VMS_DIR" "$1"; }
|
||||
|
||||
# $1 = executable | nvram-template. Env overrides win over the descriptor.
|
||||
_firmware_file() {
|
||||
local kind="$1" path=""
|
||||
case "$kind" in
|
||||
executable) path="${VMBOX_OVMF_CODE:-}" ;;
|
||||
nvram-template) path="${VMBOX_OVMF_VARS:-}" ;;
|
||||
esac
|
||||
if [[ -z "$path" ]]; then
|
||||
[[ -r "$VMBOX_OVMF_DESCRIPTOR" ]] ||
|
||||
die "no OVMF descriptor at $VMBOX_OVMF_DESCRIPTOR -- install edk2-ovmf ($VMBOX_ROOT/install.sh)"
|
||||
# Checked here, not in require_host_deps: only UEFI needs jq, and a
|
||||
# BIOS-only host must keep working without it.
|
||||
command -v jq >/dev/null || die "UEFI mode needs jq -- run: $VMBOX_ROOT/install.sh"
|
||||
path="$(jq -er --arg k "$kind" '.mapping[$k].filename // empty' "$VMBOX_OVMF_DESCRIPTOR")" ||
|
||||
die "OVMF descriptor $VMBOX_OVMF_DESCRIPTOR has no mapping.$kind.filename"
|
||||
fi
|
||||
[[ -r "$path" ]] || die "OVMF $kind not readable: $path"
|
||||
printf '%s' "$path"
|
||||
}
|
||||
|
||||
# Effective firmware for the next launch: per-launch env, then meta, then bios.
|
||||
firmware_of() {
|
||||
local name="$1" fw
|
||||
fw="${VMBOX_FIRMWARE:-$(meta_get "$name" firmware 2>/dev/null || true)}"
|
||||
fw="${fw:-bios}"
|
||||
case "$fw" in
|
||||
bios|uefi) printf '%s' "$fw" ;;
|
||||
*) die "unknown firmware '$fw' (expected bios or uefi)" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Fresh varstore from the template. Called by `vm new --uefi`, by `vm reset`
|
||||
# (a "pristine" sandbox must not keep the last run's boot entries or
|
||||
# HibernateLocation), and lazily the first time a BIOS sandbox boots as UEFI.
|
||||
firmware_init_vars() {
|
||||
local name="$1" vars template
|
||||
vars="$(vm_efivars "$name")"
|
||||
# Explicit exits: callers use `|| firmware_init_vars`, and set -e is off
|
||||
# inside anything called from an || list.
|
||||
template="$(_firmware_file nvram-template)" || exit 1
|
||||
install -m 644 "$template" "$vars" || die "could not create $vars"
|
||||
}
|
||||
|
||||
# Append the sandbox's firmware args to the array named by $2; none for bios.
|
||||
# A nameref, not printed output read back through a process substitution: a
|
||||
# `die` inside one is swallowed, and a missing OVMF would then quietly boot
|
||||
# the sandbox as BIOS -- the opposite of what was asked for.
|
||||
firmware_qemu_args() {
|
||||
local name="$1" fw code vars
|
||||
local -n _fw_out="$2"
|
||||
fw="$(firmware_of "$name")" || exit 1
|
||||
[[ "$fw" == uefi ]] || return 0
|
||||
code="$(_firmware_file executable)" || exit 1
|
||||
vars="$(vm_efivars "$name")"
|
||||
[[ -f "$vars" ]] || firmware_init_vars "$name"
|
||||
# Unit 0 read-only: the firmware code is a shared system file. Unit 1 is
|
||||
# this sandbox's own NVRAM copy, writable.
|
||||
_fw_out+=(
|
||||
-drive "if=pflash,format=raw,unit=0,readonly=on,file=$code"
|
||||
-drive "if=pflash,format=raw,unit=1,file=$vars"
|
||||
)
|
||||
}
|
||||
67
vmbox/lib/hibernate.sh
Normal file
67
vmbox/lib/hibernate.sh
Normal file
@ -0,0 +1,67 @@
|
||||
#!/bin/bash
|
||||
# ============================================================================
|
||||
# vmbox: `vm hibernate-setup <name>` -- make a sandbox able to hibernate and
|
||||
# resume. Overlay only: the sealed base is never rebuilt for this, because
|
||||
# `vm build --force` re-seals a new checksum under every existing sandbox.
|
||||
# ============================================================================
|
||||
|
||||
# shellcheck source=common.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
|
||||
# shellcheck source=launch.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/launch.sh"
|
||||
# shellcheck source=ssh.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/ssh.sh"
|
||||
|
||||
readonly HIBERNATE_GUEST_SCRIPT="/var/tmp/vmbox-hibernate-setup.sh"
|
||||
readonly HIBERNATE_X11_PROFILE="/var/tmp/vmbox-x11.sh"
|
||||
|
||||
_hibernate_boot() {
|
||||
local name="$1"
|
||||
launch_vm "$name" >/dev/null
|
||||
vm_wait_ssh "$name" 150 || die "sandbox '$name' did not become reachable"
|
||||
}
|
||||
|
||||
# Clean poweroff, then wait for qemu to go. Not `vm run`: its exit-code
|
||||
# recovery would boot the guest again behind our back.
|
||||
_hibernate_poweroff() {
|
||||
local name="$1" waited=0
|
||||
vm_ssh_exec "$name" 'sudo systemctl poweroff' </dev/null >/dev/null 2>&1 || true
|
||||
while vm_is_running "$name" && (( waited < 90 )); do
|
||||
sleep 1; waited=$(( waited + 1 ))
|
||||
done
|
||||
vm_is_running "$name" && die "sandbox '$name' did not power off within 90s"
|
||||
return 0
|
||||
}
|
||||
|
||||
cmd_hibernate_setup() {
|
||||
local name swap_mb=0
|
||||
name="$(validate_vm_name "${1:-}")"; shift || true
|
||||
require_vm "$name"
|
||||
if [[ "${1:-}" == --swap-mb ]]; then
|
||||
swap_mb="${2:-}"; [[ "$swap_mb" =~ ^[0-9]+$ ]] || die "--swap-mb needs MiB"
|
||||
fi
|
||||
|
||||
vm_is_running "$name" || _hibernate_boot "$name"
|
||||
vm_scp_to "$name" "$VMBOX_GUEST_DIR/hibernate-setup.sh" "$HIBERNATE_GUEST_SCRIPT" ||
|
||||
die "could not copy the setup script in"
|
||||
vm_scp_to "$name" "$VMBOX_GUEST_DIR/vmbox-x11.sh" "$HIBERNATE_X11_PROFILE" ||
|
||||
die "could not copy vmbox-x11.sh in"
|
||||
vm_ssh_exec "$name" "sudo bash $HIBERNATE_GUEST_SCRIPT $swap_mb $HIBERNATE_X11_PROFILE" </dev/null ||
|
||||
die "hibernate setup failed in the guest"
|
||||
|
||||
# The cmdline only takes effect on the next boot.
|
||||
log "Rebooting '$name' so the new kernel cmdline applies"
|
||||
_hibernate_poweroff "$name"
|
||||
_hibernate_boot "$name"
|
||||
|
||||
# Prove it rather than assume it: the running kernel carries the resume
|
||||
# args (and no-kvmclock, so a resume reads the RTC), and logind (what
|
||||
# `systemctl hibernate` asks) says yes.
|
||||
vm_ssh_exec "$name" 'grep -q "resume_offset=.*no-kvmclock" /proc/cmdline' </dev/null ||
|
||||
die "the guest booted without resume_offset=/no-kvmclock on its cmdline"
|
||||
local can
|
||||
# sudo: polkit denies CanHibernate to a non-seat ssh session.
|
||||
can="$(vm_ssh_exec "$name" 'sudo busctl call org.freedesktop.login1 /org/freedesktop/login1 org.freedesktop.login1.Manager CanHibernate' </dev/null)"
|
||||
[[ "$can" == 's "yes"' ]] || die "logind CanHibernate says: ${can:-nothing}"
|
||||
ok "'$name' can hibernate and resume (logind CanHibernate=yes)"
|
||||
}
|
||||
74
vmbox/lib/last_event.py
Executable file
74
vmbox/lib/last_event.py
Executable file
@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Classify the last stop recorded in a vmbox QMP event log (events.jsonl).
|
||||
|
||||
Prints exactly one of:
|
||||
guest-shutdown the guest powered itself off (ACPI S5)
|
||||
hibernate the guest hibernated: QEMU emits SUSPEND_DISK right before
|
||||
the SHUTDOWN when the guest enters ACPI S4
|
||||
guest-reset the guest rebooted (-no-reboot turns that into a SHUTDOWN)
|
||||
panic GUEST_PANICKED (pvpanic)
|
||||
host a SHUTDOWN the guest did not initiate
|
||||
<reason> any other SHUTDOWN reason, verbatim
|
||||
none no stop at all
|
||||
|
||||
Usage: last_event.py <events.jsonl>
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _events(lines: Iterable[str]) -> Iterable[dict[str, Any]]:
|
||||
for raw in lines:
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
msg = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(msg, dict):
|
||||
yield msg
|
||||
|
||||
|
||||
def classify(lines: Iterable[str]) -> str:
|
||||
"""Return the kind of the LAST stop in the log (see module docstring)."""
|
||||
kind = "none"
|
||||
suspend_disk = False
|
||||
for msg in _events(lines):
|
||||
event = msg.get("event", "")
|
||||
if event == "GUEST_PANICKED":
|
||||
kind = "panic"
|
||||
elif event == "SUSPEND_DISK":
|
||||
# Only meaningful for the SHUTDOWN that follows it.
|
||||
suspend_disk = True
|
||||
elif event == "SHUTDOWN":
|
||||
data = msg.get("data", {})
|
||||
reason = data.get("reason", "")
|
||||
if not reason:
|
||||
reason = "guest-shutdown" if data.get("guest") else "host"
|
||||
kind = (
|
||||
"hibernate" if suspend_disk and reason == "guest-shutdown" else reason
|
||||
)
|
||||
suspend_disk = False
|
||||
return kind
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) != 2:
|
||||
print(__doc__, file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
with open(sys.argv[1], encoding="utf-8") as fh:
|
||||
print(classify(fh))
|
||||
except FileNotFoundError:
|
||||
print("none")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@ -11,6 +11,8 @@
|
||||
|
||||
# shellcheck source=common.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
|
||||
# shellcheck source=firmware.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/firmware.sh"
|
||||
|
||||
readonly VMBOX_MEM="${VMBOX_MEM:-4096}"
|
||||
readonly VMBOX_SMP="${VMBOX_SMP:-4}"
|
||||
@ -55,7 +57,10 @@ launch_vm() {
|
||||
overlay="$(vm_overlay "$name")"
|
||||
port="$(meta_get "$name" ssh_port)"
|
||||
index="$(meta_get "$name" index)"
|
||||
rtc="$(meta_get "$name" rtc 2>/dev/null || true)"
|
||||
# VMBOX_RTC_ONCE is a one-launch override and is never written to meta:
|
||||
# `vm wake` powers the guest on at the instant its alarm fired, without
|
||||
# moving the sandbox's own pin.
|
||||
rtc="${VMBOX_RTC_ONCE:-$(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")"
|
||||
@ -111,6 +116,9 @@ launch_vm() {
|
||||
)
|
||||
|
||||
[[ -n "$rtc" ]] && args+=(-rtc "base=$rtc")
|
||||
# One-shot, for `vm wake`: see DOCS-rtc-wake.md ("TCO watchdog").
|
||||
[[ -n "${VMBOX_WATCHDOG_ACTION:-}" ]] && args+=(-action "watchdog=$VMBOX_WATCHDOG_ACTION")
|
||||
firmware_qemu_args "$name" args
|
||||
|
||||
_launch_qemu "$name" "$serial" "$qmp" args
|
||||
printf '%s' "$serial_n"
|
||||
|
||||
@ -9,6 +9,8 @@
|
||||
|
||||
# shellcheck source=common.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
|
||||
# shellcheck source=firmware.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/firmware.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.
|
||||
@ -46,19 +48,22 @@ _overlay_next_index() {
|
||||
}
|
||||
|
||||
overlay_new() {
|
||||
local name rtc="" arg
|
||||
local name rtc="" firmware=bios 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 ;;
|
||||
--uefi) firmware=uefi; shift ;;
|
||||
*) 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
|
||||
# Fail before anything is created, not halfway through.
|
||||
if [[ "$firmware" == uefi ]]; then _firmware_file executable >/dev/null || exit 1; fi
|
||||
|
||||
local dir index
|
||||
dir="$(vm_dir "$name")"
|
||||
@ -71,9 +76,11 @@ overlay_new() {
|
||||
# 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"
|
||||
meta_set "$name" firmware "$firmware"
|
||||
[[ "$firmware" == uefi ]] && firmware_init_vars "$name"
|
||||
|
||||
_overlay_create_disk "$name"
|
||||
ok "sandbox '$name' ready (ssh port $(( VMBOX_SSH_PORT_BASE + index )), peer ${VMBOX_GUEST_SUBNET}.${index})"
|
||||
ok "sandbox '$name' ready ($firmware) (ssh port $(( VMBOX_SSH_PORT_BASE + index )), peer ${VMBOX_GUEST_SUBNET}.${index})"
|
||||
}
|
||||
|
||||
_overlay_create_disk() {
|
||||
@ -106,6 +113,12 @@ overlay_reset() {
|
||||
|
||||
# Discard everything the guest did: the overlay holds 100% of its writes.
|
||||
_overlay_create_disk "$name"
|
||||
# The varstore is guest-writable state too (boot entries, systemd's
|
||||
# HibernateLocation), so pristine means a fresh copy of it as well.
|
||||
if [[ -f "$(vm_efivars "$name")" ]]; then
|
||||
rm -f "$(vm_efivars "$name")"
|
||||
firmware_init_vars "$name"
|
||||
fi
|
||||
rm -f "$(vm_dir "$name")"/serial.*.log "$(vm_events "$name")" "$(vm_pidfile "$name")"
|
||||
ok "sandbox '$name' reset to pristine"
|
||||
}
|
||||
|
||||
@ -8,6 +8,9 @@ any VNC or SPICE viewer installed on the host.
|
||||
Usage:
|
||||
qmp.py <qmp.sock> screendump <out.png>
|
||||
qmp.py <qmp.sock> status
|
||||
qmp.py <qmp.sock> pause-on-shutdown # a guest poweroff/hibernate leaves
|
||||
# QEMU alive in 'shutdown' (vm wake)
|
||||
qmp.py <qmp.sock> quit
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -19,17 +22,17 @@ import sys
|
||||
from typing import Any, TextIO
|
||||
|
||||
|
||||
def _open(path: str) -> tuple[socket.socket, TextIO]:
|
||||
def open_session(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")
|
||||
command(stream, "qmp_capabilities")
|
||||
return sock, stream
|
||||
|
||||
|
||||
def _command(stream: TextIO, name: str, **args: Any) -> dict[str, Any]:
|
||||
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:
|
||||
@ -55,7 +58,7 @@ def main() -> int:
|
||||
print(f"qmp: no such socket: {path} (is the sandbox running?)", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
sock, stream = _open(path)
|
||||
sock, stream = open_session(path)
|
||||
try:
|
||||
if action == "screendump":
|
||||
if len(sys.argv) < 4:
|
||||
@ -63,9 +66,16 @@ def main() -> int:
|
||||
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")
|
||||
reply = command(stream, "screendump", filename=out, format="png")
|
||||
elif action == "status":
|
||||
reply = _command(stream, "query-status")
|
||||
reply = command(stream, "query-status")
|
||||
elif action == "pause-on-shutdown":
|
||||
# Runtime equivalent of -no-shutdown, so launch defaults stay as
|
||||
# they are: the machine stops, but its CMOS (and the RTC alarm in
|
||||
# it) stays readable instead of vanishing with the process.
|
||||
reply = command(stream, "set-action", shutdown="pause")
|
||||
elif action == "quit":
|
||||
reply = command(stream, "quit")
|
||||
else:
|
||||
print(f"qmp: unknown action '{action}'", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
@ -89,11 +89,14 @@ _repo_clone() {
|
||||
ok "$repo_name cloned at $head (committed state)"
|
||||
}
|
||||
|
||||
# Gitignored files are skipped: build output (a Flutter app's build/ and
|
||||
# .dart_tool/ run to gigabytes) is not "the edits in front of you", and
|
||||
# pushing it through 9p OOM-killed a 4 GiB-capped harness (2026-10-01).
|
||||
_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/" ||
|
||||
"rsync -a --exclude .git --filter=':- .gitignore' $GUEST_MOUNT/$repo_name/ ~/$repo_name/" ||
|
||||
die "rsync failed"
|
||||
ok "$repo_name copied (working-tree state)"
|
||||
}
|
||||
|
||||
215
vmbox/lib/rtc_alarm.py
Executable file
215
vmbox/lib/rtc_alarm.py
Executable file
@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read a stopped guest's CMOS RTC alarm from the host, over QMP.
|
||||
|
||||
This is the "did the wake instant survive the guest going down?" probe for
|
||||
`vm wake`. QEMU keeps a powered-off guest's CMOS alive while the machine sits
|
||||
in the 'shutdown' runstate (see `qmp.py pause-on-shutdown`), so the alarm the
|
||||
guest armed with `rtcwake -m no` can be read straight out of the emulated
|
||||
MC146818 -- not from anything the guest reported about itself.
|
||||
|
||||
The alarm registers hold only h:m:s (QEMU's FADT has no day/month alarm:
|
||||
"alarms up to one day"), so the wake instant is the NEXT time the RTC matches
|
||||
them -- the same comparison the hardware makes.
|
||||
|
||||
Usage:
|
||||
rtc_alarm.py read <qmp.sock.ctl> # JSON report; guest must be stopped
|
||||
rtc_alarm.py now <qmp.sock.ctl> # current RTC as a UTC epoch
|
||||
rtc_alarm.py decode <rtc_epoch> <sec> <min> <hour> <reg_b> [<pm1_en>]
|
||||
# offline decode (unit tests); hex ok
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any, TextIO
|
||||
|
||||
from qmp import command, open_session
|
||||
|
||||
REG_B_SET_24H = 0x02
|
||||
REG_B_BINARY = 0x04
|
||||
REG_B_AIE = 0x20
|
||||
PM1_EN_RTC_EN = 0x400
|
||||
DONT_CARE = 0xC0
|
||||
# ICH9 ACPI PM I/O base as programmed by both SeaBIOS and OVMF on q35.
|
||||
PM_BASE = int(os.environ.get("VMBOX_PM_BASE", "0x600"), 0)
|
||||
CMOS_ALARM_REGS = {"sec": 0x01, "min": 0x03, "hour": 0x05, "reg_b": 0x0B}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AlarmReport:
|
||||
"""Everything `vm wake` needs to decide whether, and when, to power on."""
|
||||
|
||||
rtc_now: datetime
|
||||
alarm: datetime | None
|
||||
aie: bool
|
||||
rtc_en: bool | None
|
||||
regs: dict[str, int]
|
||||
|
||||
@property
|
||||
def armed(self) -> bool:
|
||||
"""AIE on and registers decodable: a real board would wake."""
|
||||
return self.aie and self.alarm is not None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""JSON-ready view; epochs are the RTC read as UTC, like rtcwake does."""
|
||||
out: dict[str, Any] = {
|
||||
"rtc_now": _iso(self.rtc_now),
|
||||
"rtc_now_epoch": int(self.rtc_now.timestamp()),
|
||||
"aie": self.aie,
|
||||
"rtc_en": self.rtc_en,
|
||||
"armed": self.armed,
|
||||
"regs": {k: f"0x{v:02x}" for k, v in self.regs.items()},
|
||||
"alarm": None,
|
||||
"alarm_epoch": None,
|
||||
"lead_s": None,
|
||||
}
|
||||
if self.alarm is not None:
|
||||
out["alarm"] = _iso(self.alarm)
|
||||
out["alarm_epoch"] = int(self.alarm.timestamp())
|
||||
out["lead_s"] = int((self.alarm - self.rtc_now).total_seconds())
|
||||
return out
|
||||
|
||||
|
||||
def _iso(when: datetime) -> str:
|
||||
# The exact shape QEMU's `-rtc base=` accepts.
|
||||
return when.strftime("%Y-%m-%dT%H:%M:%S")
|
||||
|
||||
|
||||
def decode_field(raw: int, bcd: bool) -> int | None:
|
||||
"""One alarm byte; None when it is a don't-care (matches any value)."""
|
||||
if raw & DONT_CARE == DONT_CARE:
|
||||
return None
|
||||
return (raw >> 4) * 10 + (raw & 0x0F) if bcd else raw
|
||||
|
||||
|
||||
def decode_hour(raw: int, bcd: bool, h24: bool) -> int | None:
|
||||
"""Hour alarm byte, honouring 12-hour mode (bit 7 = PM)."""
|
||||
if raw & DONT_CARE == DONT_CARE:
|
||||
return None
|
||||
if h24:
|
||||
return decode_field(raw, bcd)
|
||||
value = decode_field(raw & 0x7F, bcd)
|
||||
if value is None:
|
||||
return None
|
||||
return value % 12 + (12 if raw & 0x80 else 0)
|
||||
|
||||
|
||||
def next_match(
|
||||
now: datetime, sec: int | None, minute: int | None, hour: int | None
|
||||
) -> datetime | None:
|
||||
"""First instant >= now whose h:m:s matches; None if none within a day."""
|
||||
start = now.replace(microsecond=0)
|
||||
for step in range(24 * 3600 + 1):
|
||||
t = start + timedelta(seconds=step)
|
||||
if (
|
||||
(sec is None or t.second == sec)
|
||||
and (minute is None or t.minute == minute)
|
||||
and (hour is None or t.hour == hour)
|
||||
):
|
||||
return t
|
||||
return None
|
||||
|
||||
|
||||
def build_report(
|
||||
now: datetime, regs: dict[str, int], pm1_en: int | None
|
||||
) -> AlarmReport:
|
||||
"""Decode raw register values into an AlarmReport."""
|
||||
reg_b = regs["reg_b"]
|
||||
bcd = not reg_b & REG_B_BINARY
|
||||
h24 = bool(reg_b & REG_B_SET_24H)
|
||||
alarm = next_match(
|
||||
now,
|
||||
decode_field(regs["sec"], bcd),
|
||||
decode_field(regs["min"], bcd),
|
||||
decode_hour(regs["hour"], bcd, h24),
|
||||
)
|
||||
rtc_en = None if pm1_en is None else bool(pm1_en & PM1_EN_RTC_EN)
|
||||
return AlarmReport(now, alarm, bool(reg_b & REG_B_AIE), rtc_en, regs)
|
||||
|
||||
|
||||
def _hmp(stream: TextIO, line: str) -> str:
|
||||
reply = command(stream, "human-monitor-command", **{"command-line": line})
|
||||
if "error" in reply:
|
||||
raise SystemExit(f"rtc_alarm: '{line}' failed: {reply['error']}")
|
||||
return str(reply.get("return", ""))
|
||||
|
||||
|
||||
def _port_in(stream: TextIO, size: str, port: int) -> int:
|
||||
match = re.search(r"=\s*(0x[0-9a-fA-F]+)", _hmp(stream, f"i /{size} 0x{port:x}"))
|
||||
if not match:
|
||||
raise SystemExit(f"rtc_alarm: unreadable I/O port 0x{port:x}")
|
||||
return int(match.group(1), 16)
|
||||
|
||||
|
||||
def _cmos(stream: TextIO, index: int) -> int:
|
||||
# Index then data. Never index 0x0C: reading register C clears the flags.
|
||||
_hmp(stream, f"o /b 0x70 0x{index:02x}")
|
||||
return _port_in(stream, "b", 0x71)
|
||||
|
||||
|
||||
def rtc_now(stream: TextIO) -> datetime:
|
||||
"""The guest RTC's current value, read as UTC."""
|
||||
reply = command(stream, "qom-get", path="/machine", property="rtc-time")
|
||||
if "error" in reply:
|
||||
raise SystemExit(f"rtc_alarm: cannot read rtc-time: {reply['error']}")
|
||||
tm = reply["return"]
|
||||
return datetime(
|
||||
tm["tm_year"] + 1900,
|
||||
tm["tm_mon"] + 1,
|
||||
tm["tm_mday"],
|
||||
tm["tm_hour"],
|
||||
tm["tm_min"],
|
||||
tm["tm_sec"],
|
||||
tzinfo=UTC,
|
||||
)
|
||||
|
||||
|
||||
def read_live(stream: TextIO) -> AlarmReport:
|
||||
"""Read the alarm out of a STOPPED guest's emulated CMOS."""
|
||||
status = command(stream, "query-status").get("return", {})
|
||||
if status.get("running"):
|
||||
# The CMOS index register is shared with the guest; poking it while
|
||||
# the guest runs could corrupt one of its own RTC accesses.
|
||||
raise SystemExit("rtc_alarm: guest is running; read only after it stopped")
|
||||
regs = {name: _cmos(stream, index) for name, index in CMOS_ALARM_REGS.items()}
|
||||
pm1_en = _port_in(stream, "h", PM_BASE + 2)
|
||||
return build_report(rtc_now(stream), regs, pm1_en)
|
||||
|
||||
|
||||
def _decode_cli(args: list[str]) -> AlarmReport:
|
||||
now = datetime.fromtimestamp(int(args[0]), tz=UTC)
|
||||
vals = [int(a, 0) for a in args[1:]]
|
||||
regs = dict(zip(("sec", "min", "hour", "reg_b"), vals[:4], strict=True))
|
||||
return build_report(now, regs, vals[4] if len(vals) > 4 else None)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
argv = sys.argv[1:]
|
||||
if argv[:1] == ["decode"] and len(argv) in (6, 7):
|
||||
print(json.dumps(_decode_cli(argv[1:]).as_dict()))
|
||||
return 0
|
||||
if len(argv) != 2 or argv[0] not in ("read", "now"):
|
||||
print(__doc__, file=sys.stderr)
|
||||
return 2
|
||||
if not os.path.exists(argv[1]):
|
||||
print(f"rtc_alarm: no such socket: {argv[1]}", file=sys.stderr)
|
||||
return 1
|
||||
sock, stream = open_session(argv[1])
|
||||
try:
|
||||
if argv[0] == "now":
|
||||
print(int(rtc_now(stream).timestamp()))
|
||||
else:
|
||||
print(json.dumps(read_live(stream).as_dict()))
|
||||
finally:
|
||||
stream.close()
|
||||
sock.close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
230
vmbox/lib/rtcwake.sh
Normal file
230
vmbox/lib/rtcwake.sh
Normal file
@ -0,0 +1,230 @@
|
||||
#!/bin/bash
|
||||
# ============================================================================
|
||||
# vmbox: `vm wake` -- emulate "the PC wakes itself on its RTC alarm".
|
||||
#
|
||||
# QEMU does not power a guest on from S5, or resume it from S4, when the CMOS
|
||||
# alarm fires: measured on qemu 11.1.1 (SeaBIOS and OVMF), the alarm flag in
|
||||
# register C sets and the machine stays in 'shutdown'. Only S3 wakes natively.
|
||||
# So the firmware's half is emulated here, and everything around it is real:
|
||||
#
|
||||
# 1. QEMU is told to PAUSE instead of exiting when the guest goes down, so
|
||||
# the guest's CMOS outlives the poweroff/hibernate the way it does on a
|
||||
# real board.
|
||||
# 2. The armed instant is read from that CMOS over QMP (rtc_alarm.py), not
|
||||
# from anything the guest said about itself.
|
||||
# 3. We wait until the guest RTC reaches it (optionally time-accelerated),
|
||||
# then power the machine on with the RTC at that instant -- a cold boot
|
||||
# for S5, a resume from the swap image for S4, on identical hardware.
|
||||
# ============================================================================
|
||||
|
||||
# shellcheck source=common.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
|
||||
# shellcheck source=launch.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/launch.sh"
|
||||
# shellcheck source=ssh.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/ssh.sh"
|
||||
|
||||
readonly WAKE_DOWN_TIMEOUT="${VMBOX_WAKE_DOWN_TIMEOUT:-240}"
|
||||
readonly WAKE_UP_TIMEOUT="${VMBOX_WAKE_UP_TIMEOUT:-180}"
|
||||
|
||||
# Exit codes, distinct so a test can assert the failure it expects.
|
||||
readonly WAKE_RC_NOT_ARMED=9 WAKE_RC_NEVER_DOWN=10 WAKE_RC_NOT_BACK=11
|
||||
readonly WAKE_RC_TOO_LONG=12 WAKE_RC_WRONG_STOP=13 WAKE_RC_CLOCK=14
|
||||
|
||||
_wake_qmp() { python3 "$VMBOX_LIB_DIR/qmp.py" "$(vm_qmp_ctl "$1")" "${@:2}"; }
|
||||
_wake_alarm() { python3 "$VMBOX_LIB_DIR/rtc_alarm.py" "$1" "$(vm_qmp_ctl "$2")"; }
|
||||
_wake_kv() { printf 'RTCWAKE %s=%s\n' "$1" "$2"; }
|
||||
_wake_utc() { date -u -d "@$1" +%Y-%m-%dT%H:%M:%S; }
|
||||
|
||||
# "mem smp firmware" of the RUNNING qemu. A resume needs the hardware it
|
||||
# hibernated on -- a different RAM size makes the kernel discard the image --
|
||||
# so the relaunch replays what was actually used, not today's defaults.
|
||||
_wake_hw_of_running() {
|
||||
local pid i mem="" smp="" fw=bios
|
||||
local -a argv=()
|
||||
pid="$(cat "$(vm_pidfile "$1")")"
|
||||
mapfile -d '' -t argv < "/proc/$pid/cmdline"
|
||||
for (( i = 0; i < ${#argv[@]}; i++ )); do
|
||||
case "${argv[i]}" in
|
||||
-m) mem="${argv[i + 1]}" ;;
|
||||
-smp) smp="${argv[i + 1]}" ;;
|
||||
if=pflash*) fw=uefi ;;
|
||||
esac
|
||||
done
|
||||
printf '%s %s %s' "$mem" "$smp" "$fw"
|
||||
}
|
||||
|
||||
# Poll the event log (past line $2) until the guest stops or panics.
|
||||
_wake_wait_down() {
|
||||
local name="$1" from="$2" events now deadline
|
||||
events="$(vm_events "$name")"
|
||||
printf -v now '%(%s)T' -1
|
||||
deadline=$(( now + WAKE_DOWN_TIMEOUT ))
|
||||
while (( now < deadline )); do
|
||||
tail -n +"$(( from + 1 ))" "$events" 2>/dev/null |
|
||||
grep -qE '"event": "(SHUTDOWN|GUEST_PANICKED)"' && return 0
|
||||
vm_is_running "$name" || return 1
|
||||
sleep 1
|
||||
printf -v now '%(%s)T' -1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
_wake_quit() {
|
||||
local name="$1" pid waited=0
|
||||
pid="$(cat "$(vm_pidfile "$name")" 2>/dev/null || true)"
|
||||
_wake_qmp "$name" quit >/dev/null 2>&1 || true
|
||||
while [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null && (( waited < 30 )); do
|
||||
sleep 1; waited=$(( waited + 1 ))
|
||||
done
|
||||
vm_is_running "$name" && die "qemu for '$name' did not exit after quit"
|
||||
return 0
|
||||
}
|
||||
|
||||
cmd_wake() {
|
||||
local name accel=1 max_wait=900
|
||||
name="$(validate_vm_name "${1:-}")"; shift || true
|
||||
require_vm "$name"
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--accel) accel="${2:-}"; shift 2 ;;
|
||||
--max-wait) max_wait="${2:-}"; shift 2 ;;
|
||||
--) shift; break ;;
|
||||
*) break ;;
|
||||
esac
|
||||
done
|
||||
[[ "$accel" =~ ^[1-9][0-9]*$ ]] || die "--accel needs a positive integer"
|
||||
[[ "$max_wait" =~ ^[0-9]+$ ]] || die "--max-wait needs seconds"
|
||||
[[ $# -gt 0 ]] || die "usage: vm wake <name> [--accel N] [--max-wait S] [--] <guest command...>"
|
||||
command -v jq >/dev/null || die "vm wake needs jq -- run: $VMBOX_ROOT/install.sh"
|
||||
|
||||
if ! vm_is_running "$name"; then
|
||||
launch_vm "$name" >/dev/null
|
||||
vm_wait_ssh "$name" 150 || die "sandbox '$name' did not become reachable"
|
||||
fi
|
||||
_wake_qmp "$name" pause-on-shutdown >/dev/null || die "could not set shutdown=pause over QMP"
|
||||
|
||||
local events hw before=0 ssh_pid
|
||||
events="$(vm_events "$name")"
|
||||
[[ -f "$events" ]] && before="$(wc -l < "$events")"
|
||||
hw="$(_wake_hw_of_running "$name")"
|
||||
|
||||
log "Running in '$name' (machine will pause, not exit, when it goes down): $*"
|
||||
vm_ssh_exec "$name" "$*" </dev/null &
|
||||
ssh_pid=$!
|
||||
if ! _wake_wait_down "$name" "$before"; then
|
||||
kill "$ssh_pid" 2>/dev/null || true
|
||||
warn "the guest did not go down within ${WAKE_DOWN_TIMEOUT}s"
|
||||
_wake_kv result never-down
|
||||
return "$WAKE_RC_NEVER_DOWN"
|
||||
fi
|
||||
kill "$ssh_pid" 2>/dev/null || true
|
||||
wait "$ssh_pid" 2>/dev/null || true
|
||||
|
||||
local scoped="$events.wake" kind
|
||||
tail -n +"$(( before + 1 ))" "$events" > "$scoped"
|
||||
kind="$(python3 "$VMBOX_LIB_DIR/last_event.py" "$scoped")"
|
||||
rm -f "$scoped"
|
||||
case "$kind" in
|
||||
guest-shutdown) kind=poweroff ;;
|
||||
hibernate) ;;
|
||||
*) warn "the guest stopped by '$kind', not a poweroff or hibernate"
|
||||
# Do not leave it paused in 'shutdown': it would list as running
|
||||
# while the guest is down, and the next `vm run` would stall.
|
||||
_wake_quit "$name"
|
||||
_wake_kv result "wrong-stop:$kind"
|
||||
return "$WAKE_RC_WRONG_STOP" ;;
|
||||
esac
|
||||
_wake_kv down "$kind"
|
||||
_wake_after_down "$name" "$accel" "$max_wait" "$hw"
|
||||
}
|
||||
|
||||
_wake_after_down() {
|
||||
local name="$1" accel="$2" max_wait="$3" hw="$4" report
|
||||
report="$(_wake_alarm read "$name")" || die "could not read the CMOS alarm over QMP"
|
||||
|
||||
local armed alarm_epoch down_epoch lead aie rtc_en regs
|
||||
armed="$(jq -r '.armed' <<< "$report")"
|
||||
aie="$(jq -r '.aie' <<< "$report")"
|
||||
rtc_en="$(jq -r '.rtc_en' <<< "$report")"
|
||||
regs="$(jq -c '.regs' <<< "$report")"
|
||||
down_epoch="$(jq -r '.rtc_now_epoch' <<< "$report")"
|
||||
_wake_kv rtc_at_down "$(_wake_utc "$down_epoch")"
|
||||
_wake_kv cmos_regs "$regs"
|
||||
_wake_kv aie "$aie"
|
||||
_wake_kv rtc_en "$rtc_en"
|
||||
if [[ "$armed" != true ]]; then
|
||||
warn "NO WAKE ARMED: alarm interrupt disabled in CMOS -- the machine would stay off"
|
||||
_wake_quit "$name"
|
||||
_wake_kv result not-armed
|
||||
return "$WAKE_RC_NOT_ARMED"
|
||||
fi
|
||||
alarm_epoch="$(jq -r '.alarm_epoch' <<< "$report")"
|
||||
lead="$(jq -r '.lead_s' <<< "$report")"
|
||||
_wake_kv armed_epoch "$alarm_epoch"
|
||||
_wake_kv armed_utc "$(_wake_utc "$alarm_epoch")"
|
||||
_wake_kv lead_guest_s "$lead"
|
||||
[[ "$rtc_en" == false ]] &&
|
||||
warn "PM1_EN.RTC_EN is clear: QEMU does not care, but a real chipset may not wake"
|
||||
|
||||
local real_wait=$(( (lead + accel - 1) / accel ))
|
||||
if (( real_wait > max_wait )); then
|
||||
warn "alarm is ${lead}s of guest time away (${real_wait}s real at x$accel) > --max-wait $max_wait"
|
||||
_wake_quit "$name"
|
||||
_wake_kv result too-far
|
||||
return "$WAKE_RC_TOO_LONG"
|
||||
fi
|
||||
log "Machine is off; alarm in ${lead}s guest time -> waiting ${real_wait}s real (x$accel)"
|
||||
local t0 t1
|
||||
printf -v t0 '%(%s)T' -1
|
||||
sleep "$real_wait"
|
||||
printf -v t1 '%(%s)T' -1
|
||||
_wake_kv accel "$accel"
|
||||
_wake_kv waited_real_s "$(( t1 - t0 ))"
|
||||
|
||||
# The firmware wakes the instant the RTC matches; with acceleration the
|
||||
# real RTC is still short of that, so power on AT the alarm instant.
|
||||
local now_epoch base
|
||||
now_epoch="$(_wake_alarm now "$name")"
|
||||
base=$(( now_epoch > alarm_epoch ? now_epoch : alarm_epoch ))
|
||||
_wake_kv poweron_rtc "$(_wake_utc "$base")"
|
||||
_wake_quit "$name"
|
||||
_wake_power_on "$name" "$hw" "$base" "$alarm_epoch"
|
||||
}
|
||||
|
||||
_wake_power_on() {
|
||||
local name="$1" hw="$2" base="$3" alarm_epoch="$4" mem smp fw t0 t1 guest_now
|
||||
read -r mem smp fw <<< "$hw"
|
||||
log "Powering '$name' on (RTC $(_wake_utc "$base"), ${mem}M x${smp}, $fw)"
|
||||
printf -v t0 '%(%s)T' -1
|
||||
# `env`, not a prefix assignment: VMBOX_MEM/SMP are readonly in this
|
||||
# shell (launch.sh), and the relaunch must not inherit this shell's values.
|
||||
# watchdog=none: QEMU reset every resumed guest ~20 s in. TCO_TMR reads
|
||||
# 0x32 (30 s) after a cold boot but 0x04 (2.4 s, QEMU's default) after a
|
||||
# resume, and the restored driver keeps pinging every 15 s (measured).
|
||||
# An emulator artefact, not the flow under test; the WATCHDOG events
|
||||
# still land in events.jsonl.
|
||||
if ! env VMBOX_MEM="$mem" VMBOX_SMP="$smp" VMBOX_FIRMWARE="$fw" \
|
||||
VMBOX_RTC_ONCE="$(_wake_utc "$base")" VMBOX_START_TIMEOUT="$WAKE_UP_TIMEOUT" \
|
||||
VMBOX_WATCHDOG_ACTION=none "$VMBOX_ROOT/bin/vm" start "$name" >/dev/null; then
|
||||
warn "the guest did not come back within ${WAKE_UP_TIMEOUT}s"
|
||||
_wake_kv result not-back
|
||||
return "$WAKE_RC_NOT_BACK"
|
||||
fi
|
||||
printf -v t1 '%(%s)T' -1
|
||||
guest_now="$(vm_ssh_exec "$name" 'date +%s' | tr -dc '0-9')"
|
||||
_wake_kv back_after_real_s "$(( t1 - t0 ))"
|
||||
_wake_kv guest_clock_at_ssh "$(_wake_utc "$guest_now")"
|
||||
_wake_kv up_after_alarm_guest_s "$(( guest_now - alarm_epoch ))"
|
||||
# Powered on AT the alarm, so a guest clock behind it means the guest did
|
||||
# not take its time from the RTC -- a resume under kvmclock does exactly
|
||||
# that, and every time-gated unit would then decide on the wrong time.
|
||||
if (( guest_now < alarm_epoch )); then
|
||||
warn "guest clock is $(( alarm_epoch - guest_now ))s BEHIND its own alarm: it did not read"
|
||||
warn "the RTC (kvmclock?). Run: vm hibernate-setup $name (adds no-kvmclock)"
|
||||
_wake_kv result clock-not-from-rtc
|
||||
return "$WAKE_RC_CLOCK"
|
||||
fi
|
||||
_wake_kv result woke
|
||||
ok "WAKE: guest is back $(( guest_now - alarm_epoch ))s (guest clock) after its alarm"
|
||||
}
|
||||
@ -43,7 +43,11 @@ run_in_vm() {
|
||||
local name
|
||||
name="$(validate_vm_name "${1:-}")"; shift || true
|
||||
require_vm "$name"
|
||||
[[ $# -gt 0 ]] || die "usage: vm run <name> <command...>"
|
||||
# Per-launch firmware override; only matters when this run boots the VM.
|
||||
while [[ "${1:-}" == --uefi || "${1:-}" == --bios ]]; do
|
||||
export VMBOX_FIRMWARE="${1#--}"; shift
|
||||
done
|
||||
[[ $# -gt 0 ]] || die "usage: vm run <name> [--uefi|--bios] <command...>"
|
||||
|
||||
local serial_n serial events
|
||||
if vm_is_running "$name"; then
|
||||
@ -174,6 +178,9 @@ _run_finish() {
|
||||
verdict_report "$name" "$serial" "$scoped" || vrc=$?
|
||||
rm -f "$scoped"
|
||||
|
||||
# A hibernated guest is left alone: booting it to read the exit code
|
||||
# would resume (and so consume) the very image the caller wants to test.
|
||||
(( vrc == 8 )) && return "$vrc"
|
||||
# If the guest stopped, recover the real exit code from the overlay.
|
||||
if ! vm_is_running "$name"; then
|
||||
local rc
|
||||
|
||||
@ -15,33 +15,11 @@ source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
|
||||
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
|
||||
# Echoes one of: guest-shutdown | hibernate | 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
|
||||
python3 "$VMBOX_LIB_DIR/last_event.py" "$events"
|
||||
}
|
||||
|
||||
# Did the guest reach the end of its shutdown sequence, or die partway?
|
||||
@ -67,6 +45,7 @@ verdict_is_alive() {
|
||||
|
||||
# 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
|
||||
# 8 hibernated
|
||||
verdict_report() {
|
||||
local name="$1" serial="$2" events="$3" kind
|
||||
kind="$(verdict_last_event "$events")"
|
||||
@ -81,6 +60,12 @@ verdict_report() {
|
||||
warn " '$POWEROFF_MARKER'; the sequence was cut short (see $serial)"
|
||||
return 3
|
||||
;;
|
||||
hibernate)
|
||||
# Not a failure and not a poweroff: S4 leaves no 'Power down' line,
|
||||
# which used to be misreported as a DIRTY shutdown.
|
||||
ok "VERDICT: hibernated -- the guest entered ACPI S4 (QMP SUSPEND_DISK)"
|
||||
return 8
|
||||
;;
|
||||
guest-reset)
|
||||
warn "VERDICT: REBOOTED, did not power off -- the guest reset instead of halting"
|
||||
return 4
|
||||
|
||||
155
vmbox/tests/rtcwake_e2e.sh
Executable file
155
vmbox/tests/rtcwake_e2e.sh
Executable file
@ -0,0 +1,155 @@
|
||||
#!/bin/bash
|
||||
# ============================================================================
|
||||
# vmbox: end-to-end proof of `vm wake` (RTC wake emulation), on a real guest.
|
||||
#
|
||||
# A. poweroff: arm `rtcwake -m no -s 120`, power off; the harness must find
|
||||
# the alarm in the stopped guest's CMOS, power it on at that instant,
|
||||
# and the guest's boot-time unit must run AFTER the armed instant.
|
||||
# B. hibernate: same, but `systemctl hibernate`; the guest must RESUME
|
||||
# (same boot_id, a pre-hibernate process still alive, the post-resume
|
||||
# hook ran after the armed instant) and survive a watchdog window.
|
||||
# C. negative control: arm, then `rtcwake -m disable`, power off; the
|
||||
# harness must refuse to power it on (exit 9). A harness that has only
|
||||
# ever woken a machine is untested.
|
||||
#
|
||||
# Usage: tests/rtcwake_e2e.sh [--uefi] [--keep]
|
||||
# Env: VM (sandbox, default rtcwake), ACCEL_POWEROFF (default 1: a real
|
||||
# two-minute wait), ACCEL_HIBERNATE (default 10), VMBOX_MEM (2048).
|
||||
# Run it under ~/.claude/scripts/capped.sh (CAP_MEM=4G CAP_CPU_PCT=20).
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
VMBOX_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
|
||||
readonly VMBOX_ROOT
|
||||
readonly VM_CLI="$VMBOX_ROOT/bin/vm"
|
||||
readonly VM="${VM:-rtcwake}"
|
||||
readonly ACCEL_POWEROFF="${ACCEL_POWEROFF:-1}"
|
||||
readonly ACCEL_HIBERNATE="${ACCEL_HIBERNATE:-10}"
|
||||
# Small enough for capped.sh's 4G ceiling, and swap is sized from it.
|
||||
export VMBOX_MEM="${VMBOX_MEM:-2048}"
|
||||
readonly PROBE=/var/tmp/rtcwake_guest_probe.sh
|
||||
readonly ARM='sudo rtcwake -m no -s 120 >/dev/null && sudo cp /sys/class/rtc/rtc0/wakealarm /var/lib/vmbox-wake/armed'
|
||||
|
||||
uefi=()
|
||||
keep=0
|
||||
pass=0
|
||||
fail=0
|
||||
|
||||
step() { printf '\n\033[0;34m=== %s\033[0m\n' "$*"; }
|
||||
check() {
|
||||
local label="$1"; shift
|
||||
if "$@"; then
|
||||
printf '\033[0;32m PASS\033[0m %s\n' "$label"; pass=$(( pass + 1 ))
|
||||
else
|
||||
printf '\033[0;31m FAIL\033[0m %s\n' "$label"; fail=$(( fail + 1 ))
|
||||
fi
|
||||
}
|
||||
guest() { "$VM_CLI" ssh "$VM" "$@" </dev/null 2>/dev/null; }
|
||||
# Not via `vm ssh`: it boots a stopped sandbox, which once turned a guest that
|
||||
# had been reset into a "still up" PASS.
|
||||
# Capture first: `list | grep -q` under pipefail fails on the SIGPIPE grep -q
|
||||
# causes by exiting early, i.e. exactly when the VM IS running.
|
||||
running() { local l; l="$("$VM_CLI" list)"; grep -qE "^$VM +running" <<< "$l"; }
|
||||
stopped() { ! running; }
|
||||
readonly EVENTS="$HOME/.local/share/vmbox/vms/$VM/events.jsonl"
|
||||
kv() { sed -n "s/^RTCWAKE $1=//p" <<< "$2" | tail -1; }
|
||||
probe_line() { guest "grep '^$1 ' /var/lib/vmbox-wake/log | tail -1"; }
|
||||
|
||||
# vm wake, keeping both its output and its exit code.
|
||||
wake() {
|
||||
local out rc=0
|
||||
out="$("$VM_CLI" wake "$VM" "$@" 2>&1)" || rc=$?
|
||||
printf '%s\n' "$out" | grep -E '^RTCWAKE|WAKE:|NO WAKE' >&2
|
||||
printf '%s\nRTCWAKE exit=%s\n' "$out" "$rc"
|
||||
}
|
||||
|
||||
setup() {
|
||||
step "Setup: fresh sandbox '$VM' ${uefi[*]:-(bios)}, VMBOX_MEM=$VMBOX_MEM"
|
||||
"$VM_CLI" rm "$VM" >/dev/null 2>&1 || true
|
||||
"$VM_CLI" new "$VM" "${uefi[@]}"
|
||||
"$VM_CLI" hibernate-setup "$VM"
|
||||
"$VM_CLI" scp "$VM" "$VMBOX_ROOT/tests/rtcwake_guest_probe.sh" "$PROBE" >/dev/null
|
||||
guest "sudo bash $PROBE install && sudo install -d /var/lib/vmbox-wake" >/dev/null
|
||||
}
|
||||
|
||||
case_poweroff() {
|
||||
step "A. poweroff -> harness powers on at the armed instant (x$ACCEL_POWEROFF)"
|
||||
local before out armed boot_epoch boot_id
|
||||
before="$(guest 'cat /proc/sys/kernel/random/boot_id')"
|
||||
out="$(wake --accel "$ACCEL_POWEROFF" -- "$ARM && sudo systemctl poweroff")"
|
||||
armed="$(kv armed_epoch "$out")"
|
||||
check "harness exit 0, result=woke" test "$(kv exit "$out")/$(kv result "$out")" = 0/woke
|
||||
check "stop classified as poweroff" test "$(kv down "$out")" = poweroff
|
||||
check "CMOS alarm == guest wakealarm ($armed)" \
|
||||
test "$armed" = "$(guest 'cat /var/lib/vmbox-wake/armed')"
|
||||
read -r _ boot_epoch boot_id <<< "$(probe_line boot)"
|
||||
check "boot unit ran at $boot_epoch >= armed $armed" test "${boot_epoch:-0}" -ge "${armed:-1}"
|
||||
check "it was a fresh boot (boot_id changed)" test "${boot_id:-}" != "$before"
|
||||
}
|
||||
|
||||
case_hibernate() {
|
||||
step "B. hibernate -> harness resumes at the armed instant (x$ACCEL_HIBERNATE)"
|
||||
local before pid out armed res_epoch res_id
|
||||
before="$(guest 'cat /proc/sys/kernel/random/boot_id')"
|
||||
guest "setsid -f sh -c 'echo \$\$ > /var/tmp/sleeper.pid; exec sleep 31536000' >/dev/null 2>&1 </dev/null"
|
||||
sleep 1
|
||||
pid="$(guest 'cat /var/tmp/sleeper.pid')"
|
||||
out="$(wake --accel "$ACCEL_HIBERNATE" -- "$ARM && sudo systemctl hibernate")"
|
||||
armed="$(kv armed_epoch "$out")"
|
||||
check "harness exit 0, result=woke" test "$(kv exit "$out")/$(kv result "$out")" = 0/woke
|
||||
check "stop classified as hibernate (QMP SUSPEND_DISK)" test "$(kv down "$out")" = hibernate
|
||||
check "CMOS alarm == guest wakealarm ($armed)" \
|
||||
test "$armed" = "$(guest 'cat /var/lib/vmbox-wake/armed')"
|
||||
check "resumed, not rebooted (boot_id unchanged)" \
|
||||
test "$(guest 'cat /proc/sys/kernel/random/boot_id')" = "$before"
|
||||
check "pre-hibernate sleep (pid $pid) still alive" \
|
||||
guest "tr '\\0' ' ' < /proc/$pid/cmdline | grep -q '^sleep 31536000'"
|
||||
read -r _ res_epoch res_id <<< "$(probe_line resume-hibernate)"
|
||||
check "resume hook ran at $res_epoch >= armed $armed" test "${res_epoch:-0}" -ge "${armed:-1}"
|
||||
check "resume hook ran in the same boot" test "${res_id:-}" = "$before"
|
||||
# Unmasked, the ICH9 TCO watchdog reset every resumed guest ~20 s in (see
|
||||
# DOCS-rtc-wake.md). A resume that dies a minute in is not a resume.
|
||||
local launches
|
||||
launches="$(grep -c _RECORDER_READY "$EVENTS")"
|
||||
sleep 45
|
||||
check "still running 45 s after resume" running
|
||||
check "never relaunched (still $launches launches)" \
|
||||
test "$(grep -c _RECORDER_READY "$EVENTS")" = "$launches"
|
||||
local now_id
|
||||
now_id="$(running && guest 'cat /proc/sys/kernel/random/boot_id' || echo not-running)"
|
||||
check "...and still the same boot ($now_id)" test "$now_id" = "$before"
|
||||
# The X session is dead after a resume (virtio-gpu, see DOCS); `vm run`
|
||||
# must still return rather than hang on it.
|
||||
check "vm run still returns after resume" timeout 60 "$VM_CLI" run "$VM" true
|
||||
printf ' info: WATCHDOG expiries in the log (ignored by vm wake): %s\n' \
|
||||
"$(grep -c '"WATCHDOG"' "$EVENTS" || true)"
|
||||
}
|
||||
|
||||
case_not_armed() {
|
||||
step "C. negative control: alarm disabled before poweroff -> must stay off"
|
||||
local out
|
||||
out="$(wake --accel 10 -- "$ARM && sudo rtcwake -m disable >/dev/null && sudo systemctl poweroff")"
|
||||
check "harness exit 9, result=not-armed" test "$(kv exit "$out")/$(kv result "$out")" = 9/not-armed
|
||||
check "AIE reported off" test "$(kv aie "$out")" = false
|
||||
check "machine left powered off" stopped
|
||||
}
|
||||
|
||||
main() {
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--uefi) uefi=(--uefi); shift ;;
|
||||
--keep) keep=1; shift ;;
|
||||
*) echo "usage: $0 [--uefi] [--keep]" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
setup
|
||||
case_poweroff
|
||||
case_hibernate
|
||||
case_not_armed
|
||||
(( keep )) || "$VM_CLI" rm "$VM" >/dev/null
|
||||
printf '\n\033[0;34m=== RESULT: %d passed, %d failed\033[0m\n' "$pass" "$fail"
|
||||
(( fail == 0 ))
|
||||
}
|
||||
|
||||
main "$@"
|
||||
53
vmbox/tests/rtcwake_guest_probe.sh
Executable file
53
vmbox/tests/rtcwake_guest_probe.sh
Executable file
@ -0,0 +1,53 @@
|
||||
#!/bin/bash
|
||||
# ============================================================================
|
||||
# Runs INSIDE the guest (as root) for tests/rtcwake_e2e.sh.
|
||||
#
|
||||
# rtcwake_guest_probe.sh install install a boot-time unit and a resume hook
|
||||
# rtcwake_guest_probe.sh <event> append "<event> <epoch> <boot_id>" to the log
|
||||
#
|
||||
# These stand in for the units a real wake flow depends on (wake-alarm's
|
||||
# on-boot timer, a post-resume hook): the test asserts they RAN, and when,
|
||||
# relative to the armed alarm -- not merely that the guest answered ssh.
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
readonly LOG_DIR=/var/lib/vmbox-wake
|
||||
readonly LOG="$LOG_DIR/log"
|
||||
readonly SELF=/usr/local/sbin/vmbox-wake-probe
|
||||
|
||||
record() {
|
||||
local boot_id epoch
|
||||
read -r boot_id < /proc/sys/kernel/random/boot_id
|
||||
printf -v epoch '%(%s)T' -1
|
||||
install -d -m 755 "$LOG_DIR"
|
||||
printf '%s %s %s\n' "$1" "$epoch" "$boot_id" >> "$LOG"
|
||||
}
|
||||
|
||||
install_probe() {
|
||||
install -m 755 "$0" "$SELF"
|
||||
cat > /etc/systemd/system/vmbox-wake-boot.service <<UNIT
|
||||
[Unit]
|
||||
Description=vmbox wake test: record that a boot happened, and when
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=$SELF boot
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
# systemd-sleep runs every executable here with (pre|post) (hibernate|...).
|
||||
cat > /usr/lib/systemd/system-sleep/vmbox-wake-probe <<HOOK
|
||||
#!/bin/sh
|
||||
[ "\$1" = post ] && exec $SELF "resume-\$2"
|
||||
exit 0
|
||||
HOOK
|
||||
chmod 755 /usr/lib/systemd/system-sleep/vmbox-wake-probe
|
||||
systemctl daemon-reload
|
||||
systemctl enable vmbox-wake-boot.service
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
install) install_probe ;;
|
||||
'') echo "usage: $0 install | <event>" >&2; exit 2 ;;
|
||||
*) record "$1" ;;
|
||||
esac
|
||||
189
vmbox/tests/test_rtcwake.bats
Normal file
189
vmbox/tests/test_rtcwake.bats
Normal file
@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env bats
|
||||
# ============================================================================
|
||||
# vmbox: host-side unit tests for UEFI mode and RTC wake emulation.
|
||||
#
|
||||
# No guest is booted: the CMOS decoder is fed register values (the BCD case
|
||||
# is the exact set read from a real guest on 2026-10-01), and firmware
|
||||
# selection runs against fake OVMF files. The booting proof is
|
||||
# tests/rtcwake_e2e.sh. VMBOX_HOME is a per-test temp dir, as in
|
||||
# test_vmbox.bats -- nothing here may touch ~/.local/share/vmbox.
|
||||
# ============================================================================
|
||||
|
||||
setup() {
|
||||
VMBOX_TEST_HOME="$(mktemp -d)"
|
||||
export VMBOX_HOME="$VMBOX_TEST_HOME"
|
||||
REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/.." && pwd -P)"
|
||||
export REPO_ROOT
|
||||
source "$REPO_ROOT/lib/common.sh"
|
||||
source "$REPO_ROOT/lib/verdict.sh"
|
||||
source "$REPO_ROOT/lib/firmware.sh"
|
||||
install -d "$VMBOX_VMS_DIR"
|
||||
}
|
||||
|
||||
teardown() {
|
||||
[[ -n "${VMBOX_TEST_HOME:-}" && -d "$VMBOX_TEST_HOME" ]] && rm -rf "$VMBOX_TEST_HOME"
|
||||
}
|
||||
|
||||
decode() { python3 "$REPO_ROOT/lib/rtc_alarm.py" decode "$@"; }
|
||||
field() { jq -r ".$1" <<< "$output"; }
|
||||
|
||||
# 2026-10-01T09:01:53Z, the RTC value read from the live guest.
|
||||
NOW=1790845313
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CMOS alarm decoding
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@test "decode: BCD 24h registers from a real guest give rtcwake's exact epoch" {
|
||||
run decode "$NOW" 0x33 0x02 0x09 0x22 0x0520
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$(field alarm_epoch)" = 1790845353 ] # what `rtcwake -s 60` printed
|
||||
[ "$(field armed)" = true ]
|
||||
[ "$(field rtc_en)" = true ]
|
||||
[ "$(field lead_s)" = 40 ]
|
||||
}
|
||||
|
||||
@test "decode: alarm interrupt off means NOT armed, even with valid registers" {
|
||||
run decode "$NOW" 0x33 0x02 0x09 0x02
|
||||
[ "$(field armed)" = false ]
|
||||
[ "$(field aie)" = false ]
|
||||
[ "$(field rtc_en)" = null ]
|
||||
}
|
||||
|
||||
@test "decode: binary mode (REG_B DM bit) is not read as BCD" {
|
||||
run decode "$NOW" 51 2 9 0x26
|
||||
[ "$(field alarm)" = 2026-10-01T09:02:51 ]
|
||||
}
|
||||
|
||||
@test "decode: 12-hour mode maps PM, 12 AM and 12 PM correctly" {
|
||||
run decode "$NOW" 0x00 0x00 0x89 0x20
|
||||
[ "$(field alarm)" = 2026-10-01T21:00:00 ]
|
||||
run decode "$NOW" 0x00 0x00 0x12 0x20
|
||||
[ "$(field alarm)" = 2026-10-02T00:00:00 ]
|
||||
run decode "$NOW" 0x00 0x00 0x92 0x20
|
||||
[ "$(field alarm)" = 2026-10-01T12:00:00 ]
|
||||
}
|
||||
|
||||
@test "decode: an alarm just past midnight wraps to the next day" {
|
||||
run decode 1790899190 0x10 0x00 0x00 0x22 # now 23:59:50
|
||||
[ "$(field alarm)" = 2026-10-02T00:00:10 ]
|
||||
[ "$(field lead_s)" = 20 ]
|
||||
}
|
||||
|
||||
@test "decode: an alarm already in the past is ~24h away, like the hardware" {
|
||||
run decode "$NOW" 0x00 0x00 0x09 0x22 # 09:00:00, now 09:01:53
|
||||
[ "$(field alarm)" = 2026-10-02T09:00:00 ]
|
||||
[ "$(field lead_s)" -gt 86000 ]
|
||||
}
|
||||
|
||||
@test "decode: don't-care bytes (0xC0+) match any value" {
|
||||
run decode "$NOW" 0xff 0xff 0xff 0x22
|
||||
[ "$(field lead_s)" = 0 ]
|
||||
}
|
||||
|
||||
@test "decode: impossible register values are not armed" {
|
||||
run decode "$NOW" 0x75 0x02 0x09 0x22 # second 75
|
||||
[ "$(field alarm)" = null ]
|
||||
[ "$(field armed)" = false ]
|
||||
}
|
||||
|
||||
@test "rtc_alarm: refuses unknown actions" {
|
||||
run python3 "$REPO_ROOT/lib/rtc_alarm.py" poke /nonexistent
|
||||
[ "$status" -eq 2 ]
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# hibernate classification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@test "last_event: SUSPEND_DISK then SHUTDOWN is a hibernate" {
|
||||
printf '%s\n' '{"event":"SUSPEND_DISK"}' \
|
||||
'{"event":"SHUTDOWN","data":{"guest":true,"reason":"guest-shutdown"}}' > "$VMBOX_HOME/e"
|
||||
run verdict_last_event "$VMBOX_HOME/e"
|
||||
[ "$output" = hibernate ]
|
||||
}
|
||||
|
||||
@test "last_event: a later plain poweroff is not a hibernate" {
|
||||
printf '%s\n' '{"event":"SUSPEND_DISK"}' \
|
||||
'{"event":"SHUTDOWN","data":{"guest":true,"reason":"guest-shutdown"}}' \
|
||||
'{"event":"SHUTDOWN","data":{"guest":true,"reason":"guest-shutdown"}}' > "$VMBOX_HOME/e"
|
||||
run verdict_last_event "$VMBOX_HOME/e"
|
||||
[ "$output" = guest-shutdown ]
|
||||
}
|
||||
|
||||
@test "verdict: a hibernate is exit 8, not a DIRTY shutdown" {
|
||||
install -d "$(vm_dir v)"
|
||||
printf '%s\n' '{"event":"SUSPEND_DISK"}' \
|
||||
'{"event":"SHUTDOWN","data":{"guest":true,"reason":"guest-shutdown"}}' > "$VMBOX_HOME/e"
|
||||
: > "$VMBOX_HOME/serial.log"
|
||||
run verdict_report v "$VMBOX_HOME/serial.log" "$VMBOX_HOME/e"
|
||||
[ "$status" -eq 8 ]
|
||||
[[ "$output" == *hibernated* ]]
|
||||
[[ "$output" != *DIRTY* ]]
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# firmware selection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_fake_ovmf() {
|
||||
printf 'code' > "$VMBOX_HOME/CODE.fd"
|
||||
printf 'vars-template' > "$VMBOX_HOME/VARS.fd"
|
||||
export VMBOX_OVMF_CODE="$VMBOX_HOME/CODE.fd" VMBOX_OVMF_VARS="$VMBOX_HOME/VARS.fd"
|
||||
}
|
||||
|
||||
@test "firmware: default is bios, and bios adds no qemu args" {
|
||||
install -d "$(vm_dir v)"
|
||||
[ "$(firmware_of v)" = bios ]
|
||||
local -a args=()
|
||||
firmware_qemu_args v args
|
||||
[ "${#args[@]}" -eq 0 ]
|
||||
}
|
||||
|
||||
@test "firmware: meta uefi wins over the default, env wins over meta" {
|
||||
install -d "$(vm_dir v)"
|
||||
meta_set v firmware uefi
|
||||
[ "$(firmware_of v)" = uefi ]
|
||||
VMBOX_FIRMWARE=bios run firmware_of v
|
||||
[ "$output" = bios ]
|
||||
}
|
||||
|
||||
@test "firmware: an unknown firmware name is refused" {
|
||||
install -d "$(vm_dir v)"
|
||||
VMBOX_FIRMWARE=coreboot run firmware_of v
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "firmware: uefi adds read-only code + a per-VM varstore copied from the template" {
|
||||
_fake_ovmf
|
||||
install -d "$(vm_dir v)"
|
||||
meta_set v firmware uefi
|
||||
local -a args=()
|
||||
firmware_qemu_args v args
|
||||
[ "${#args[@]}" -eq 4 ]
|
||||
[[ "${args[1]}" == *"unit=0,readonly=on,file=$VMBOX_HOME/CODE.fd" ]]
|
||||
[[ "${args[3]}" == *"unit=1,file=$(vm_efivars v)" ]]
|
||||
[ "$(cat "$(vm_efivars v)")" = vars-template ]
|
||||
}
|
||||
|
||||
@test "firmware: missing OVMF fails closed instead of booting as bios" {
|
||||
install -d "$(vm_dir v)"
|
||||
meta_set v firmware uefi
|
||||
VMBOX_OVMF_CODE="$VMBOX_HOME/nope.fd" run firmware_qemu_args v args
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"not readable"* ]]
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# vm wake argument handling (no guest needed)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@test "vm wake: unknown sandbox and bad --accel are refused before anything runs" {
|
||||
run "$REPO_ROOT/bin/vm" wake nosuch -- true
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"no such vm"* ]]
|
||||
install -d "$(vm_dir v)"
|
||||
run "$REPO_ROOT/bin/vm" wake v --accel 0 -- true
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"positive integer"* ]]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user