diff --git a/README.md b/README.md index ebd4ae8..70cc2c6 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ Encrypted autoinstalls are not fully unattended — the LUKS passphrase prompt s Run `./bin/omarchy-iso-boot [release/omarchy.iso]`. -Run `./test/all` for the unit tests, which cover cidata autoinstall loading and the orchestrator's SSH access phase without needing a built ISO. +Run `./test/all` for the fast, VM-free tests under `test/unit/`, which cover cidata autoinstall loading and the orchestrator's phases without needing a built ISO. To exercise installation alongside existing Windows-style partitions, run `./bin/omarchy-iso-test-windows-disk [release/omarchy.iso]`. It creates a @@ -110,6 +110,20 @@ The harness syncs the acceptance suite from `$OMARCHY_PATH` when it is available Pass `--encrypt` to drive the encrypted install flow (including typing the LUKS passphrase at boot) instead of the unencrypted one. Pass `--no-preview` to collect the same visual artifacts without opening them in `imv` when the run finishes. +## Integration testing the ISO + +Scenarios under `test/integration.d/` boot a real ISO install in QEMU and assert on what the running system actually does. The runner installs the ISO once — unattended, from a generated cidata drive — and saves the result as a reusable base image; every scenario then boots a throwaway overlay of that base with its own copy of the firmware vars, so neither disk nor NVRAM state leaks between runs. Shared machinery (VM lifecycle, QMP screendump + OCR console driving, virtual keystrokes, guest SSH, the cidata build) lives in `test/integration.d/base-test.sh`, so a new scenario is one file. + +```bash +./test/integration release/omarchy.iso # install once, run all scenarios +./test/integration release/omarchy.iso --reuse-base # fast loop against the saved base +./test/integration release/omarchy.iso factory-reset # a single named scenario +``` + +The first scenario is `factory-reset`: it proves `omarchy-system-factory-reset` hands a machine on without destroying a shared ESP. The installed ESP gets a Windows entry with payload plus a second Linux cloned under a foreign machine-id with its own boot directory and UKIs; a real factory reset is then driven through a guest pty, and the harness asserts the foreign entries survive both the staged reset and first-boot provisioning, that the old Omarchy identity is fully retired, and that the machine reaches first-boot setup unattended. + +Artifacts — screenshots, the fixtured/staged/final `limine.conf`, the reset typescript, and the factory-reset log — land under `test-runs/-integration/runs/-/`, and `--no-preview` skips the `imv` review just like the acceptance harness. + ## Signing the ISO Run `./bin/omarchy-iso-sign [release/omarchy.iso]`. The signing key is retrieved from the shared Omarchy vault with the 1Password CLI. diff --git a/bin/omarchy-iso-make b/bin/omarchy-iso-make index 279db74..e8195b4 100755 --- a/bin/omarchy-iso-make +++ b/bin/omarchy-iso-make @@ -3,6 +3,7 @@ set -e NO_CACHE="" +KEEP_PKG_CACHE="" LOCAL_OMARCHY_PATH="" LOCAL_PKGS_PATH="" while [[ $# -gt 0 ]]; do @@ -11,6 +12,10 @@ while [[ $# -gt 0 ]]; do NO_CACHE=1 shift ;; + --keep-pkg-cache) + KEEP_PKG_CACHE=1 + shift + ;; --no-boot-offer) NO_BOOT_OFFER=1 shift @@ -52,7 +57,7 @@ while [[ $# -gt 0 ]]; do ;; *) echo "Unknown option: $1" - echo "Usage: $0 [--no-cache] [--no-boot-offer] [--debug] [--edge] [--dev|--rc] [--local-source ]" + echo "Usage: $0 [--no-cache] [--keep-pkg-cache] [--no-boot-offer] [--debug] [--edge] [--dev|--rc] [--local-source ]" exit 1 ;; esac @@ -100,7 +105,11 @@ lint_file_permissions() { lint_file_permissions -sudo rm -rf /var/cache/pacman/pkg/* +# Purging the host package cache needs interactive sudo; --keep-pkg-cache +# skips it so unattended builds (test rigs, agents) can run without a prompt. +if [[ -z $KEEP_PKG_CACHE ]]; then + sudo rm -rf /var/cache/pacman/pkg/* +fi git submodule update --init --recursive --jobs=8 BUILD_SCRIPT="builder/build-iso.sh" diff --git a/test/all b/test/all index fd91768..bb35d57 100755 --- a/test/all +++ b/test/all @@ -1,13 +1,16 @@ #!/bin/bash +# +# Fast, VM-free tests only. The integration scenarios that boot the ISO in +# QEMU live in test/integration.d/ and run via ./test/integration. set -euo pipefail ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) -for test in "$ROOT"/test/*-test.sh; do +for test in "$ROOT"/test/unit/*-test.sh; do printf '==> %s\n' "${test#$ROOT/}" bash "$test" done -printf '==> test/test_*.py\n' -python3 -m unittest discover -t "$ROOT" -s "$ROOT/test" +printf '==> test/unit/test_*.py\n' +python3 -m unittest discover -t "$ROOT" -s "$ROOT/test/unit" diff --git a/test/integration b/test/integration new file mode 100755 index 0000000..3954354 --- /dev/null +++ b/test/integration @@ -0,0 +1,95 @@ +#!/bin/bash +# +# Integration runner: boots an ISO's installed system in QEMU and runs the +# scenarios in test/integration.d/ against it. The unattended cidata install +# happens once per ISO and is saved as a reusable base image; every scenario +# then boots a throwaway overlay of that base. +# +# Usage: test/integration [release/omarchy.iso] [scenario ...] [options] +# +# scenario Basename of a test/integration.d/-test.sh file; +# all scenarios run when none are named +# --reuse-base Skip the install if a saved base image exists +# --port PORT Host port forwarded to guest SSH (default 2322) +# --memory MB VM memory (default 8192) +# --timeout SECS Install timeout (default 2400) +# --no-preview Do not open the collected screenshots in imv + +set -euo pipefail + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) + +ISO="" +REUSE_BASE=false +SCENARIOS=() + +export OMARCHY_INTEGRATION_SSH_PORT=2322 +export OMARCHY_INTEGRATION_MEMORY=8192 +export OMARCHY_INTEGRATION_INSTALL_TIMEOUT=2400 +export OMARCHY_INTEGRATION_NO_PREVIEW=false + +while (($#)); do + case "$1" in + --reuse-base) REUSE_BASE=true ;; + --no-preview) OMARCHY_INTEGRATION_NO_PREVIEW=true ;; + --port) OMARCHY_INTEGRATION_SSH_PORT="$2"; shift ;; + --memory) OMARCHY_INTEGRATION_MEMORY="$2"; shift ;; + --timeout) OMARCHY_INTEGRATION_INSTALL_TIMEOUT="$2"; shift ;; + -*) echo "Unknown option: $1" >&2; exit 1 ;; + *) + if [[ $1 == *.iso ]]; then + ISO="$1" + else + SCENARIOS+=("$ROOT/test/integration.d/$1-test.sh") + fi + ;; + esac + shift +done + +if [[ -z $ISO && -t 0 ]]; then + ISO=$(ls -t "$ROOT"/release/*.iso 2>/dev/null | gum choose --header "Select ISO to test") +fi + +if [[ ! -f ${ISO:-} ]]; then + echo "No ISO to test. Pass one explicitly or put it in release/." >&2 + exit 1 +fi + +if (( ${#SCENARIOS[@]} == 0 )); then + SCENARIOS=("$ROOT"/test/integration.d/*-test.sh) +else + for scenario in "${SCENARIOS[@]}"; do + if [[ $(basename "$scenario") == "base-test.sh" || ! -f $scenario ]]; then + echo "No such scenario: $scenario" >&2 + exit 1 + fi + done +fi + +omarchy-pkg-add qemu-full edk2-ovmf socat imagemagick tesseract tesseract-data-eng mtools + +export OMARCHY_INTEGRATION_ISO +OMARCHY_INTEGRATION_ISO=$(realpath "$ISO") + +# The install phase runs in this process; scenarios run in their own so each +# gets its own VM lifecycle, cleanup trap, and run directory. +SCENARIO=install +source "$ROOT/test/integration.d/base-test.sh" + +if $REUSE_BASE && base_image_ready; then + log "Reusing existing base image: $BASE_DISK" +else + install_phase +fi + +status=0 +for scenario in "${SCENARIOS[@]}"; do + [[ $(basename "$scenario") == "base-test.sh" ]] && continue + printf '\033[1;35m==> %s\033[0m\n' "${scenario#$ROOT/}" + # env -u: a SCENARIO exported by the caller (or leaked from this process) + # must not override the name each scenario derives from its own $0. + env -u SCENARIO bash "$scenario" || status=1 +done + +exit $status diff --git a/test/integration.d/base-test.sh b/test/integration.d/base-test.sh new file mode 100644 index 0000000..10d4d2e --- /dev/null +++ b/test/integration.d/base-test.sh @@ -0,0 +1,572 @@ +#!/bin/bash +# +# Shared harness for the integration scenarios in test/integration.d/: QEMU +# lifecycle, QMP screendump + OCR console driving, virtual keystrokes, guest +# SSH, cidata autoinstall, and the reusable base-image contract. Source this +# from a scenario; ./test/integration exports the configuration and ensures +# the base image exists before any scenario runs. + +set -euo pipefail + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd) + +ISO="$OMARCHY_INTEGRATION_ISO" +SSH_PORT="${OMARCHY_INTEGRATION_SSH_PORT:-2322}" +MEMORY="${OMARCHY_INTEGRATION_MEMORY:-8192}" +INSTALL_TIMEOUT="${OMARCHY_INTEGRATION_INSTALL_TIMEOUT:-2400}" +NO_PREVIEW="${OMARCHY_INTEGRATION_NO_PREVIEW:-false}" +BOOT_TIMEOUT=600 + +GUEST_USER="omarchy" +GUEST_PASSWORD="omarchy" +GUEST_HOSTNAME="omarchy-test" + +SCENARIO="${SCENARIO:-$(basename "${0%-test.sh}")}" + +OVMF_CODE="/usr/share/edk2/x64/OVMF_CODE.4m.fd" +OVMF_VARS_TEMPLATE="/usr/share/edk2/x64/OVMF_VARS.4m.fd" + +BASE_DIR="$ROOT/test-runs/$(basename "$ISO" .iso)-integration" +RUN_DIR="$BASE_DIR/runs/$(date +%Y%m%d-%H%M%S)-$SCENARIO" +BASE_DISK="$BASE_DIR/base.qcow2" +BASE_OVMF="$BASE_DIR/OVMF_VARS.4m.fd" +ACTIVE_OVMF="$BASE_OVMF" +SSH_KEY="$BASE_DIR/id_ed25519" +CIDATA_IMG="$BASE_DIR/cidata.img" +HTTP_PORT=$((SSH_PORT + 1)) +HTTP_PID="" + +mkdir -p "$BASE_DIR" "$RUN_DIR" + +QMP_SOCK=$(mktemp -u "${TMPDIR:-/tmp}/omarchy-integration-qmp.XXXXXX.sock") +PIDFILE="$RUN_DIR/qemu.pid" + +FAILURES=0 + +log() { + printf '\033[1;35m==> %s\033[0m\n' "$1" +} + +check() { + local description="$1" + shift + + if "$@" >/dev/null 2>&1; then + printf 'ok - %s\n' "$description" + else + printf 'not ok - %s\n' "$description" + ((FAILURES += 1)) + fi +} + +finish() { + if ((FAILURES == 0)); then + log "$SCENARIO passed. Artifacts: $RUN_DIR" + else + log "$SCENARIO FAILED: $FAILURES assertion(s). Artifacts: $RUN_DIR" + exit 1 + fi +} + +base_image_ready() { + [[ -f $BASE_DISK && -f $BASE_OVMF && -f $SSH_KEY ]] +} + +# ---------------------------------------------------------------- vm control + +vm_running() { + [[ -f $PIDFILE ]] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null +} + +qmp() { + printf '{"execute":"qmp_capabilities"}\n{"execute":%s}\n' "$1" | + timeout 5 socat -t 2 - "UNIX-CONNECT:$QMP_SOCK" 2>/dev/null || true +} + +screendump() { + qmp "\"screendump\", \"arguments\": {\"filename\": \"$1\"}" >/dev/null +} + +capture_console() { + local name="$1" shot="$RUN_DIR/.capture.ppm" + + sleep 1 + screendump "$shot" + [[ -s $shot ]] || return 0 + magick "$shot" "$RUN_DIR/$name.png" 2>/dev/null || true + rm -f "$shot" +} + +stop_vm() { + vm_running || return 0 + + if ! ssh_guest "echo $GUEST_PASSWORD | sudo -S systemctl poweroff" >/dev/null 2>&1; then + qmp '"system_powerdown"' >/dev/null + fi + + local waited=0 + while vm_running && ((waited < 15)); do + sleep 1 + ((waited += 1)) + done + + if vm_running; then + qmp '"quit"' >/dev/null + sleep 1 + fi + + if vm_running; then + kill "$(cat "$PIDFILE")" 2>/dev/null || true + sleep 1 + fi +} + +open_screenshots() { + local entry + local -a screenshots=() + + while IFS= read -r -d '' entry; do + screenshots+=("${entry#* }") + done < <( + find "$RUN_DIR" -type f \( -name "success-*.png" -o -name "failure-*.png" \) -printf '%T@ %p\0' | + sort -zn + ) + + (( ${#screenshots[@]} > 0 )) || return 0 + + log "Visual review: ${#screenshots[@]} screenshots in $RUN_DIR" + [[ $NO_PREVIEW == "true" ]] && return 0 + + if command -v imv >/dev/null && [[ -n ${WAYLAND_DISPLAY:-}${DISPLAY:-} ]]; then + setsid -f imv "${screenshots[@]}" >/dev/null 2>&1 + fi +} + +cleanup() { + local status=$? + + [[ -n $HTTP_PID ]] && kill "$HTTP_PID" 2>/dev/null || true + vm_running && log "Stopping test VM" + stop_vm + rm -f "$RUN_DIR/.screen.ppm" "$RUN_DIR/.screen.png" + open_screenshots + rmdir "$RUN_DIR" 2>/dev/null || true + + return $status +} +trap cleanup EXIT + +start_vm() { + local disk="$1" serial="$2" + shift 2 + + qemu-system-x86_64 \ + -cpu host -enable-kvm -machine q35,accel=kvm \ + -smp "$(nproc)" \ + -m "$MEMORY" \ + -drive if=pflash,format=raw,readonly=on,file="$OVMF_CODE" \ + -drive if=pflash,format=raw,file="$ACTIVE_OVMF" \ + -drive file="$disk",format=qcow2,if=none,id=drive0 \ + -device virtio-blk-pci,drive=drive0,bootindex=1 \ + -device virtio-vga \ + -display none \ + -usb -device usb-tablet \ + -netdev user,id=net0,hostfwd=tcp:127.0.0.1:$SSH_PORT-:22 \ + -device virtio-net-pci,netdev=net0 \ + -qmp "unix:$QMP_SOCK,server,nowait" \ + -serial "file:$serial" \ + -pidfile "$PIDFILE" \ + -daemonize \ + "$@" +} + +# Boot a throwaway overlay of the installed base. The disk gets a per-run +# overlay; the firmware vars need the same isolation or NVRAM state would +# leak between runs. +start_vm_from_base() { + qemu-img create -f qcow2 -b "$BASE_DISK" -F qcow2 "$RUN_DIR/run.qcow2" >/dev/null + cp "$BASE_OVMF" "$RUN_DIR/OVMF_VARS.4m.fd" + ACTIVE_OVMF="$RUN_DIR/OVMF_VARS.4m.fd" + start_vm "$RUN_DIR/run.qcow2" "$RUN_DIR/serial.log" +} + +# ------------------------------------------------------------ console driver + +ocr_screen() { + local shot="$RUN_DIR/.screen.ppm" prepped="$RUN_DIR/.screen.png" + + screendump "$shot" + [[ -s $shot ]] || return 0 + magick "$shot" -colorspace gray -negate -resize 150% "$prepped" 2>/dev/null || return 0 + tesseract "$prepped" - --psm 6 2>/dev/null || true +} + +wait_for_screen() { + local text="$1" timeout="$2" waited=0 slug + + until ocr_screen | grep -qi "$text"; do + if ! vm_running; then + echo "VM exited while waiting for screen: $text" >&2 + return 1 + fi + + if ((waited >= timeout)); then + slug=${text,,} + slug=${slug// /-} + slug=${slug//[^a-z0-9-]/} + capture_console "failure-waiting-for-$slug" + echo "Timed out after ${timeout}s waiting for screen: $text" >&2 + return 1 + fi + + sleep 3 + ((waited += 3)) + done + + sleep 1 +} + +press() { + local part json="" + local -a parts + + IFS='-' read -ra parts <<<"$1" + for part in "${parts[@]}"; do + json+="{\"type\":\"qcode\",\"data\":\"$part\"}," + done + + qmp "\"send-key\", \"arguments\": {\"keys\": [${json%,}]}" >/dev/null +} + +type_text() { + local text="$1" ch i + + for ((i = 0; i < ${#text}; i++)); do + ch=${text:i:1} + case "$ch" in + [a-z0-9]) press "$ch" ;; + [A-Z]) press "shift-${ch,,}" ;; + " ") press spc ;; + .) press dot ;; + ,) press comma ;; + -) press minus ;; + _) press shift-minus ;; + /) press slash ;; + :) press shift-semicolon ;; + "&") press shift-7 ;; + "=") press equal ;; + "?") press shift-slash ;; + "!") press shift-1 ;; + "~") press shift-grave_accent ;; + @) press shift-2 ;; + *) echo "type_text: unsupported character: $ch" >&2; return 1 ;; + esac + sleep 0.05 + done +} + +# --------------------------------------------------------------------- guest + +ssh_guest() { + ssh -i "$SSH_KEY" -p "$SSH_PORT" \ + -o BatchMode=yes \ + -o IdentitiesOnly=yes \ + -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null \ + -o ConnectTimeout=5 \ + -o LogLevel=ERROR \ + "$GUEST_USER@127.0.0.1" "$@" +} + +ssh_sudo() { + ssh_guest "echo $GUEST_PASSWORD | sudo -S -p '' bash -c $(printf %q "$1")" +} + +wait_for_ssh() { + local timeout="$1" failure_name="${2:-failure-ssh-timeout}" waited=0 + + while ! ssh_guest true 2>/dev/null; do + if ! vm_running; then + echo "VM exited while waiting for SSH" >&2 + return 1 + fi + + if ((waited >= timeout)); then + capture_console "$failure_name" + echo "Timed out after ${timeout}s waiting for SSH" >&2 + return 1 + fi + + sleep 5 + ((waited += 5)) + done +} + +# Authorize SSH the way a person would when the guest has no key yet (or a +# reset just scrubbed it): console login on a spare TTY, then a bootstrap +# script fetched from a throwaway host HTTP server. +bootstrap_ssh() { + log "Authorizing SSH access via console login" + + mkdir -p "$BASE_DIR/www" + cat >"$BASE_DIR/www/bootstrap" <>~/.ssh/authorized_keys +chmod 600 ~/.ssh/authorized_keys +echo "$GUEST_PASSWORD" | sudo -S ufw allow from 10.0.2.2 to any port 22 proto tcp +echo "$GUEST_PASSWORD" | sudo -S systemctl enable --now sshd.service +EOF + + (cd "$BASE_DIR/www" && exec python3 -m http.server "$HTTP_PORT" --bind 127.0.0.1 >/dev/null 2>&1) & + HTTP_PID=$! + + local waited=0 + while true; do + press ctrl-alt-f3 + sleep 4 + ocr_screen | grep -qi "login:" && break + ((waited += 8)) + + if ((waited >= 300)); then + capture_console "failure-console-timeout" + echo "Timed out waiting for a console login prompt" >&2 + return 1 + fi + sleep 4 + done + + type_text "$GUEST_USER" + press ret + wait_for_screen "Password" 60 + type_text "$GUEST_PASSWORD" + press ret + sleep 3 + + type_text "curl -fsS http://10.0.2.2:$HTTP_PORT/bootstrap -o /tmp/bs && bash /tmp/bs" + press ret + + wait_for_ssh 360 "failure-bootstrap-ssh-timeout" + capture_console "success-bootstrap-ssh" + + kill "$HTTP_PID" 2>/dev/null || true + HTTP_PID="" + press ctrl-alt-f1 +} + +# ---------------------------------------------------------- cidata autoinstall + +# The configurator's own output files, synthesized for the 40G virtio disk the +# VM boots from. Sizing mirrors the configurator: 1MiB gap, 2GiB ESP, the rest +# btrfs minus the GPT backup reserve. +build_cidata() { + local dir="$BASE_DIR/cidata" hash + local disk_bytes=$((40 * 1024 * 1024 * 1024)) + local mib=$((1024 * 1024)) gib=$((1024 * 1024 * 1024)) + local boot_start=$mib boot_size=$((2 * gib)) + local main_start=$((boot_size + boot_start)) + local main_size=$((disk_bytes - main_start - mib)) + + rm -rf "$dir" + mkdir -p "$dir" + + hash=$(openssl passwd -6 "$GUEST_PASSWORD") + + cat >"$dir/user_credentials.json" <"$dir/user_configuration.json" <"$dir/user_full_name.txt" + echo "test@omarchy.org" >"$dir/user_email_address.txt" + echo "false" >"$dir/user_encrypt_installation.txt" + cp "$SSH_KEY.pub" "$dir/authorized_keys" + + rm -f "$CIDATA_IMG" + truncate -s 4M "$CIDATA_IMG" + mkfs.vfat -n CIDATA "$CIDATA_IMG" >/dev/null + mcopy -i "$CIDATA_IMG" "$dir"/* ::/ +} + +# The dev/local ISO installs the -dev packages; a stable ISO the plain ones. +detect_packages() { + RUNTIME_PACKAGE=omarchy-dev + SETTINGS_PACKAGE=omarchy-settings-dev + if [[ $(basename "$ISO") != *dev* && $(basename "$ISO") != *local* && $(basename "$ISO") != *pr* ]]; then + RUNTIME_PACKAGE=omarchy + SETTINGS_PACKAGE=omarchy-settings + fi +} + +install_phase() { + log "Installing $(basename "$ISO") unattended via cidata (headless)" + + [[ -f $SSH_KEY ]] || ssh-keygen -t ed25519 -N "" -q -C "omarchy-integration" -f "$SSH_KEY" + detect_packages + build_cidata + + # Build under a staging name: the finished base is promoted only after a + # clean shutdown, so a failed install can never pass for a reusable base. + rm -f "$BASE_DISK" "$BASE_DISK.building" + qemu-img create -f qcow2 "$BASE_DISK.building" 40G >/dev/null + cp "$OVMF_VARS_TEMPLATE" "$BASE_OVMF" + ACTIVE_OVMF="$BASE_OVMF" + + start_vm "$BASE_DISK.building" "$RUN_DIR/install-serial.log" \ + -drive "file=$ISO,media=cdrom,if=none,format=raw,id=cdrom0" \ + -device ide-cd,drive=cdrom0,bootindex=2 \ + -drive "file=$CIDATA_IMG,format=raw,if=none,id=cidata" \ + -device usb-storage,drive=cidata + + log "Waiting for the unattended install to finish (timeout ${INSTALL_TIMEOUT}s)" + local waited=0 text progress_name + while true; do + # An unattended install reboots on its own; SSH answering means the + # installed system is up (cidata's authorized_keys enables sshd). + if ssh_guest true 2>/dev/null; then + log "Install finished and rebooted into the installed system." + capture_console "success-install-first-boot" + break + fi + + text=$(ocr_screen) + + if grep -qi "Reboot Now" <<<"$text"; then + log "Install finished. Confirming the reboot prompt." + capture_console "success-install-reboot" + press ret + fi + + if grep -qi "installation stopped" <<<"$text"; then + capture_console "failure-install-stopped" + echo "Install failed — screenshot saved to $RUN_DIR" >&2 + return 1 + fi + + if ! vm_running; then + echo "VM exited during install" >&2 + return 1 + fi + + if ((waited >= INSTALL_TIMEOUT)); then + capture_console "failure-install-timeout" + echo "Timed out after ${INSTALL_TIMEOUT}s waiting for install" >&2 + return 1 + fi + + if ((waited % 120 == 0)); then + printf -v progress_name 'success-install-progress-%04ds' "$waited" + capture_console "$progress_name" + echo " ... installing (${waited}s)" + fi + + sleep 10 + ((waited += 10)) + done + + log "Installed system is up. Saving base image." + stop_vm + mv "$BASE_DISK.building" "$BASE_DISK" +} diff --git a/test/integration.d/factory-reset-test.sh b/test/integration.d/factory-reset-test.sh new file mode 100755 index 0000000..ed6cc87 --- /dev/null +++ b/test/integration.d/factory-reset-test.sh @@ -0,0 +1,287 @@ +#!/bin/bash +# +# Factory reset on a shared ESP: proves omarchy-system-factory-reset hands a +# machine on without destroying a dual-boot setup (basecamp/omarchy#6847). +# Dresses the installed ESP up as a dual-boot machine (Windows + a foreign +# Linux, exactly what the reset must not destroy), runs a real factory reset, +# and walks the first-boot setup that follows. +# +# Staging asserts the Windows and foreign Linux entries and their ESP payload +# survive, the old Omarchy identity (entry + directory) is gone, and a fresh +# Omarchy entry exists. First boot asserts provisioning completes, the machine +# identity changed, and the foreign entries are still intact afterwards. + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +FOREIGN_ID="fedcfedcfedcfedcfedcfedcfedcfedc" + +base_image_ready || { echo "No base image; run this through ./test/integration" >&2; exit 1; } + +# --------------------------------------------------------------- esp fixture + +# Dress the ESP up as a shared dual-boot ESP: a Windows entry, and a second +# Linux installation cloned from the real Omarchy entry so its shape is +# exactly what limine-entry-tool writes, under a foreign machine-id with its +# own boot directory and byte-identical UKIs (so the cloned entry hashes +# stay valid). +fixture_shared_esp() { + OLD_ID=$(ssh_guest "cat /etc/machine-id" | tr -d '\r\n') + [[ $OLD_ID =~ ^[0-9a-f]{32}$ ]] || { echo "unexpected machine-id: $OLD_ID" >&2; return 1; } + + ssh_sudo "cp /boot/limine.conf /boot/limine.conf.pretest" >/dev/null + ssh_sudo " + set -euo pipefail + awk '/^\//{block=1} block' /boot/limine.conf | + sed -e 's/$OLD_ID/$FOREIGN_ID/g' \ + -e 's|/EFI/Linux/omarchy_|/EFI/Linux/foreign_|g' \ + -e 's|^/\(+\{0,1\}\)\([^/+]\)|/\1Foreign \2|' >/tmp/foreign-entries + grep -q 'machine-id=$FOREIGN_ID' /tmp/foreign-entries + for uki in /boot/EFI/Linux/omarchy_*.efi; do + cp \"\$uki\" \"\${uki/omarchy_/foreign_}\" + done + cat >>/boot/limine.conf <<'CONF' + +/Windows + protocol: efi + path: boot():/EFI/Microsoft/Boot/bootmgfw.efi +CONF + cat /tmp/foreign-entries >>/boot/limine.conf + mkdir -p /boot/$FOREIGN_ID /boot/EFI/Microsoft/Boot + echo foreign-payload >/boot/$FOREIGN_ID/marker + echo windows-payload >/boot/EFI/Microsoft/Boot/bootmgfw.efi + " >/dev/null + + ssh_sudo "cat /boot/limine.conf" >"$RUN_DIR/limine.conf.fixtured" + log "ESP fixtured as a shared dual-boot ESP (old machine-id $OLD_ID)" +} + +# ------------------------------------------------------------- reset driver + +# Drive the interactive reset through a pty on the guest: `script` supplies +# the terminal gum needs, a fifo feeds it answers on our schedule, and the +# typescript lands in /tmp/reset.out for the polling below. +start_reset() { + # script hands the pty over at 0x0 when its own stdin is not a terminal, + # which crashes gum's text input; give it a real size first. setsid keeps + # both processes alive after this ssh session hangs up. + ssh_guest " + rm -f /tmp/reset.in /tmp/reset.out + mkfifo /tmp/reset.in + setsid bash -c 'exec 3<>/tmp/reset.in && sleep 3600' >/dev/null 2>&1 & + setsid script -qefc 'stty rows 35 cols 130; sudo -S -p \"\" omarchy-system-factory-reset' /tmp/reset.out \ + /dev/null 2>&1 & + " + sleep 2 + ssh_guest "printf '%s\n' '$GUEST_PASSWORD' >/tmp/reset.in" +} + +reset_output() { + ssh_guest "sed 's/\x1b\[[0-9;?]*[A-Za-z]//g; s/\x1b\][^\x07]*\x07//g' /tmp/reset.out 2>/dev/null" || true +} + +wait_for_reset_output() { + local text="$1" timeout="$2" waited=0 + + until reset_output | grep -qi "$text"; do + if reset_output | grep -qE "^Error:|COMMAND_EXIT_CODE"; then + reset_output >"$RUN_DIR/reset.out" + echo "The factory reset failed before reaching: $text" >&2 + reset_output | grep -E "^Error:" >&2 || true + return 1 + fi + if ((waited >= timeout)); then + reset_output >"$RUN_DIR/reset.out" + echo "Timed out after ${timeout}s waiting for reset output: $text" >&2 + return 1 + fi + sleep 3 + ((waited += 3)) + done +} + +reset_phase() { + log "Booting reset VM from base image overlay" + + start_vm_from_base + wait_for_ssh "$BOOT_TIMEOUT" + + fixture_shared_esp + + log "Running omarchy-system-factory-reset" + start_reset + wait_for_reset_output "Type 'reset' to continue" 60 + capture_console "success-reset-confirm" + ssh_guest "printf 'reset\r' >/tmp/reset.in" + + # Staging clones @factory, rebuilds the UKI in a chroot, and reworks the + # ESP; only then does the reboot prompt appear. + wait_for_reset_output "Reboot to complete the reset" 900 + reset_output >"$RUN_DIR/reset.out" + log "Reset staged. Asserting on the shared ESP before rebooting." + + check "windows entry survives staging" \ + ssh_sudo "grep -q '^/Windows' /boot/limine.conf" + check "windows boot payload survives staging" \ + ssh_sudo "grep -q windows-payload /boot/EFI/Microsoft/Boot/bootmgfw.efi" + check "foreign linux entry survives staging" \ + ssh_sudo "grep -q 'machine-id=$FOREIGN_ID' /boot/limine.conf" + check "foreign boot directory survives staging" \ + ssh_sudo "grep -q foreign-payload /boot/$FOREIGN_ID/marker" + check "foreign UKI survives staging" \ + ssh_sudo "test -f /boot/EFI/Linux/foreign_linux.efi" + check "old omarchy entry is removed by staging" \ + ssh_sudo "! grep -q 'machine-id=$OLD_ID' /boot/limine.conf" + check "old omarchy boot directory is removed by staging" \ + ssh_sudo "! test -e /boot/$OLD_ID" + check "a fresh omarchy entry exists after staging" \ + ssh_sudo "grep -o 'machine-id=[0-9a-f]\{32\}' /boot/limine.conf | grep -qv 'machine-id=$FOREIGN_ID'" + + ssh_sudo "cat /boot/limine.conf" >"$RUN_DIR/limine.conf.staged" || true + ssh_sudo "cat /var/log/omarchy-system-factory-reset.log" >"$RUN_DIR/factory-reset.log" || true + capture_console "success-reset-staged" + + if ((FAILURES > 0)); then + log "Staging assertions failed; keeping the VM off the reboot path." + return 1 + fi + + # Decline the prompt's reboot; reboot deliberately so the ssh session + # closing cannot race the answer. + ssh_guest "printf 'n' >/tmp/reset.in" + sleep 2 + log "Rebooting into the factory first boot" + ssh_sudo "systemctl reboot" || true +} + +# --------------------------------------------------------------- first boot + +# Answer whichever setup screens the factory first boot presents. The form's +# exact steps depend on what the reset deferred, so dispatch on the screen +# actually showing instead of scripting a fixed sequence. +drive_first_boot() { + log "Driving the factory first-boot setup" + + # The reset must leave a bootable default: the machine has to reach the + # first-boot greeter on its own. A numeric default_entry pointing at a + # moved entry parks Limine at the menu instead — catch that explicitly, + # then boot the Omarchy entry by hand so the rest of the flow still runs. + if wait_for_screen "Opinionated" 120 2>/dev/null; then + printf 'ok - %s\n' "the machine boots into first-boot setup unattended" + else + if ocr_screen | grep -qi "Omarchy Bootloader"; then + printf 'not ok - %s\n' "the machine boots into first-boot setup unattended (parked at the Limine menu)" + ((FAILURES += 1)) + capture_console "failure-firstboot-parked-at-menu" + log "Selecting the Omarchy entry manually to continue the run" + press down; sleep 1; press down; sleep 1; press down; sleep 1 + press ret + wait_for_screen "Opinionated" 600 + else + echo "Neither the greeter nor the Limine menu appeared" >&2 + return 1 + fi + fi + capture_console "success-firstboot-00-greeter" + press ret + + local waited=0 text step + local -A answered=() + while true; do + text=$(ocr_screen) + step="" + + if grep -qi "look right" <<<"$text"; then + capture_console "success-firstboot-03-review" + press ret + break + elif grep -qi "keyboard layout" <<<"$text"; then + step="keyboard"; [[ -v answered[$step] ]] || { capture_console "success-firstboot-01-keyboard"; press ret; } + elif grep -qi "Username" <<<"$text"; then + step="username"; [[ -v answered[$step] ]] || { type_text "$GUEST_USER"; press ret; } + elif grep -qi "Conf *irm" <<<"$text"; then + step="confirm"; [[ -v answered[$step] ]] || { type_text "$GUEST_PASSWORD"; press ret; } + elif grep -qi "Password" <<<"$text"; then + step="password"; [[ -v answered[$step] ]] || { type_text "$GUEST_PASSWORD"; press ret; } + elif grep -qi "Full name" <<<"$text"; then + step="fullname"; [[ -v answered[$step] ]] || { type_text "Omarchy Test"; press ret; } + elif grep -qi "Email address" <<<"$text"; then + step="email"; [[ -v answered[$step] ]] || { type_text "test@omarchy.org"; capture_console "success-firstboot-02-form"; press ret; } + elif grep -qi "Hostname" <<<"$text"; then + step="hostname"; [[ -v answered[$step] ]] || { type_text "$GUEST_HOSTNAME"; press ret; } + elif grep -qi "Timezone" <<<"$text"; then + step="timezone"; [[ -v answered[$step] ]] || press ret + fi + [[ -n $step ]] && answered[$step]=1 + + if ! vm_running; then + echo "VM exited during first-boot setup" >&2 + return 1 + fi + if ((waited >= 600)); then + capture_console "failure-firstboot-form-timeout" + echo "Timed out driving the first-boot form" >&2 + return 1 + fi + sleep 3 + ((waited += 3)) + done + + capture_console "success-firstboot-04-finalizing" +} + +first_boot_phase() { + drive_first_boot + + # Finalization can still be running when the console appears; the pending + # flag clearing is the definitive end of provisioning. The reset scrubbed + # users, host keys, and sshd state, so SSH has to be re-authorized first. + bootstrap_ssh + + log "Waiting for provisioning to finish" + local waited=0 rc + while true; do + rc=0 + ssh_guest "test -f /var/lib/omarchy/provisioning/pending" 2>/dev/null || rc=$? + (( rc == 0 || rc == 255 )) || break + if ((waited >= 900)); then + capture_console "failure-provisioning-timeout" + echo "Timed out waiting for first-boot provisioning to complete" >&2 + return 1 + fi + sleep 10 + ((waited += 10)) + done + + local new_id + new_id=$(ssh_guest "cat /etc/machine-id" | tr -d '\r\n') + + check "provisioning completed on first boot" \ + ssh_guest "! test -f /var/lib/omarchy/provisioning/pending" + # A well-formed identity, and not one we already know: an empty or echoed + # ID would make the boot-entry grep below match the wrong entry. + check "the reset minted a fresh machine identity" \ + bash -c "[[ '$new_id' =~ ^[0-9a-f]{32}\$ && '$new_id' != '$OLD_ID' && '$new_id' != '$FOREIGN_ID' ]]" + check "windows entry survives the first boot" \ + ssh_sudo "grep -q '^/Windows' /boot/limine.conf" + check "windows boot payload survives the first boot" \ + ssh_sudo "grep -q windows-payload /boot/EFI/Microsoft/Boot/bootmgfw.efi" + check "foreign linux entry survives the first boot" \ + ssh_sudo "grep -q 'machine-id=$FOREIGN_ID' /boot/limine.conf" + check "foreign boot directory survives the first boot" \ + ssh_sudo "grep -q foreign-payload /boot/$FOREIGN_ID/marker" + check "foreign UKI survives the first boot" \ + ssh_sudo "test -f /boot/EFI/Linux/foreign_linux.efi" + check "old omarchy identity never returns" \ + ssh_sudo "! grep -q 'machine-id=$OLD_ID' /boot/limine.conf" + check "the new identity owns a boot entry" \ + ssh_sudo "grep -q 'machine-id=$new_id' /boot/limine.conf" + + ssh_sudo "cat /boot/limine.conf" >"$RUN_DIR/limine.conf.final" || true + capture_console "success-final" +} + +# ---------------------------------------------------------------------- main + +reset_phase +first_boot_phase +finish diff --git a/test/__init__.py b/test/unit/__init__.py similarity index 100% rename from test/__init__.py rename to test/unit/__init__.py diff --git a/test/cidata-load-test.sh b/test/unit/cidata-load-test.sh similarity index 99% rename from test/cidata-load-test.sh rename to test/unit/cidata-load-test.sh index 4914dc6..fe0962a 100644 --- a/test/cidata-load-test.sh +++ b/test/unit/cidata-load-test.sh @@ -7,7 +7,7 @@ set -euo pipefail -ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd) CIDATA_LOAD="$ROOT/configs/airootfs/usr/local/bin/omarchy-cidata-load" pass() { diff --git a/test/partition-numbering-test.sh b/test/unit/partition-numbering-test.sh similarity index 98% rename from test/partition-numbering-test.sh rename to test/unit/partition-numbering-test.sh index 29ad90b..45a73e6 100644 --- a/test/partition-numbering-test.sh +++ b/test/unit/partition-numbering-test.sh @@ -13,7 +13,7 @@ set -uo pipefail -ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd) LIB="$ROOT/configs/airootfs/usr/share/omarchy-iso/disk-partitioning.sh" if ! command -v parted >/dev/null 2>&1; then diff --git a/test/test_configure_ssh_access.py b/test/unit/test_configure_ssh_access.py similarity index 98% rename from test/test_configure_ssh_access.py rename to test/unit/test_configure_ssh_access.py index 50050d2..21e1f76 100644 --- a/test/test_configure_ssh_access.py +++ b/test/unit/test_configure_ssh_access.py @@ -14,7 +14,7 @@ from pathlib import Path from subprocess import CompletedProcess from unittest import mock -sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "configs/airootfs/usr/share/omarchy-iso")) +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "configs/airootfs/usr/share/omarchy-iso")) # phases_impl imports the archinstall adapter at module scope, which pulls in # the archinstall library that only exists on the live ISO. configure_ssh_access diff --git a/test/test_configure_tailscale.py b/test/unit/test_configure_tailscale.py similarity index 98% rename from test/test_configure_tailscale.py rename to test/unit/test_configure_tailscale.py index 23b7bc6..bbb3728 100644 --- a/test/test_configure_tailscale.py +++ b/test/unit/test_configure_tailscale.py @@ -15,7 +15,7 @@ from pathlib import Path from subprocess import CompletedProcess from unittest import mock -sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "configs/airootfs/usr/share/omarchy-iso")) +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "configs/airootfs/usr/share/omarchy-iso")) # phases_impl imports the archinstall adapter at module scope, which pulls in # the archinstall library that only exists on the live ISO. configure_tailscale diff --git a/test/test_keyboard.py b/test/unit/test_keyboard.py similarity index 98% rename from test/test_keyboard.py rename to test/unit/test_keyboard.py index c8b2abf..3c36b35 100644 --- a/test/test_keyboard.py +++ b/test/unit/test_keyboard.py @@ -7,7 +7,7 @@ import unittest from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] MODULE_PATH = ROOT / "configs/airootfs/usr/share/omarchy-iso/orchestrator/keyboard.py" SPEC = importlib.util.spec_from_file_location("installer_keyboard", MODULE_PATH) KEYBOARD = importlib.util.module_from_spec(SPEC) diff --git a/test/test_offline_mirror_pruning.py b/test/unit/test_offline_mirror_pruning.py similarity index 98% rename from test/test_offline_mirror_pruning.py rename to test/unit/test_offline_mirror_pruning.py index 88ed468..fa07bf4 100644 --- a/test/test_offline_mirror_pruning.py +++ b/test/unit/test_offline_mirror_pruning.py @@ -6,7 +6,7 @@ import unittest from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] PRUNER = ROOT / "builder/prune-offline-mirror.sh" diff --git a/test/test_provisioning_state.py b/test/unit/test_provisioning_state.py similarity index 99% rename from test/test_provisioning_state.py rename to test/unit/test_provisioning_state.py index d2df6e7..7386f00 100644 --- a/test/test_provisioning_state.py +++ b/test/unit/test_provisioning_state.py @@ -16,7 +16,7 @@ from pathlib import Path from subprocess import CompletedProcess from unittest import mock -sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "configs/airootfs/usr/share/omarchy-iso")) +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "configs/airootfs/usr/share/omarchy-iso")) sys.modules.setdefault( "orchestrator.archinstall_adapter", types.ModuleType("orchestrator.archinstall_adapter")