aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-xscripts/test-install.sh263
-rw-r--r--tests/unit/test_test_install.bats152
2 files changed, 355 insertions, 60 deletions
diff --git a/scripts/test-install.sh b/scripts/test-install.sh
index bb556cd..cbca9b3 100755
--- a/scripts/test-install.sh
+++ b/scripts/test-install.sh
@@ -26,6 +26,17 @@ VM_DISK_SIZE="20G"
# holds 2222 (e.g. SSH_PORT=2223 scripts/test-install.sh single-disk).
export SSH_PORT="${SSH_PORT:-2222}"
export SSH_PASSWORD="archangel"
+
+# Wall-clock bound on a single remote command. ConnectTimeout only bounds the
+# connection, so a guest that accepts SSH and then blocks in the kernel hangs
+# the run: on 2026-08-03 a `zfs destroy` stuck behind an uninterruptible
+# txg_quiesce sat for 40 minutes on a healthy connection and produced no
+# results at all. Bounded, a wedged guest costs one scenario.
+#
+# The installer invocation in run_install is the sole legitimate long runner
+# and raises this to INSTALL_TIMEOUT for its own call. Exported because
+# run_install executes in a `bash -c` child that inherits only exported vars.
+export SSH_CMD_TIMEOUT="${SSH_CMD_TIMEOUT:-120}"
SERIAL_LOG="$LOG_DIR/serial.log"
# Timeouts (seconds)
@@ -33,7 +44,7 @@ BOOT_TIMEOUT=120
# INSTALL_TIMEOUT: 30 min. DKMS zfs compile + depmod on kernel 6.18+ in
# a VM can exceed 10 min under host load. 600 was tight for 6.12; 1800
# gives headroom without masking real hangs.
-INSTALL_TIMEOUT=1800
+export INSTALL_TIMEOUT=1800
SSH_TIMEOUT=30
VERIFY_TIMEOUT=60
@@ -89,6 +100,49 @@ list_configs() {
done
}
+# Drop the cached archzfs repo before the first scenario.
+#
+# archzfs re-uploads its GitHub Releases assets under the same filenames, so
+# pacoloco ends up holding package bodies that no longer match what its
+# archzfs.db advertises. Every ZFS scenario then dies at pacstrap with
+# "invalid or corrupted package" or "Maximum file size exceeded", which reads
+# exactly like an installer regression and isn't one. It went stale twice on
+# 2026-08-06, the second time *during* a run: fresh at scenario one, rotten by
+# scenario six, and the whole ZFS half was lost.
+#
+# Clear the directory, not the zfs-dkms/zfs-utils globs build.sh uses. Removing
+# the bodies while leaving a stale db just trades a checksum error for a size
+# error, which is how the second failure disguised itself as a new bug.
+#
+# Best-effort by design. The cache is pacoloco-owned so removal needs root, and
+# a run without root is still worth having — it just carries the risk this
+# exists to remove, so say so rather than failing silently.
+#
+# The path is injectable so tests can exercise this against a temp directory.
+# Without that it hardcoded a system path, and merely sourcing the file and
+# calling the function ran `sudo rm -rf` on the real cache — which is what
+# happened the first time this was written.
+clear_archzfs_cache() {
+ local dir="${ARCHZFS_CACHE_DIR:-/var/cache/pacoloco/pkgs/archzfs}"
+ [[ -d "$dir" ]] || return 0
+
+ # This is `rm -rf` under sudo on an overridable path, so validate the shape
+ # before running it. A mistyped override should cost a warning, not the
+ # machine.
+ if [[ "$(basename "$dir")" != archzfs || "$(dirname "$dir")" == / ]]; then
+ warn "Refusing to clear implausible archzfs cache path: $dir"
+ return 0
+ fi
+
+ if sudo -n rm -rf "$dir" 2>/dev/null; then
+ info "Cleared cached archzfs repo (pacoloco refetches on first use)"
+ else
+ warn "Could not clear $dir — needs root."
+ warn "A stale archzfs cache fails every ZFS scenario at pacstrap; clear it by hand if that happens."
+ fi
+ return 0
+}
+
find_iso() {
ISO_FILE=$(ls -t "$PROJECT_DIR/out/"*.iso 2>/dev/null | head -1)
if [[ -z "$ISO_FILE" ]]; then
@@ -391,11 +445,21 @@ send_luks_passphrase() {
return 0
}
-# Send ZFS passphrase via QEMU monitor sendkey
-# Two passphrase prompts occur (both on VGA framebuffer, not serial):
-# 1. ZFSBootMenu prompts to unlock the pool and show boot environments
-# 2. mkinitcpio's zfs hook prompts again when the selected kernel boots
-# We detect the UEFI firmware log to time the first, then wait for the second.
+# Send ZFS passphrase via QEMU monitor sendkey.
+#
+# One prompt, not two. ZFSBootMenu asks in order to read the kernel and
+# initramfs; the booted initramfs then loads the key from the keyfile
+# configure_zfs_keyfile bakes into the image, silently. Before that fix the
+# key did not survive kexec and the initramfs asked a second time, so this
+# used to send twice on fixed sleeps.
+#
+# Sending a second time now would type the passphrase into whatever is on
+# screen once the system is up, which is a login prompt.
+#
+# Both prompts render on the VGA framebuffer, never the serial console, so
+# there is nothing to match on: the UEFI firmware's ZFSBootMenu handoff is
+# the last thing serial shows, and the wait after it is necessarily a fixed
+# sleep rather than a poll.
send_zfs_passphrase() {
local test_name="$1"
local passphrase="$2"
@@ -415,27 +479,111 @@ send_zfs_passphrase() {
done
info "ZFSBootMenu loading detected after ${waited}s"
- # Prompt 1: ZFSBootMenu passphrase (unlocks pool to show boot menu)
+ # The only prompt: ZFSBootMenu unlocking the pool.
step "Waiting for ZFSBootMenu passphrase prompt (15s)..."
sleep 15
- step "Sending ZFS passphrase (1/2: ZFSBootMenu)..."
+ step "Sending ZFS passphrase..."
monitor_sendkeys "$monitor_sock" "$passphrase"
- info "ZFSBootMenu passphrase sent"
+ info "ZFS passphrase sent"
- # Prompt 2: mkinitcpio zfs hook passphrase (re-imports pool during kernel boot)
- step "Waiting for initramfs passphrase prompt (30s)..."
- sleep 30
- step "Sending ZFS passphrase (2/2: initramfs)..."
- monitor_sendkeys "$monitor_sock" "$passphrase"
- info "Initramfs passphrase sent"
+ return 0
+}
+
+# Which passphrase-entry path a boot from disk needs: "luks", "zfs", or empty.
+# NO_ENCRYPT wins over a passphrase being present, because the test configs set
+# both — sending a passphrase to an unencrypted boot would type it at a login
+# prompt. LUKS is checked first, preserving the precedence the inline block had.
+config_encrypt_flag() {
+ local config="$1"
+ local luks_pass zfs_pass no_encrypt
+ luks_pass=$(grep '^LUKS_PASSPHRASE=' "$config" | cut -d= -f2)
+ zfs_pass=$(grep '^ZFS_PASSPHRASE=' "$config" | cut -d= -f2)
+ no_encrypt=$(grep '^NO_ENCRYPT=' "$config" | cut -d= -f2)
+
+ [[ "$no_encrypt" == "yes" ]] && return 0
+ if [[ -n "$luks_pass" ]]; then
+ echo "luks"
+ elif [[ -n "$zfs_pass" ]]; then
+ echo "zfs"
+ fi
+ return 0
+}
+
+# Start the guest from its installed disk and get it past any passphrase
+# prompt. Returns 0 once the VM is running and unlocked, 1 otherwise. Callers
+# own their own failure bookkeeping, which is why this doesn't touch
+# TESTS_FAILED or cleanup_disks.
+boot_from_disk() {
+ local config="$1"
+ local config_name disk_count encrypt_flag vm_pid
+ config_name=$(basename "$config" .conf)
+ disk_count=$(get_disk_count "$config")
+ encrypt_flag=$(config_encrypt_flag "$config")
+
+ vm_pid=$(start_vm_from_disk "$config_name" "$disk_count" "$encrypt_flag")
+ if [[ -z "$vm_pid" ]]; then
+ error "Failed to start VM from disk"
+ return 1
+ fi
+ info "VM started from disk (PID: $vm_pid)"
+
+ case "$encrypt_flag" in
+ luks)
+ if ! send_luks_passphrase "$config_name" \
+ "$(grep '^LUKS_PASSPHRASE=' "$config" | cut -d= -f2)" "$disk_count"; then
+ error "Failed to send LUKS passphrase"
+ return 1
+ fi
+ ;;
+ zfs)
+ if ! send_zfs_passphrase "$config_name" \
+ "$(grep '^ZFS_PASSPHRASE=' "$config" | cut -d= -f2)"; then
+ error "Failed to send ZFS passphrase"
+ return 1
+ fi
+ ;;
+ esac
+ return 0
+}
+
+# Restart the guest and wait for SSH to come back.
+#
+# Needed after a live-root `zfs rollback`. Rolling back a mounted root leaves
+# the running system inconsistent with its own filesystem, and the pool in a
+# state where the next ZFS command can wedge: on 2026-08-03 the cleanup
+# `zfs destroy` blocked in cv_wait_common behind an uninterruptible
+# txg_quiesce, and the suite sat dead for 40 minutes. A reboot exports and
+# reimports the pool, which clears that, and it is also the only honest way to
+# observe rolled-back contents — the page and dentry caches otherwise keep
+# serving the pre-rollback view.
+#
+# Assumes SSH is enabled, which holds because every caller sits inside
+# run_test's SSH branch.
+reboot_guest() {
+ local config="$1"
+ local config_name installed_password
+ config_name=$(basename "$config" .conf)
+ installed_password=$(grep '^ROOT_PASSWORD=' "$config" | cut -d= -f2)
+
+ step "Rebooting guest..."
+ stop_vm "$config_name" true
+ : > "$SERIAL_LOG"
+
+ boot_from_disk "$config" || return 1
+ if ! wait_for_ssh "$BOOT_TIMEOUT" "$installed_password"; then
+ error "Guest did not return on SSH after reboot"
+ return 1
+ fi
+ info "Guest back up after reboot"
return 0
}
# Run SSH command (uses SSH_PASSWORD by default, or INSTALLED_PASSWORD if set)
ssh_cmd() {
local password="${INSTALLED_PASSWORD:-$SSH_PASSWORD}"
- sshpass -p "$password" ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
+ timeout "$SSH_CMD_TIMEOUT" \
+ sshpass -p "$password" ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-p "$SSH_PORT" root@localhost "$@" 2>/dev/null
}
@@ -519,7 +667,10 @@ run_install() {
fi
# Run the installer (NO_ENCRYPT is set in the config file, not via flag)
- ssh_cmd "archangel --config-file /root/test.conf" || return 1
+ # The one call that legitimately runs for many minutes. Raise the
+ # per-command bound to match the outer timeout wrapping run_install, so
+ # the short default can't kill a healthy install mid-pacstrap.
+ SSH_CMD_TIMEOUT="$INSTALL_TIMEOUT" ssh_cmd "archangel --config-file /root/test.conf" || return 1
return 0
}
@@ -868,14 +1019,39 @@ verify_zfssnapshot_wrapper() {
error "zfssnapshot rollback --name failed"
return 1
fi
+ # Reboot before looking, and before touching ZFS again.
+ #
+ # That rollback just reverted the mounted root underneath the running OS.
+ # Two things follow. The running system can't be trusted to report what is
+ # on disk, because the page and dentry caches keep serving the pre-rollback
+ # view — that's what made this check fail on four scenarios and pass on two
+ # identical ones. And the pool is left fragile: the next ZFS command can
+ # wedge it, which is exactly what the cleanup destroy below did on
+ # 2026-08-03, blocking in cv_wait_common behind an uninterruptible
+ # txg_quiesce until the run was killed 40 minutes later.
+ #
+ # A reboot exports and reimports the pool, which resolves both.
+ if ! reboot_guest "$config"; then
+ error "Guest did not come back after the rollback reboot"
+ return 1
+ fi
+
if ! ssh_cmd "test -f '$sentinel'"; then
error "Rollback did not restore sentinel — wrapper rollback path broken"
+ # Distinguish the two failures for whoever reads this next: a rollback
+ # that never happened leaves the snapshot and no file; one that
+ # happened leaves a consistent dataset. Post-reboot both answers are
+ # trustworthy, which they were not before.
+ error " ls: $(ssh_cmd "ls -la '$sentinel' 2>&1" | head -1)"
+ error " rollback snapshot still listed: $(ssh_cmd "zfs list -t snapshot -H -o name 2>&1 | grep -c wrapper-rollback")"
return 1
fi
info "Round-trip rollback via wrapper restored sentinel"
# Cleanup: destroy the wrapper-rollback snapshot we left behind so
# the VM ends in a clean state matching how verify_rollback leaves it.
+ # Safe here and not before the reboot, because the pool has been
+ # reimported since the rollback.
ssh_cmd "echo yes | zfssnapshot delete --name '$rb_snap_name' 2>&1" >/dev/null || true
return 0
@@ -1035,54 +1211,17 @@ run_test() {
step "Booting from installed disk..."
: > "$SERIAL_LOG" # Clear serial log
- # Determine encryption mode (needs monitor socket for passphrase entry via sendkey)
- local luks_passphrase
- luks_passphrase=$(grep '^LUKS_PASSPHRASE=' "$config" | cut -d= -f2)
- local zfs_passphrase
- zfs_passphrase=$(grep '^ZFS_PASSPHRASE=' "$config" | cut -d= -f2)
- local no_encrypt
- no_encrypt=$(grep '^NO_ENCRYPT=' "$config" | cut -d= -f2)
- local encrypt_flag=""
- if [[ -n "$luks_passphrase" && "$no_encrypt" != "yes" ]]; then
- encrypt_flag="luks"
- elif [[ -n "$zfs_passphrase" && "$no_encrypt" != "yes" ]]; then
- encrypt_flag="zfs"
- fi
-
- local vm_pid2
- vm_pid2=$(start_vm_from_disk "$config_name" "$disk_count" "$encrypt_flag")
-
- if [[ -z "$vm_pid2" ]]; then
- error "Failed to start VM from disk"
+ # boot_from_disk owns the encryption-mode decision and the monitor sendkey
+ # passphrase entry. Same sequence reboot_guest uses mid-test, so the two
+ # paths can't drift.
+ if ! boot_from_disk "$config"; then
+ stop_vm "$config_name"
+ cp "$SERIAL_LOG" "$LOG_DIR/${config_name}-reboot-serial.log" 2>/dev/null || true
cleanup_disks "$config_name"
TESTS_FAILED=$((TESTS_FAILED + 1))
FAILED_TESTS+=("$config_name")
return 1
fi
- info "VM started from disk (PID: $vm_pid2)"
-
- # If encryption is enabled, send passphrase via monitor sendkey
- if [[ "$encrypt_flag" == "luks" ]]; then
- if ! send_luks_passphrase "$config_name" "$luks_passphrase" "$disk_count"; then
- error "Failed to send LUKS passphrase"
- stop_vm "$config_name"
- cp "$SERIAL_LOG" "$LOG_DIR/${config_name}-reboot-serial.log" 2>/dev/null || true
- cleanup_disks "$config_name"
- TESTS_FAILED=$((TESTS_FAILED + 1))
- FAILED_TESTS+=("$config_name")
- return 1
- fi
- elif [[ "$encrypt_flag" == "zfs" ]]; then
- if ! send_zfs_passphrase "$config_name" "$zfs_passphrase"; then
- error "Failed to send ZFS passphrase"
- stop_vm "$config_name"
- cp "$SERIAL_LOG" "$LOG_DIR/${config_name}-reboot-serial.log" 2>/dev/null || true
- cleanup_disks "$config_name"
- TESTS_FAILED=$((TESTS_FAILED + 1))
- FAILED_TESTS+=("$config_name")
- return 1
- fi
- fi
# Check if SSH is enabled in the config
local enable_ssh
@@ -1212,6 +1351,10 @@ main() {
# Find ISO
find_iso
+ # Before the first scenario, so a stale upstream re-upload can't fail every
+ # ZFS install with an error that looks like a regression.
+ clear_archzfs_cache
+
# Determine which configs to run
if [[ ${#configs[@]} -eq 0 ]]; then
# Run all configs
diff --git a/tests/unit/test_test_install.bats b/tests/unit/test_test_install.bats
index bf43dd8..52ea037 100644
--- a/tests/unit/test_test_install.bats
+++ b/tests/unit/test_test_install.bats
@@ -318,6 +318,10 @@ error: failed to commit transaction (invalid or corrupted package (checksum))
@test "ssh_cmd picks up a caller-scoped INSTALLED_PASSWORD" {
# Proves local-instead-of-export still reaches ssh_cmd: bash's dynamic
# scoping exposes a caller's local to the functions it calls.
+ #
+ # timeout is stubbed as a pass-through because the real one is an external
+ # binary: it would exec the real sshpass and never see these stubs.
+ timeout() { shift; "$@"; }
sshpass() { echo "$2"; }
ssh() { :; }
caller_with_local() {
@@ -329,6 +333,7 @@ error: failed to commit transaction (invalid or corrupted package (checksum))
}
@test "a caller-scoped INSTALLED_PASSWORD does not leak past a failed return" {
+ timeout() { shift; "$@"; }
sshpass() { echo "$2"; }
ssh() { :; }
SSH_PASSWORD="live-iso-password"
@@ -350,3 +355,150 @@ error: failed to commit transaction (invalid or corrupted package (checksum))
grep -qE '^[[:space:]]*local INSTALLED_PASSWORD=' "$src"
! grep -qE '^[[:space:]]*export INSTALLED_PASSWORD' "$src"
}
+
+#############################
+# config_encrypt_flag
+#############################
+# Decides which passphrase-entry path a reboot needs. It's the one pure piece
+# of the boot-from-disk sequence, which is otherwise qemu orchestration, so it
+# carries the precedence rules the rest of that sequence depends on.
+
+mkcfg() {
+ local f
+ f=$(mktemp)
+ printf '%s\n' "$@" > "$f"
+ echo "$f"
+}
+
+@test "config_encrypt_flag reports luks for a LUKS config" {
+ local f; f=$(mkcfg 'LUKS_PASSPHRASE=secret' 'DISKS=/dev/vda')
+ [ "$(config_encrypt_flag "$f")" = "luks" ]
+ rm -f "$f"
+}
+
+@test "config_encrypt_flag reports zfs for a ZFS-passphrase config" {
+ local f; f=$(mkcfg 'ZFS_PASSPHRASE=secret' 'DISKS=/dev/vda')
+ [ "$(config_encrypt_flag "$f")" = "zfs" ]
+ rm -f "$f"
+}
+
+@test "config_encrypt_flag prefers luks when a config carries both" {
+ # Preserves the precedence the inline block had: LUKS is checked first,
+ # and a config with both is a misconfiguration rather than a real mode.
+ local f; f=$(mkcfg 'LUKS_PASSPHRASE=a' 'ZFS_PASSPHRASE=b')
+ [ "$(config_encrypt_flag "$f")" = "luks" ]
+ rm -f "$f"
+}
+
+@test "config_encrypt_flag reports nothing when NO_ENCRYPT overrides a passphrase" {
+ # The test configs set a passphrase *and* NO_ENCRYPT=yes; sending a
+ # passphrase to an unencrypted boot would type it at a login prompt.
+ #
+ # Asserted through `run` on purpose. A bare [ -z "$(...)" ] also passes
+ # when the function doesn't exist, so it can't fail for the reason the
+ # test exists — checking status too makes absence register as 127.
+ local f; f=$(mkcfg 'ZFS_PASSPHRASE=testpass' 'NO_ENCRYPT=yes')
+ run config_encrypt_flag "$f"
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+ rm -f "$f"
+}
+
+@test "config_encrypt_flag reports nothing for an unencrypted config" {
+ local f; f=$(mkcfg 'DISKS=/dev/vda' 'HOSTNAME=x')
+ run config_encrypt_flag "$f"
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+ rm -f "$f"
+}
+
+#############################
+# ssh_cmd timeout bound
+#############################
+# ConnectTimeout bounds the connection, not execution. On 2026-08-03 a remote
+# `zfs destroy` blocked behind an uninterruptible txg_quiesce with the
+# connection healthy, and the suite sat dead for 40 minutes. A wedged guest
+# should cost one scenario, not the run.
+
+@test "ssh_cmd bounds the remote command with the default timeout" {
+ timeout() { echo "bound=$1"; }
+ run ssh_cmd true
+ [[ "$output" == "bound=$SSH_CMD_TIMEOUT" ]]
+}
+
+@test "ssh_cmd honors a per-call timeout override" {
+ # run_install's installer call legitimately runs for many minutes and
+ # raises this; every other call keeps the short default.
+ timeout() { echo "bound=$1"; }
+ SSH_CMD_TIMEOUT=1800 run ssh_cmd true
+ [[ "$output" == "bound=1800" ]]
+}
+
+#############################
+# clear_archzfs_cache
+#############################
+# Exercised against an injected directory, never the real one. An earlier
+# version of this block hardcoded the system path, so running the unit suite
+# invoked `sudo rm -rf /var/cache/pacoloco/pkgs/archzfs` on the live machine.
+# A unit test must not reach outside its sandbox.
+
+@test "clear_archzfs_cache is a no-op when the cache directory is absent" {
+ ARCHZFS_CACHE_DIR="$BATS_TEST_TMPDIR/absent" run clear_archzfs_cache
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+}
+
+@test "clear_archzfs_cache removes an existing cache directory" {
+ local dir="$BATS_TEST_TMPDIR/archzfs"
+ mkdir -p "$dir"
+ touch "$dir/archzfs.db" "$dir/zfs-dkms-1-1-x86_64.pkg.tar.zst"
+ # Stub sudo so the test needs no privilege and stays in its sandbox.
+ sudo() { shift; "$@"; }
+ ARCHZFS_CACHE_DIR="$dir" run clear_archzfs_cache
+ [ "$status" -eq 0 ]
+ [ ! -d "$dir" ]
+}
+
+@test "clear_archzfs_cache clears the db too, not just the package bodies" {
+ # Removing bodies while leaving a stale archzfs.db trades a checksum error
+ # for "Maximum file size exceeded" — same cause, new message.
+ local dir="$BATS_TEST_TMPDIR/db/archzfs"
+ mkdir -p "$dir"
+ touch "$dir/archzfs.db"
+ sudo() { shift; "$@"; }
+ ARCHZFS_CACHE_DIR="$dir" run clear_archzfs_cache
+ [ ! -e "$dir/archzfs.db" ]
+}
+
+@test "clear_archzfs_cache warns and succeeds when removal is denied" {
+ local dir="$BATS_TEST_TMPDIR/denied/archzfs"
+ mkdir -p "$dir"
+ sudo() { return 1; }
+ ARCHZFS_CACHE_DIR="$dir" run clear_archzfs_cache
+ # Non-fatal: a run without root is still worth having, but must say so.
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"needs root"* ]]
+ [ -d "$dir" ]
+}
+
+@test "clear_archzfs_cache refuses a path that isn't shaped like the cache" {
+ # The removal runs `rm -rf` under sudo, so a mistyped override must cost a
+ # warning rather than the machine.
+ local dir="$BATS_TEST_TMPDIR/not-the-cache"
+ mkdir -p "$dir"
+ sudo() { echo "SUDO RAN"; }
+ ARCHZFS_CACHE_DIR="$dir" run clear_archzfs_cache
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Refusing"* ]]
+ [[ "$output" != *"SUDO RAN"* ]]
+ [ -d "$dir" ]
+}
+
+@test "clear_archzfs_cache refuses a top-level directory" {
+ sudo() { echo "SUDO RAN"; }
+ ARCHZFS_CACHE_DIR="/archzfs" run clear_archzfs_cache
+ # /archzfs won't exist, so this exits on the -d guard; the point is that
+ # neither guard lets a root-level path reach the removal.
+ [ "$status" -eq 0 ]
+ [[ "$output" != *"SUDO RAN"* ]]
+}