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
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
|
||||
Reference in new issue
Block a user