#!/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}" # cache=none keeps the host page cache out of the guest's disk writes, so # install times measure the installer rather than the host (CI sets it). DISK_CACHE="${OMARCHY_INTEGRATION_DISK_CACHE:-}" 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${DISK_CACHE:+,cache=$DISK_CACHE} \ -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")" } # The deadline is wall-clock: under QEMU user networking every attempt can # spend its whole ConnectTimeout waiting for a banner, so counting only the # sleeps let a "300 s" wait run for ten minutes or more, silently. wait_for_ssh() { local timeout="$1" failure_name="${2:-failure-ssh-timeout}" local started=$SECONDS next_note=30 progress_name while ! ssh_guest true 2>/dev/null; do if ! vm_running; then echo "VM exited while waiting for SSH" >&2 return 1 fi if ((SECONDS - started >= timeout)); then capture_console "$failure_name" echo "Timed out after ${timeout}s waiting for SSH" >&2 return 1 fi if ((SECONDS - started >= next_note)); then printf -v progress_name 'waiting-ssh-%04ds' "$((SECONDS - started))" capture_console "$progress_name" echo " ... waiting for SSH ($((SECONDS - started))s)" ((next_note += 30)) fi sleep 2 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 edge/dev/local ISO installs the -dev packages, as builder/build-iso.sh # picks them; a stable ISO the plain ones. detect_packages() { RUNTIME_PACKAGE=omarchy-dev SETTINGS_PACKAGE=omarchy-settings-dev if [[ $(basename "$ISO") != *edge* && $(basename "$ISO") != *dev* && $(basename "$ISO") != *local* && $(basename "$ISO") != *pr* ]]; then RUNTIME_PACKAGE=omarchy SETTINGS_PACKAGE=omarchy-settings fi } # The firmware names what it starts on the serial port: the ISO's medium while # the installer runs, the installed disk's Limine once the install is over. installed_system_started() { grep -aq 'starting Boot.*"Limine"' "$1" 2>/dev/null } 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 first_boot_at="" 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 # A line every 30 s so a slow runner never looks hung; a screenshot of # the installer's dashboard every 2 min, which is enough to see progress. if ((waited % 120 == 0)); then printf -v progress_name 'success-install-progress-%04ds' "$waited" capture_console "$progress_name" fi # Once the installed system has started, the install is over and what is # being waited for is the first boot: say so, or a hung first boot reads # as a slow install. if [[ -z $first_boot_at ]] && installed_system_started "$RUN_DIR/install-serial.log"; then first_boot_at=$waited log "Install finished after about ${waited}s. Waiting for the installed system's first boot." fi if ((waited % 30 == 0)); then if [[ -n $first_boot_at ]]; then echo " ... first boot ($((waited - first_boot_at))s, install took about ${first_boot_at}s)" else echo " ... installing (${waited}s)" fi fi sleep 10 ((waited += 10)) done # Keep the installer's own clock, its log and the first boot's numbers next # to the base image while the system is reachable, so CI can report them # without attaching the disk. ssh_sudo "cat /var/log/omarchy-install-timing.json" >"$BASE_DIR/omarchy-install-timing.json" 2>/dev/null || true ssh_sudo "cat /var/log/omarchy-install.log" >"$BASE_DIR/omarchy-install.log" 2>/dev/null || true # SSH can answer before startup finishes, and systemd-analyze refuses until # it has; wait (bounded) so the first-boot numbers are final. ssh_guest "timeout 120 systemctl is-system-running --wait >/dev/null; systemd-analyze" \ >"$BASE_DIR/first-boot-systemd-analyze.txt" 2>/dev/null || true log "Installed system is up. Saving base image." stop_vm mv "$BASE_DISK.building" "$BASE_DISK" }