mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-11 12:27:32 +01:00
scripts/vmbox/harness.sh provisions a disposable Arch guest, runs the REAL
install.sh in it, and adjudicates 8 cases on a host-side exit code. Plus
DOCS-vmbox.md, which states what a pass does and does not mean.
Covered: system-python imports, timer enabled, wake-alarm.service still
`static` (an [Install] there fires the alarm at *login*), the shutdown wrapper
shadowing /usr/bin/shutdown, the alarm-cache shape jq expects, a trigger tick,
the inhibitor, and the re-fire path. With --window it also opens the real
fullscreen Tk window and screenshots it from the host -- confirmed showing
"WAKE UP!", round 1/4 and a live flash challenge.
Not covered, and said plainly in the doc rather than implied away: audio (the
guest has no pactl/paplay), the phone, GPU and real outputs, and anything
bootloader-related (guest is BIOS, this machine is UEFI).
**The VM earned its place immediately by finding two live bugs that the host
could not reach and the unit suite passed straight through.**
1. The inhibitor claimed a block it did not have. Popen succeeding only means
the binary launched; logind can still refuse -- the guest's polkit denies a
non-interactive session outright ("Failed to inhibit: Access denied", exit
1) while the alarm cheerfully logged the power key as blocked.
take_power_key_inhibitor() now waits INHIBIT_CONFIRM_SECONDS and confirms
the child survived, returning None and logging the refusal otherwise.
2. An unsigned session record was written and then rejected on read. With no
HMAC key on the machine (/etc/gatelock absent, as in the guest),
verify_entry_hmac returns False for a *missing* key exactly as it does for
a forgery -- so every session record was unreadable and item 2's power-off
defence silently did not exist. An entry with no "hmac" field is now
honoured with a loud warning; a present-but-wrong signature is still
rejected, since that one really is tampering.
Two harness traps worth recording, both hit while building it:
- `vm run` reports the *VM's* verdict, not the guest command's exit code, so a
guest script exiting 1 still gives rc=0. A gate built on that fails OPEN.
guest_cases.sh prints ALL-CASES-PASSED only when every case passed and the
harness greps for it; verified by breaking a case on purpose and watching
the gate go red.
- `vm share`/`vm repo` key off the path basename, so REPO_DIR must be absolute
and symlink-resolved. A relative "scripts/vmbox/../.." makes rsync write
into /home and fail with permissions errors that look like a guest fault.
Guest checks live in real .py files beside the shell, not heredocs, so they
get linted like everything else -- ruff's autofix gutted an early heredoc
version (stripped the assignments AND the print) exactly as TODO-hardening
warned, and only running it showed the file had stopped producing output.
Also drops three stale fan references the guest install run surfaced:
install.sh still announced "Fans will ramp to 100%" and README.md still
described the fan ramp, three commits after item 14 deleted the feature.
Splits carried: test_inhibit.py -> + test_inhibit_cleanup.py,
test_alarm_session.py -> + test_alarm_session_refire.py.
455 tests pass, 100% branch coverage. Harness: 8/8 cases, exit 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
200 lines
6.4 KiB
Bash
Executable File
200 lines
6.4 KiB
Bash
Executable File
#!/bin/bash
|
|
|
|
# ============================================================================
|
|
# wake_alarm's default gate for shell and system scripts.
|
|
#
|
|
# Provisions a disposable Arch guest, installs this repo into it exactly the
|
|
# way production does, and runs the cases that cannot safely run on the host:
|
|
# install.sh, the systemd units, the shutdown wrapper, the power-key
|
|
# inhibitor, and the re-fire path after a cut-short alarm.
|
|
#
|
|
# What it deliberately cannot cover -- audio, the phone, real outputs, the
|
|
# bootloader -- is in DOCS-vmbox.md. Read that before trusting a pass.
|
|
#
|
|
# Usage: bash scripts/vmbox/harness.sh [--name <sandbox>] [--keep] [--window]
|
|
# ============================================================================
|
|
|
|
set -euo pipefail
|
|
|
|
VM_NAME="wake-alarm-harness"
|
|
KEEP=0
|
|
RUN_WINDOW=0
|
|
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
|
|
readonly SCRIPT_DIR
|
|
# Absolute and symlink-resolved: `vm share` and `vm repo` both key off the
|
|
# path's basename, so a relative "scripts/vmbox/../.." resolves to the wrong
|
|
# directory in the guest and rsync writes into /home instead of the repo.
|
|
REPO_DIR="$(readlink -f "${SCRIPT_DIR}/../..")"
|
|
readonly REPO_DIR
|
|
SCRIPT_NAME="$(basename "$0")"
|
|
readonly SCRIPT_NAME
|
|
|
|
# Packages the guest image lacks that this package genuinely needs. tk is not
|
|
# optional: without it `import tkinter` fails with a missing libtk8.6.so and
|
|
# the alarm cannot open its window. Measured on the stock guest, not guessed.
|
|
readonly GUEST_PACKAGES="tk"
|
|
|
|
# Printed by guest_cases.sh only when every case passed. See run_cases().
|
|
readonly GUEST_OK_SENTINEL="ALL-CASES-PASSED"
|
|
|
|
usage() {
|
|
cat <<USAGE
|
|
Usage: $SCRIPT_NAME [OPTIONS]
|
|
|
|
Run wake_alarm's system-level cases in a disposable Arch VM.
|
|
|
|
Options:
|
|
--name <sandbox> Sandbox name (default: $VM_NAME)
|
|
--keep Leave the sandbox running afterwards, for poking at
|
|
--window Also open the real alarm window and screenshot it
|
|
-h, --help Show this help
|
|
|
|
Exit codes:
|
|
0 every case passed
|
|
1 a case failed
|
|
2 the harness could not run (vmbox missing, provisioning failed)
|
|
USAGE
|
|
exit 0
|
|
}
|
|
|
|
die() {
|
|
echo "Error: $*" >&2
|
|
exit 2
|
|
}
|
|
|
|
step() {
|
|
echo "==> $*"
|
|
}
|
|
|
|
validate_requirements() {
|
|
command -v vm >/dev/null 2>&1 ||
|
|
die "vmbox is not installed. Run: bash ~/utils/vmbox/install.sh"
|
|
[[ -f "$REPO_DIR/install.sh" ]] ||
|
|
die "cannot find install.sh; run this from the wake-alarm repo"
|
|
}
|
|
|
|
# Bring the sandbox up from scratch. `vm new` on an existing name is an error,
|
|
# so an existing sandbox is reset instead -- that is the sub-second path and
|
|
# it guarantees a pristine starting point either way.
|
|
provision_sandbox() {
|
|
if vm list 2>/dev/null | awk '{print $1}' | grep -qx "$VM_NAME"; then
|
|
step "Resetting existing sandbox '$VM_NAME'"
|
|
vm reset "$VM_NAME" >/dev/null
|
|
else
|
|
step "Creating sandbox '$VM_NAME'"
|
|
vm new "$VM_NAME" >/dev/null
|
|
fi
|
|
|
|
# `vm repo` copies out of the shared mount, so the share must exist first.
|
|
# A bind mount, never a symlink: 9p does not follow one.
|
|
step "Sharing the repo with the guest"
|
|
vm share "$REPO_DIR" >/dev/null
|
|
|
|
step "Installing guest packages ($GUEST_PACKAGES)"
|
|
vm run "$VM_NAME" \
|
|
"sudo pacman -S --noconfirm --needed $GUEST_PACKAGES" >/dev/null ||
|
|
die "could not install guest packages"
|
|
|
|
# --worktree, not a clone: the point of the gate is to test the edits in
|
|
# front of you, including the ones not committed yet.
|
|
step "Copying the working tree into the guest"
|
|
vm repo "$VM_NAME" "$REPO_DIR" --worktree >/dev/null ||
|
|
die "could not copy the repo into the guest"
|
|
}
|
|
|
|
# The real production install path, not a hand-rolled approximation: this is
|
|
# the same install.sh a machine runs, so a break in it fails the gate.
|
|
run_install() {
|
|
step "Running install.sh in the guest"
|
|
if ! vm run "$VM_NAME" 'cd ~/wake-alarm && bash install.sh' >/tmp/wa-install.log 2>&1; then
|
|
tail -20 /tmp/wa-install.log >&2
|
|
die "install.sh failed in the guest (full log: /tmp/wa-install.log)"
|
|
fi
|
|
}
|
|
|
|
# `vm run` reports the *VM's* verdict (clean/dirty/hung), not the command's
|
|
# exit status -- a guest script exiting 1 still gives rc=0 on the host. That
|
|
# would make this gate fail *open*, which is worse than having no gate, so the
|
|
# verdict is carried in the output as an explicit sentinel and matched here.
|
|
run_cases() {
|
|
step "Running the guest cases"
|
|
local output
|
|
output="$(vm run "$VM_NAME" \
|
|
'bash ~/wake-alarm/scripts/vmbox/guest_cases.sh all' 2>&1)"
|
|
echo "$output"
|
|
grep -q "$GUEST_OK_SENTINEL" <<<"$output"
|
|
}
|
|
|
|
# The alarm's window needs a real X server, so the guest is the only place it
|
|
# can be seen at all without hijacking the host's screen. The screenshot is
|
|
# the artefact: it outlives the guest and is what a human actually checks.
|
|
run_window_case() {
|
|
step "Opening the alarm window in the guest"
|
|
local output
|
|
output="$(vm run "$VM_NAME" \
|
|
'bash ~/wake-alarm/scripts/vmbox/guest_cases.sh window' 2>&1)"
|
|
echo "$output"
|
|
grep -q "$GUEST_OK_SENTINEL" <<<"$output" || return 1
|
|
local shot="/tmp/wake-alarm-window.png"
|
|
vm screenshot "$VM_NAME" "$shot"
|
|
echo "Alarm window screenshot: $shot"
|
|
}
|
|
|
|
cleanup() {
|
|
if [[ $KEEP -eq 1 ]]; then
|
|
echo "Sandbox '$VM_NAME' left running (--keep). Poke at it with: vm ssh $VM_NAME"
|
|
return
|
|
fi
|
|
vm reset "$VM_NAME" >/dev/null 2>&1 || true
|
|
}
|
|
|
|
main() {
|
|
validate_requirements
|
|
trap cleanup EXIT
|
|
|
|
provision_sandbox
|
|
run_install
|
|
|
|
local status=0
|
|
run_cases || status=1
|
|
if [[ $RUN_WINDOW -eq 1 ]]; then
|
|
run_window_case || status=1
|
|
fi
|
|
|
|
echo "============================================================"
|
|
if [[ $status -eq 0 ]]; then
|
|
echo "vmbox harness: PASSED"
|
|
echo "NOT verified on the host — see DOCS-vmbox.md for what this misses."
|
|
else
|
|
echo "vmbox harness: FAILED"
|
|
fi
|
|
echo "============================================================"
|
|
return $status
|
|
}
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case $1 in
|
|
--name)
|
|
VM_NAME="${2:?--name needs a sandbox name}"
|
|
shift 2
|
|
;;
|
|
--keep)
|
|
KEEP=1
|
|
shift
|
|
;;
|
|
--window)
|
|
RUN_WINDOW=1
|
|
shift
|
|
;;
|
|
-h|--help)
|
|
usage
|
|
;;
|
|
*)
|
|
echo "Unknown option: $1" >&2
|
|
exit 2
|
|
;;
|
|
esac
|
|
done
|
|
|
|
main "$@"
|