Add an integration test suite that boots ISO scenarios in QEMU (#109)
* Move the unit tests under test/unit test/ is growing an integration suite; the fast, VM-free tests get their own directory so the two kinds cannot be confused. test/all keeps its role as the aggregate runner for everything that needs no built ISO. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add an integration suite that boots ISO scenarios in QEMU test/integration installs an ISO once — unattended, from a generated cidata drive — and saves a reusable base image; each scenario in test/integration.d/ then boots a throwaway overlay with its own copy of the firmware vars. Shared machinery (VM lifecycle, QMP screendump + OCR console driving, virtual keystrokes, guest SSH, the cidata build) lives in base-test.sh, so a new scenario is one file. The first scenario proves omarchy-system-factory-reset hands a machine on without destroying a shared ESP: a Windows entry and a foreign Linux with its own machine-id, boot directory, and UKIs must survive both the staged reset and the first-boot provisioning that follows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Let omarchy-iso-make keep the host package cache Purging /var/cache/pacman/pkg needs interactive sudo, which blocks unattended builds. --keep-pkg-cache skips the purge so test rigs and agents can build an ISO without a prompt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
10bd6632d3
commit
0c1333837f
14 files changed
+993
-13
No files matched your search
@@ -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/<iso>-integration/runs/<timestamp>-<scenario>/`, 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.
|
||||
|
||||
+11
-2
@@ -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 <omarchy-checkout> <pkgs-checkout>]"
|
||||
echo "Usage: $0 [--no-cache] [--keep-pkg-cache] [--no-boot-offer] [--debug] [--edge] [--dev|--rc] [--local-source <omarchy-checkout> <pkgs-checkout>]"
|
||||
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"
|
||||
|
||||
@@ -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"
|
||||
Executable
+95
@@ -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/<scenario>-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
|
||||
@@ -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" <<EOF
|
||||
mkdir -p ~/.ssh && chmod 700 ~/.ssh
|
||||
echo "$(cat "$SSH_KEY.pub")" >>~/.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" <<EOF
|
||||
{
|
||||
"root_enc_password": $(jq -Rn --arg v "$hash" '$v'),
|
||||
"users": [
|
||||
{
|
||||
"enc_password": $(jq -Rn --arg v "$hash" '$v'),
|
||||
"groups": [],
|
||||
"sudo": true,
|
||||
"username": "$GUEST_USER"
|
||||
}
|
||||
]
|
||||
}
|
||||
EOF
|
||||
|
||||
cat >"$dir/user_configuration.json" <<EOF
|
||||
{
|
||||
"app_config": null,
|
||||
"archinstall-language": "English",
|
||||
"auth_config": {},
|
||||
"audio_config": { "audio": "pipewire" },
|
||||
"bootloader_config": { "bootloader": "Limine", "uki": false, "removable": false },
|
||||
"custom_commands": [],
|
||||
"omarchy_install": {
|
||||
"mode": "full_disk",
|
||||
"defer_provisioning": false,
|
||||
"target_mount": "/mnt",
|
||||
"boot": {
|
||||
"esp_mount": "/boot",
|
||||
"esp_path": "/EFI/limine",
|
||||
"efi_binary": "limine_x64.efi",
|
||||
"enable_fallback": true
|
||||
},
|
||||
"storage": { "kernel": "linux" }
|
||||
},
|
||||
"disk_config": {
|
||||
"config_type": "default_layout",
|
||||
"device_modifications": [
|
||||
{
|
||||
"device": "/dev/vda",
|
||||
"partitions": [
|
||||
{
|
||||
"btrfs": [],
|
||||
"dev_path": null,
|
||||
"flags": [ "boot", "esp" ],
|
||||
"fs_type": "fat32",
|
||||
"mount_options": [],
|
||||
"mountpoint": "/boot",
|
||||
"obj_id": "ea21d3f2-82bb-49cc-ab5d-6f81ae94e18d",
|
||||
"size": { "sector_size": { "unit": "B", "value": 512 }, "unit": "B", "value": $boot_size },
|
||||
"start": { "sector_size": { "unit": "B", "value": 512 }, "unit": "B", "value": $boot_start },
|
||||
"status": "create",
|
||||
"type": "primary"
|
||||
},
|
||||
{
|
||||
"btrfs": [
|
||||
{ "mountpoint": "/", "name": "@" },
|
||||
{ "mountpoint": "/home", "name": "@home" },
|
||||
{ "mountpoint": "/var/log", "name": "@log" },
|
||||
{ "mountpoint": "/var/cache/pacman/pkg", "name": "@pkg" }
|
||||
],
|
||||
"dev_path": null,
|
||||
"flags": [],
|
||||
"fs_type": "btrfs",
|
||||
"mount_options": [ "compress=zstd" ],
|
||||
"mountpoint": null,
|
||||
"obj_id": "8c2c2b92-1070-455d-b76a-56263bab24aa",
|
||||
"size": { "sector_size": { "unit": "B", "value": 512 }, "unit": "B", "value": $main_size },
|
||||
"start": { "sector_size": { "unit": "B", "value": 512 }, "unit": "B", "value": $main_start },
|
||||
"status": "create",
|
||||
"type": "primary"
|
||||
}
|
||||
],
|
||||
"wipe": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"hostname": "$GUEST_HOSTNAME",
|
||||
"kernels": [ "linux" ],
|
||||
"network_config": { "type": "iso" },
|
||||
"ntp": true,
|
||||
"parallel_downloads": 8,
|
||||
"script": null,
|
||||
"services": [],
|
||||
"swap": true,
|
||||
"timezone": "UTC",
|
||||
"locale_config": { "kb_layout": "us", "sys_enc": "UTF-8", "sys_lang": "en_US.UTF-8" },
|
||||
"mirror_config": {
|
||||
"custom_repositories": [],
|
||||
"custom_servers": [
|
||||
{"url": "https://mirror.omarchy.org/\$repo/os/\$arch"},
|
||||
{"url": "https://mirror.rackspace.com/archlinux/\$repo/os/\$arch"},
|
||||
{"url": "https://geo.mirror.pkgbuild.com/\$repo/os/\$arch"}
|
||||
],
|
||||
"mirror_regions": {},
|
||||
"optional_repositories": []
|
||||
},
|
||||
"packages": [
|
||||
"base-devel",
|
||||
"git",
|
||||
"omarchy-keyring",
|
||||
"$SETTINGS_PACKAGE",
|
||||
"$RUNTIME_PACKAGE"
|
||||
],
|
||||
"profile_config": { "gfx_driver": null, "greeter": null, "profile": {} },
|
||||
"version": "3.0.9"
|
||||
}
|
||||
EOF
|
||||
|
||||
echo "Omarchy Test" >"$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"
|
||||
}
|
||||
Executable
+287
@@ -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 \
|
||||
</tmp/reset.in >/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
|
||||
File renamed without changes.
@@ -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() {
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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")
|
||||
Reference in new issue
Block a user