#!/bin/bash # ============================================================================ # vm -- disposable Arch VM sandbox. # # Runs destructive system scripts (shutdown installers, chattr +i guards, # pacman hooks) against a throwaway Arch guest, and verifies the outcome from # the HOST -- a guest that powers itself off cannot report its own success. # ============================================================================ set -euo pipefail VMBOX_BIN_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd -P)" readonly VMBOX_BIN_DIR # shellcheck source=../lib/common.sh source "$VMBOX_BIN_DIR/../lib/common.sh" usage() { cat <<'USAGE' Usage: vm [args] build Build the golden base image (once, ~10 min) new [--rtc ] [--uefi] Create a sandbox (instant; thin overlay) run [--uefi|--bios] Run a command in the sandbox, report the verdict start Boot the sandbox and wait until ssh answers wake [--accel N] [--max-wait S] [--] 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 [--swap-mb N] Swap + resume= so the guest can hibernate/resume ssh [args...] Interactive shell in the sandbox scp Copy a host file into the sandbox share Expose a host dir to sandboxes (read-only bind) unshare Stop exposing it repo [--worktree] Copy a host repo in (clone=HEAD, --worktree=uncommitted) screenshot [out.png] Capture the guest screen (for locker tests) lightdm Switch the sandbox to lightdm autologin -> i3, tty1 a plain getty (the host's login topology) shim [mode] Fake a hardware tool on PATH: exit[:N] | hang | sleep:S; calls logged in /var/log/vmbox-shim/ reset Wipe back to pristine (sub-second) rm Delete the sandbox entirely list List sandboxes and their state --rtc Pin the guest clock, e.g. 2026-08-22T20:55:00. Stored in the VM's meta and re-applied on EVERY launch: the shutdown scripts are gated on a 21:00-05:00 window, so an unpinned clock makes their tests nondeterministic. --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 vm run demo 'sudo systemctl poweroff' # -> verdict: clean poweroff vm reset demo # back to pristine USAGE } # vm repo [--worktree] cmd_repo() { local name="${1:-}" src="${2:-}" mode=clone [[ -n "$name" && -n "$src" ]] || die "usage: vm repo [--worktree]" shift 2 [[ "${1:-}" == "--worktree" ]] && mode=worktree repo_sync "$name" "$src" "$mode" } # Copy a file into a sandbox. lib/ssh.sh has always been able to do this; # without a subcommand the only route was base64 through `vm run`. cmd_scp() { local name="${1:-}" src="${2:-}" dest="${3:-}" [[ -n "$name" && -n "$src" && -n "$dest" ]] || die "usage: vm scp " name="$(validate_vm_name "$name")" require_vm "$name" [[ -e "$src" ]] || die "no such file: $src" vm_is_running "$name" || die "sandbox '$name' is not running (start it with: vm run $name true)" vm_scp_to "$name" "$src" "$dest" || die "copy failed" 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 )"; return 0; } 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 '-')" 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)" } main() { local cmd="${1:-}" [[ $# -gt 0 ]] && shift || true case "$cmd" in build) require_host_deps; source "$VMBOX_LIB_DIR/base.sh"; base_build "$@" ;; new) require_host_deps; source "$VMBOX_LIB_DIR/overlay.sh"; overlay_new "$@" ;; reset) source "$VMBOX_LIB_DIR/overlay.sh"; overlay_reset "$@" ;; rm) source "$VMBOX_LIB_DIR/overlay.sh"; overlay_rm "$@" ;; run) require_host_deps; source "$VMBOX_LIB_DIR/run.sh"; run_in_vm "$@" ;; 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 "$@" ;; lightdm) require_host_deps; source "$VMBOX_LIB_DIR/desktop.sh"; cmd_lightdm "$@" ;; shim) require_host_deps; source "$VMBOX_LIB_DIR/desktop.sh"; cmd_shim "$@" ;; repo) source "$VMBOX_LIB_DIR/repo.sh"; cmd_repo "$@" ;; share) source "$VMBOX_LIB_DIR/repo.sh"; repo_share "$@" ;; unshare) source "$VMBOX_LIB_DIR/repo.sh"; repo_unshare "$@" ;; list|ls) cmd_list ;; -h|--help|help|'') usage ;; *) die "unknown command '$cmd' (try: vm --help)" ;; esac } main "$@"