diff options
| -rw-r--r-- | .gitignore | 20 | ||||
| -rw-r--r-- | README.org | 57 | ||||
| -rw-r--r-- | build-aur.sh | 15 | ||||
| -rwxr-xr-x | build.sh | 35 | ||||
| -rwxr-xr-x | installer/archangel | 62 | ||||
| -rw-r--r-- | installer/lib/btrfs.sh | 69 | ||||
| -rw-r--r-- | installer/lib/common.sh | 41 | ||||
| -rw-r--r-- | installer/lib/config.sh | 22 | ||||
| -rwxr-xr-x | scripts/test-install.sh | 93 | ||||
| -rw-r--r-- | tests/unit/test_archangel.bats | 95 | ||||
| -rw-r--r-- | tests/unit/test_btrfs.bats | 35 | ||||
| -rw-r--r-- | tests/unit/test_build_aur.bats | 11 | ||||
| -rw-r--r-- | tests/unit/test_common.bats | 70 | ||||
| -rw-r--r-- | tests/unit/test_config.bats | 58 | ||||
| -rw-r--r-- | tests/unit/test_test_install.bats | 50 |
15 files changed, 622 insertions, 111 deletions
@@ -15,8 +15,11 @@ aur-packages.staging/ # Personal session/workflow docs (not project documentation) .ai/ +.claude/ todo.org inbox/ +CLAUDE.md +AGENTS.md # Host-specific installer configs (contain secrets); see *.conf.example templates installer/velox-*.conf @@ -25,3 +28,20 @@ installer/velox-*.conf *~ \#*\# .\#* + +# --- elisp ruleset --- +githooks/ +*.elc +*.eln + +# --- python ruleset --- +__pycache__/ +*.pyc +*.pyo +.coverage +coverage.json +htmlcov/ +.pytest_cache/ + +# Claude Code: task archive (follows todo file privacy) +/archive/task-archive.org @@ -27,15 +27,24 @@ The ISO also doubles as a [[#rescue-disk][rescue disk]] with data recovery, boot Archangel currently uses linux-lts for stability. Choosing linux and linux-zen kernel varieties coming shortly. ** Filesystem Comparison +:PROPERTIES: +:CUSTOM_ID: filesystem-comparison +:END: | Feature | ZFS | Btrfs | |------------------+----------------------------+----------------------| | Bootloader | ZFSBootMenu | GRUB + grub-btrfs | +|------------------+----------------------------+----------------------| | Encryption | Native ZFS encryption | LUKS2 | +|------------------+----------------------------+----------------------| | Snapshot utility | zfssnapshot | snapper | +|------------------+----------------------------+----------------------| | Snapshot boot | Built into ZFSBootMenu | grub-btrfs menu | +|------------------+----------------------------+----------------------| | RAID support | mirror, raidz1/2/3, stripe | RAID0, RAID1, RAID10 | +|------------------+----------------------------+----------------------| | EFI size | 512MB | 1GB | +|------------------+----------------------------+----------------------| * Prerequisites @@ -69,9 +78,9 @@ back to the upstream URLs. them into the ISO as a local pacman repo at ~/usr/share/aur-packages~. They work in the live environment (~pacman -Sl aur~) and install onto the target offline, so the installer never needs the AUR or a build -toolchain on the target. The v1 set (audited 2026-06-09): +toolchain on the target. The v1 set (re-audited 2026-06-16): ~downgrade~, ~yay~, ~informant~, ~zrepl~, ~pacman-cleanup-hook~, -~sanoid~, ~zfs-auto-snapshot~, ~topgrade~, ~ventoy-bin~. +~zfs-auto-snapshot~, ~topgrade~, ~ventoy-bin~. Building these needs ~base-devel~ and ~git~ on the build host, and the build must run under ~sudo~ so it can drop to your user for ~makepkg~ @@ -155,6 +164,9 @@ make build 2. Boot from USB and wait for the live environment to load. ** SSH Access +:PROPERTIES: +:CUSTOM_ID: ssh-access +:END: The live environment automatically starts an SSH server, allowing remote access for headless installations, rescue operations, or VM testing. Avahi is @@ -172,6 +184,9 @@ The default root password is set in ~build.sh~ (variable ~LIVE_ROOT_PASSWORD~). operations only. Do not expose the live environment to untrusted networks. ** Rescue Disk +:PROPERTIES: +:CUSTOM_ID: rescue-disk +:END: The ISO serves as a general-purpose rescue disk with a comprehensive set of recovery and diagnostic tools pre-installed: @@ -231,6 +246,9 @@ After configuration, the installation runs without intervention: - Genesis snapshot creation ** Unattended Installation +:PROPERTIES: +:CUSTOM_ID: unattended-installation +:END: For automated or headless installations, you can specify a simple plain text config file containing the choices. @@ -283,19 +301,30 @@ A complete example with all options is available at ~installer/archangel.conf.ex *** Config File Reference -| Field | Required | Default | Description | -|----------------+----------+-------------+--------------------------------------------| +| Field | Required | Default | Description | +|------------------+----------+-------------+--------------------------------------------| | ~FILESYSTEM~ | No | zfs | Filesystem type (zfs or btrfs) | +|------------------+----------+-------------+--------------------------------------------| | ~HOSTNAME~ | Yes | - | System hostname | +|------------------+----------+-------------+--------------------------------------------| | ~TIMEZONE~ | Yes | - | Timezone (Region/City format) | +|------------------+----------+-------------+--------------------------------------------| | ~DISKS~ | Yes | - | Comma-separated disk paths | +|------------------+----------+-------------+--------------------------------------------| | ~ZFS_PASSPHRASE~ | Yes* | - | Encryption passphrase (*if not NO_ENCRYPT) | +|------------------+----------+-------------+--------------------------------------------| | ~ROOT_PASSWORD~ | Yes | - | Root user password | +|------------------+----------+-------------+--------------------------------------------| | ~LOCALE~ | No | en_US.UTF-8 | System locale | +|------------------+----------+-------------+--------------------------------------------| | ~KEYMAP~ | No | us | Console keyboard layout | +|------------------+----------+-------------+--------------------------------------------| | ~RAID_LEVEL~ | No | mirror | RAID type for multi-disk | +|------------------+----------+-------------+--------------------------------------------| | ~ENABLE_SSH~ | No | yes | Enable SSH server | +|------------------+----------+-------------+--------------------------------------------| | ~NO_ENCRYPT~ | No | no | Skip encryption (testing only) | +|------------------+----------+-------------+--------------------------------------------| * Post-Reboot @@ -310,6 +339,9 @@ A complete example with all options is available at ~installer/archangel.conf.ex 3. Log in as root * Post-Installation +:PROPERTIES: +:CUSTOM_ID: post-installation +:END: ** ZFS Snapshot Management @@ -363,6 +395,9 @@ installation and can decline, or set ~ENABLE_SSH=no~ in a config file. and consider installing ~fail2ban~. * Testing +:PROPERTIES: +:CUSTOM_ID: testing +:END: Two test layers: ~make test~ runs shellcheck + bats unit tests for the pure logic in ~installer/lib/*.sh~ (fast, no VMs). ~make test-install~ runs the full @@ -425,11 +460,15 @@ archangel/ ** Script Descriptions -| Script | Description | -|---------------------+--------------------------------------------------------------------------------------------------------------| -| ~build.sh~ | Builds the ISO. Copies releng profile, adds packages, configures kernel, runs mkarchiso | -| ~installer/archangel~ | Interactive installer. Handles disk partitioning, filesystem creation, base system install, bootloader setup | -| ~scripts/test-vm.sh~ | Launches QEMU VM for testing. Supports single and multi-disk configurations | +| Script | Description | +|-----------------------+----------------------------------------------------------------------------------------------| +| ~build.sh~ | Builds the ISO. Copies releng profile, adds packages, configures kernel, runs mkarchiso | +|-----------------------+----------------------------------------------------------------------------------------------| +| ~installer/archangel~ | Interactive installer. Handles disk partitioning, filesystem creation, base system install, | +| | bootloader setup | +|-----------------------+----------------------------------------------------------------------------------------------| +| ~scripts/test-vm.sh~ | Launches QEMU VM for testing. Supports single and multi-disk configurations | +|-----------------------+----------------------------------------------------------------------------------------------| * Troubleshooting diff --git a/build-aur.sh b/build-aur.sh index 9b83a33..3bec75c 100644 --- a/build-aur.sh +++ b/build-aur.sh @@ -18,12 +18,14 @@ ############################# # The v1 genuine-AUR build set: packages with no exact official-repo match -# whose runtime + make deps all resolve from official / archzfs / the baked -# local repo (the v1 dependency gate). paru (second helper) and -# mkinitcpio-firmware (pulls AUR firmware deps) are deferred to vNext. -# Audited 2026-06-09. This list is the one place the set is named; build.sh -# reads it for the package-list append, and the manifest records what -# actually shipped. +# whose runtime + make deps all resolve from the official repos / archzfs +# (the v1 dependency gate). paru (second helper), mkinitcpio-firmware, and +# sanoid are deferred to vNext: each pulls an AUR-of-AUR dependency that +# makepkg -s can't resolve from the official repos (sanoid needs +# perl-config-inifiles, which is AUR-only). Re-audited 2026-06-16 against the +# official sync dbs after a real build caught sanoid's missing dep. This list +# is the one place the set is named; build.sh reads it for the package-list +# append, and the manifest records what actually shipped. aur_v1_packages() { printf '%s\n' \ downgrade \ @@ -31,7 +33,6 @@ aur_v1_packages() { informant \ zrepl \ pacman-cleanup-hook \ - sanoid \ zfs-auto-snapshot \ topgrade \ ventoy-bin @@ -447,33 +447,14 @@ EOF info "Setting root password for live ISO..." # Generate password hash PASS_HASH=$(openssl passwd -6 "$LIVE_ROOT_PASSWORD") -# Modify the existing shadow file's root entry (don't replace entire file) -# The releng template has multiple accounts; replacing breaks the file -if [[ -f "$PROFILE_DIR/airootfs/etc/shadow" ]]; then - sed -i "s|^root:[^:]*:|root:${PASS_HASH}:|" "$PROFILE_DIR/airootfs/etc/shadow" -else - # Fallback: create complete shadow file if it doesn't exist - cat > "$PROFILE_DIR/airootfs/etc/shadow" << EOF -root:${PASS_HASH}:19000:0:99999:7::: -bin:!*:19000:::::: -daemon:!*:19000:::::: -mail:!*:19000:::::: -ftp:!*:19000:::::: -http:!*:19000:::::: -nobody:!*:19000:::::: -dbus:!*:19000:::::: -systemd-coredump:!*:19000:::::: -systemd-network:!*:19000:::::: -systemd-oom:!*:19000:::::: -systemd-journal-remote:!*:19000:::::: -systemd-resolve:!*:19000:::::: -systemd-timesync:!*:19000:::::: -tss:!*:19000:::::: -uuidd:!*:19000:::::: -polkitd:!*:19000:::::: -avahi:!*:19000:::::: -EOF -fi +# Modify the existing shadow file's root entry (don't replace the whole +# file — the releng template ships /etc/shadow with multiple accounts and +# rewriting it from scratch would drop them). The profile is always copied +# fresh from releng above, so the file is present; if it's missing, that +# copy is broken — fail loudly rather than silently rebuilding a stale list. +[[ -f "$PROFILE_DIR/airootfs/etc/shadow" ]] \ + || error "Expected shadow file missing: $PROFILE_DIR/airootfs/etc/shadow (releng profile copy broken?)" +sed -i "s|^root:[^:]*:|root:${PASS_HASH}:|" "$PROFILE_DIR/airootfs/etc/shadow" chmod 400 "$PROFILE_DIR/airootfs/etc/shadow" # Allow root SSH login with password (for testing) diff --git a/installer/archangel b/installer/archangel index ec3017a..b9817e8 100755 --- a/installer/archangel +++ b/installer/archangel @@ -791,9 +791,24 @@ EOF info "Exposing baked AUR repo to pacstrap..." append_aur_repo /etc/pacman.conf "file://$aur_repo_dir" mapfile -t aur_packages < <(aur_manifest_names "$aur_repo_dir/manifest.tsv") + # Drop ZFS-only AUR tooling on a non-ZFS target. The baked repo carries + # the full set on every ISO, but e.g. zfs-auto-snapshot has a hard zfs + # dependency that can't resolve on a btrfs install and would abort the + # whole pacstrap transaction. + mapfile -t aur_packages < <(filter_aur_for_fs "$FILESYSTEM" "${aur_packages[@]}") info "Baked AUR packages to install: ${aur_packages[*]:-none}" fi + # Force-refresh package databases before pacstrap. The ISO bakes an + # archzfs sync db at build time, so as the ISO ages that db pins an older + # zfs-dkms while linux-lts is pulled current from the live mirror, and the + # DKMS build then fails against the newer kernel. A single -y can be + # skipped by pacman's freshness check against the GitHub-served archzfs db + # (no reliable timestamps), so -yy forces the refresh. This keeps the + # "zfs-dkms always matches the kernel" guarantee true regardless of ISO age. + info "Refreshing package databases..." + pacman -Syy --noconfirm || error "Failed to refresh package databases" + info "Installing base packages (this takes a while)..." local packages @@ -910,6 +925,47 @@ configure_ssh() { fi } +# Close the second passphrase prompt on encrypted ZFS boots. +# +# ZFSBootMenu unlocks the pool to read this kernel and initramfs, then kexecs +# into it — and the loaded key does not survive kexec. With no key inside the +# initramfs, the zfs hook re-imports the pool, finds keylocation=prompt, and +# asks for the same passphrase a second time. +# +# Pointing the encryption root at a keyfile that lives inside the encrypted +# dataset closes it. ZFSBootMenu cannot read a file in a dataset it has not +# unlocked, so it overrides the file:// URI and prompts once — documented +# upstream behavior, not a side effect. The booted initramfs carries the +# keyfile and loads the key silently. +# +# keyformat stays passphrase: it is what lets ZFSBootMenu accept the typed +# value, and a raw key would leave it no way in at all. keylocation alone is +# settable with `zfs set` (zfsprops(7)), so this never reaches for `zfs +# change-key`, which would rekey the pool and prompt for new key material +# mid-install. +# +# Never relocate this keyfile onto the ESP or into a custom ZFSBootMenu image. +# Both are unencrypted; the protection here comes entirely from the keyfile and +# the initramfs living inside the encrypted dataset. +configure_zfs_keyfile() { + local passphrase="$1" + local pool="$2" + local keyfile="/etc/zfs/zroot.key" + + mkdir -p "$MNTPOINT$(dirname "$keyfile")" + + # No trailing newline: ZFS reads the file's bytes as the passphrase, so a + # stray newline would not match what the user types at the ZBM prompt. + printf '%s' "$passphrase" > "$MNTPOINT$keyfile" + chmod 000 "$MNTPOINT$keyfile" + + zfs set keylocation="file://$keyfile" "$pool" \ + || error "Failed to point $pool at $keyfile" + + ensure_initramfs_files "$keyfile" "$MNTPOINT/etc/mkinitcpio.conf" + info "Keyfile embedded in initramfs - one passphrase prompt at boot." +} + configure_initramfs() { step "Configuring Initramfs for ZFS" @@ -969,6 +1025,12 @@ EOF # system. (Audited 2026-04-27 against silent-sed pattern.) sed -i 's/^HOOKS=.*/HOOKS=(base udev microcode modconf kms keyboard keymap consolefont block zfs filesystems)/' $MNTPOINT/etc/mkinitcpio.conf + # Embed the pool key so the booted initramfs doesn't re-prompt. Must run + # before mkinitcpio -P below, which is what bakes FILES= into the image. + if [[ "$NO_ENCRYPT" != "yes" ]]; then + configure_zfs_keyfile "$ZFS_PASSPHRASE" "$POOL_NAME" + fi + # Get the installed kernel version (not the running kernel) local kernel_ver kernel_ver=$(ls $MNTPOINT/usr/lib/modules | grep lts | head -1) diff --git a/installer/lib/btrfs.sh b/installer/lib/btrfs.sh index 0a34be0..67c96a0 100644 --- a/installer/lib/btrfs.sh +++ b/installer/lib/btrfs.sh @@ -340,6 +340,36 @@ create_btrfs_subvolumes() { # Btrfs Mount Functions ############################# +# Compose the mount-option string for a single subvolume: the shared +# BTRFS_OPTS prefixed with subvol=<name>, then the per-subvol extra +# flags applied. compress=no and nodatacow both drop the default +# compress=zstd; nodatacow also appends nodatacow; nosuid appends +# nosuid,nodev. Pure string transform — no I/O. Shared by +# mount_btrfs_subvolumes and generate_btrfs_fstab so the two stay in sync. +# Usage: parse_btrfs_subvol_opts NAME EXTRA +parse_btrfs_subvol_opts() { + local name="$1" extra="$2" + local opts="subvol=$name,$BTRFS_OPTS" + + if [[ -n "$extra" ]]; then + # compress=no: drop the default compression, don't add anything + if [[ "$extra" == *"compress=no"* ]]; then + opts=$(echo "$opts" | sed 's/,compress=zstd//') + fi + # nodatacow implies no compression (incompatible), so drop it too + if [[ "$extra" == *"nodatacow"* ]]; then + opts="$opts,nodatacow" + opts=$(echo "$opts" | sed 's/,compress=zstd//') + fi + # nosuid,nodev hardening for tmp subvolumes + if [[ "$extra" == *"nosuid"* ]]; then + opts="$opts,nosuid,nodev" + fi + fi + + echo "$opts" +} + mount_btrfs_subvolumes() { local partition="$1" @@ -356,25 +386,8 @@ mount_btrfs_subvolumes() { # Skip root, already mounted [[ "$name" == "@" ]] && continue - # Build mount options - local opts="subvol=$name,$BTRFS_OPTS" - - # Apply extra options (override defaults where specified) - if [[ -n "$extra" ]]; then - # Handle compress=no by removing compress from opts and not adding it - if [[ "$extra" == *"compress=no"* ]]; then - opts=$(echo "$opts" | sed 's/,compress=zstd//') - fi - # Handle nodatacow - if [[ "$extra" == *"nodatacow"* ]]; then - opts="$opts,nodatacow" - opts=$(echo "$opts" | sed 's/,compress=zstd//') - fi - # Handle nosuid,nodev for tmp - if [[ "$extra" == *"nosuid"* ]]; then - opts="$opts,nosuid,nodev" - fi - fi + local opts + opts=$(parse_btrfs_subvol_opts "$name" "$extra") info "Mounting $name -> $MNTPOINT$mountpoint" mkdir -p "$MNTPOINT$mountpoint" @@ -412,22 +425,8 @@ EOF for subvol_spec in "${BTRFS_SUBVOLS[@]}"; do IFS=':' read -r name mountpoint extra <<< "$subvol_spec" - # Build mount options - local opts="subvol=$name,$BTRFS_OPTS" - - # Apply extra options - if [[ -n "$extra" ]]; then - if [[ "$extra" == *"compress=no"* ]]; then - opts=$(echo "$opts" | sed 's/,compress=zstd//') - fi - if [[ "$extra" == *"nodatacow"* ]]; then - opts="$opts,nodatacow" - opts=$(echo "$opts" | sed 's/,compress=zstd//') - fi - if [[ "$extra" == *"nosuid"* ]]; then - opts="$opts,nosuid,nodev" - fi - fi + local opts + opts=$(parse_btrfs_subvol_opts "$name" "$extra") echo "UUID=$uuid $mountpoint btrfs $opts 0 0" >> $MNTPOINT/etc/fstab done diff --git a/installer/lib/common.sh b/installer/lib/common.sh index 0317034..0378756 100644 --- a/installer/lib/common.sh +++ b/installer/lib/common.sh @@ -166,6 +166,38 @@ aur_manifest_names() { awk -F'\t' 'NR>1 {print $1}' "$manifest" } +# Print the baked AUR packages that are ZFS-only tooling, one per line. The ISO +# bakes the full AUR set on every build, but these require a ZFS root: +# zfs-auto-snapshot has a hard `zfs` dependency, and zrepl is ZFS replication. +# On a non-ZFS install neither dependency exists, so installing them is at best +# pointless and at worst aborts pacstrap (zfs-auto-snapshot's unmet `zfs` dep +# fails the whole transaction). Keep in lockstep with build-aur.sh's +# aur_v1_packages: a new ZFS-only AUR package added there belongs here too. +aur_zfs_only_packages() { + printf '%s\n' \ + zfs-auto-snapshot \ + zrepl +} + +# Filter a list of AUR package names for the target filesystem, printing the +# kept names one per line in input order. On a ZFS target every package passes +# through. On any other filesystem the ZFS-only tooling (aur_zfs_only_packages) +# is dropped so it never reaches pacstrap. install_base runs the baked manifest +# names through this before appending them to the pacstrap set. +filter_aur_for_fs() { + local fs="$1"; shift + local -A drop=() + if [[ "$fs" != zfs ]]; then + local z + while IFS= read -r z; do drop["$z"]=1; done < <(aur_zfs_only_packages) + fi + local pkg + for pkg in "$@"; do + [[ -n "${drop[$pkg]:-}" ]] && continue + printf '%s\n' "$pkg" + done +} + # Remove the named repo's stanza (its [name] header and the config lines up to # the next [section] or EOF) from the pacman.conf at $2. Used to ensure the # installed target never references the baked [aur] repo, whose @@ -181,7 +213,14 @@ strip_repo_stanza() { skip { next } { print } ' "$pacman_conf" > "$tmp" - mv "$tmp" "$pacman_conf" + # Truncate-write in place rather than `mv` the temp over the target: mktemp + # creates the temp 0600, and a mv would carry that onto pacman.conf, + # clobbering its pristine 0644 and leaving the installed config root-only. + # That broke every user-level makepkg/yay ("config file /etc/pacman.conf + # could not be read: Permission denied"). Writing through the existing file + # keeps its inode and mode. + cat "$tmp" > "$pacman_conf" + rm -f "$tmp" } ############################# diff --git a/installer/lib/config.sh b/installer/lib/config.sh index 3ba2bb3..ed54e36 100644 --- a/installer/lib/config.sh +++ b/installer/lib/config.sh @@ -116,20 +116,30 @@ check_config() { validate_config() { local errors=0 - [[ -z "$HOSTNAME" ]] && { warn "HOSTNAME not set"; ((errors++)); } - [[ -z "$TIMEZONE" ]] && { warn "TIMEZONE not set"; ((errors++)); } - [[ ${#SELECTED_DISKS[@]} -eq 0 ]] && { warn "No disks selected"; ((errors++)); } - [[ -z "$ROOT_PASSWORD" ]] && { warn "ROOT_PASSWORD not set"; ((errors++)); } + [[ -z "$HOSTNAME" ]] && { warn "HOSTNAME not set"; ((++errors)); } + [[ -z "$TIMEZONE" ]] && { warn "TIMEZONE not set"; ((++errors)); } + [[ ${#SELECTED_DISKS[@]} -eq 0 ]] && { warn "No disks selected"; ((++errors)); } + [[ -z "$ROOT_PASSWORD" ]] && { warn "ROOT_PASSWORD not set"; ((++errors)); } # Validate disks exist for disk in "${SELECTED_DISKS[@]}"; do - [[ -b "$disk" ]] || { warn "Disk not found: $disk"; ((errors++)); } + [[ -b "$disk" ]] || { warn "Disk not found: $disk"; ((++errors)); } done # Validate timezone if [[ -n "$TIMEZONE" && ! -f "/usr/share/zoneinfo/$TIMEZONE" ]]; then warn "Invalid timezone: $TIMEZONE" - ((errors++)) + ((++errors)) + fi + + # Validate the RAID level against the selected disk count. The + # interactive path only offers levels valid for the count, so this + # guards the unattended config, where RAID_LEVEL is set by hand and + # can name a level the disk count can't support. raid_is_valid treats + # an empty level on a single disk (no RAID) as valid. + if ! raid_is_valid "$RAID_LEVEL" "${#SELECTED_DISKS[@]}"; then + warn "Invalid RAID_LEVEL '$RAID_LEVEL' for ${#SELECTED_DISKS[@]} disk(s)" + ((++errors)) fi if [[ $errors -gt 0 ]]; then diff --git a/scripts/test-install.sh b/scripts/test-install.sh index 0bc4e9a..bb556cd 100755 --- a/scripts/test-install.sh +++ b/scripts/test-install.sh @@ -477,11 +477,21 @@ run_install() { local config_name config_name=$(basename "$config" .conf) - # Copy latest archangel script and lib/ to VM (in case ISO is outdated) - sshpass -p "$SSH_PASSWORD" scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ - -P "$SSH_PORT" "$PROJECT_DIR/installer/archangel" root@localhost:/usr/local/bin/archangel 2>/dev/null - sshpass -p "$SSH_PASSWORD" scp -r -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ - -P "$SSH_PORT" "$PROJECT_DIR/installer/lib" root@localhost:/usr/local/bin/ 2>/dev/null + # Copy latest archangel script and lib/ to VM (in case ISO is outdated). + # Both pushes are checked: an unreported failure here leaves the guest + # without a usable installer, and the run then dies at `archangel + # --config-file` with exit 127 and no output at all — indistinguishable + # from a real install regression. Say which push failed instead. + if ! sshpass -p "$SSH_PASSWORD" scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + -P "$SSH_PORT" "$PROJECT_DIR/installer/archangel" root@localhost:/usr/local/bin/archangel 2>/dev/null; then + echo "[ERROR] Failed to push installer/archangel to the VM" + return 1 + fi + if ! sshpass -p "$SSH_PASSWORD" scp -r -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + -P "$SSH_PORT" "$PROJECT_DIR/installer/lib" root@localhost:/usr/local/bin/ 2>/dev/null; then + echo "[ERROR] Failed to push installer/lib to the VM" + return 1 + fi # Copy config file to VM sshpass -p "$SSH_PASSWORD" scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ @@ -877,6 +887,25 @@ run_test() { local config_name config_name=$(basename "$config" .conf) + # Scope the installed-system password to this one test. It used to be + # exported and cleared by a single `unset` on the success path, so any + # failure after the reboot step returned early and leaked the *installed* + # system's password into the next scenario — where ssh_cmd then presented + # it to the *live ISO* and every SSH call failed instantly, with no output + # and no package requests. That turned one flaky check into six silent + # install failures on 2026-08-01. Declaring it local makes bash clear it on + # every return path, so the class of bug can't come back. + # + # Safe as a local rather than an export: bash's dynamic scoping makes it + # visible to ssh_cmd and every verify_* helper called from here. The one + # child process in this flow — the `bash -c` install in the retry loop + # below — runs before this is ever assigned, and an unexported local + # reaches it as empty rather than as any value, which ssh_cmd's + # ${INSTALLED_PASSWORD:-$SSH_PASSWORD} treats the same as unset. That also + # shadows a stray INSTALLED_PASSWORD inherited from the caller's + # environment, which the old export did not. + local INSTALLED_PASSWORD="" + TESTS_RUN=$((TESTS_RUN + 1)) echo "" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" @@ -973,12 +1002,17 @@ run_test() { warn "Stale archzfs package in the host pacoloco cache (archzfs re-uploads same-filename assets)." warn "Rebuild the ISO (build.sh clears it) or run: sudo rm -f /var/cache/pacoloco/pkgs/archzfs/zfs-* — then retry." fi - stop_vm "$config_name" - - # Save logs - ssh_cmd "cat /tmp/archangel-*.log" > "$LOG_DIR/${config_name}-install.log" 2>/dev/null || true + # Save logs BEFORE stopping the VM. The retry loop above already + # fetched the in-VM install log into $install_log while the guest was + # still up, so write that rather than re-asking a guest that may be + # gone. The previous order ran stop_vm first and then tried to ssh + # into the corpse, so every *-install.log this harness ever wrote was + # 0 bytes — which is why the April 2026 `mirror` failure went 96 days + # without a diagnosis. + printf '%s\n' "$install_log" > "$LOG_DIR/${config_name}-install.log" cp "$SERIAL_LOG" "$LOG_DIR/${config_name}-serial.log" 2>/dev/null || true + stop_vm "$config_name" cleanup_disks "$config_name" TESTS_FAILED=$((TESTS_FAILED + 1)) FAILED_TESTS+=("$config_name") @@ -1089,8 +1123,10 @@ run_test() { return 1 fi - # Use installed system's password for subsequent SSH commands - export INSTALLED_PASSWORD="$installed_password" + # Use installed system's password for subsequent SSH commands. + # Plain assignment to the local declared at the top of run_test — see + # the note there for why this must not be exported. + INSTALLED_PASSWORD="$installed_password" # Verify reboot survival if ! verify_reboot_survival "$config"; then @@ -1102,15 +1138,22 @@ run_test() { return 1 fi - # Verify rollback functionality - if ! verify_rollback "$config"; then - warn "Rollback verification had issues" - # Don't fail the test for rollback issues - it's a bonus check - fi - - # Verify zfssnapshot wrapper end-to-end (ZFS only — no-op for Btrfs). - # Unlike verify_rollback, this is the wrapper's only runtime - # coverage, so a regression here fails the test outright. + # Order matters here, and it is the opposite of what reads naturally. + # + # On ZFS, verify_rollback rolls back zroot/ROOT/default while it is + # mounted and running. That reverts the live root underneath the OS — + # open file handles, cached inodes and sshd's own state stop matching + # what is on disk — so SSH work afterwards fails intermittently. When + # the wrapper check ran second it inherited that damage and failed on + # the scp or the chmod at random, which is what took out the ZFS half + # of the 2026-08-01 suite. Btrfs never showed it because snapper's + # rollback does not take effect until reboot. + # + # So the fatal check runs first, on a clean freshly-booted guest, and + # the destabilising one runs last where it can only warn. Both still + # run. The real fix is to reboot the guest between them; that needs + # the encrypted-pool passphrase re-sent via monitor sendkey, so it is + # filed rather than done here. if ! verify_zfssnapshot_wrapper "$config"; then error "zfssnapshot wrapper verification failed" stop_vm "$config_name" @@ -1119,11 +1162,17 @@ run_test() { FAILED_TESTS+=("$config_name") return 1 fi + + # Non-fatal by design: this is a bonus check, and it is also the step + # that leaves the guest inconsistent, so nothing depends on it after. + if ! verify_rollback "$config"; then + warn "Rollback verification had issues" + fi fi - # Cleanup + # Cleanup. INSTALLED_PASSWORD needs no reset here — it's a local, so bash + # clears it on every return path including the failure ones. step "Cleaning up..." - unset INSTALLED_PASSWORD # Reset for next test stop_vm "$config_name" cleanup_disks "$config_name" diff --git a/tests/unit/test_archangel.bats b/tests/unit/test_archangel.bats index 645b6e6..983bfd2 100644 --- a/tests/unit/test_archangel.bats +++ b/tests/unit/test_archangel.bats @@ -401,3 +401,98 @@ setup() { run network_available [ "$status" -eq 0 ] } + +############################# +# configure_zfs_keyfile +############################# +# Encrypted ZFS installs prompt for the same passphrase twice: +# ZFSBootMenu unlocks the pool to read the kernel and initramfs, then +# kexecs, and the key doesn't survive kexec — so the booted initramfs +# re-imports the pool, finds keylocation=prompt, and asks again. +# +# configure_zfs_keyfile closes the second prompt the same way the Btrfs +# path already closes its LUKS equivalent: write the passphrase to a +# keyfile inside the encrypted root, point the encryption root at it, +# and bake it into the initramfs via FILES=. ZFSBootMenu can't read a +# file inside a dataset it hasn't unlocked yet, so it still prompts +# once — that surviving prompt is the intended behavior, not a bug. +# +# zfs is the stubbed system boundary. The keyfile write, its +# permissions, and the FILES= wiring are exercised for real. + +zfs_keyfile_fixture() { + TEST_ROOT=$(mktemp -d) + MNTPOINT="$TEST_ROOT" + ZFS_ARGS_LOG="$TEST_ROOT/zfs-args" + mkdir -p "$MNTPOINT/etc" + printf '%s\n' 'FILES=()' > "$MNTPOINT/etc/mkinitcpio.conf" + zfs() { echo "$*" >> "$ZFS_ARGS_LOG"; return 0; } +} + +@test "configure_zfs_keyfile writes the passphrase with no trailing newline" { + zfs_keyfile_fixture + configure_zfs_keyfile "correct horse" zroot + # The keyfile lands mode 000, which locks out the owner too — only root + # bypasses that, and these tests don't run as root. Restore read access to + # inspect the content; the mode itself is asserted separately below. + chmod u+r "$MNTPOINT/etc/zfs/zroot.key" + # A trailing newline would become part of the passphrase ZFS reads back, + # so the key would never match what's typed at the ZBM prompt. 13 bytes, + # not 14: no terminator. + [ "$(wc -c < "$MNTPOINT/etc/zfs/zroot.key")" -eq 13 ] + [ "$(cat "$MNTPOINT/etc/zfs/zroot.key")" = "correct horse" ] + rm -rf "$TEST_ROOT" +} + +@test "configure_zfs_keyfile points the encryption root at the keyfile" { + zfs_keyfile_fixture + configure_zfs_keyfile testpass zroot + grep -qE '^set +keylocation=file:///etc/zfs/zroot\.key +zroot$' "$ZFS_ARGS_LOG" + rm -rf "$TEST_ROOT" +} + +@test "configure_zfs_keyfile changes the location without rekeying the pool" { + zfs_keyfile_fixture + configure_zfs_keyfile testpass zroot + # keylocation is settable with plain `zfs set` (zfsprops(7)), and + # keyformat is already passphrase from pool creation. Reaching for + # `zfs change-key` here would rekey the pool and prompt for new key + # material mid-install — and losing keyformat=passphrase would leave + # ZFSBootMenu with no way to accept a typed passphrase at all. + ! grep -qF 'change-key' "$ZFS_ARGS_LOG" + rm -rf "$TEST_ROOT" +} + +@test "configure_zfs_keyfile bakes the keyfile into the initramfs" { + zfs_keyfile_fixture + configure_zfs_keyfile testpass zroot + grep -qF 'FILES=(/etc/zfs/zroot.key)' "$MNTPOINT/etc/mkinitcpio.conf" + rm -rf "$TEST_ROOT" +} + +@test "configure_zfs_keyfile leaves the keyfile unreadable to other users" { + zfs_keyfile_fixture + configure_zfs_keyfile testpass zroot + # Protected at rest by the encrypted dataset, but a stray mode 644 + # would expose it to any local user on the running system. + [ "$(stat -c '%a' "$MNTPOINT/etc/zfs/zroot.key")" -eq 0 ] + rm -rf "$TEST_ROOT" +} + +@test "configure_zfs_keyfile preserves a passphrase containing shell metacharacters" { + zfs_keyfile_fixture + configure_zfs_keyfile 'a$b "c" \d*' zroot + chmod u+r "$MNTPOINT/etc/zfs/zroot.key" + [ "$(cat "$MNTPOINT/etc/zfs/zroot.key")" = 'a$b "c" \d*' ] + rm -rf "$TEST_ROOT" +} + +@test "configure_zfs_keyfile aborts when the key change fails" { + zfs_keyfile_fixture + zfs() { return 1; } + run configure_zfs_keyfile testpass zroot + # Silently continuing would ship an initramfs whose keyfile doesn't + # match the pool, turning one prompt into an unbootable system. + [ "$status" -eq 1 ] + rm -rf "$TEST_ROOT" +} diff --git a/tests/unit/test_btrfs.bats b/tests/unit/test_btrfs.bats index 890bba2..15bf141 100644 --- a/tests/unit/test_btrfs.bats +++ b/tests/unit/test_btrfs.bats @@ -54,3 +54,38 @@ setup() { [ "$status" -eq 0 ] [ -z "$output" ] } + +############################# +# parse_btrfs_subvol_opts +############################# +# Composes the mount-option string for one subvolume from the shared +# BTRFS_OPTS plus the per-subvol extra flags. Pure string transform, +# shared by mount_btrfs_subvolumes and generate_btrfs_fstab. BTRFS_OPTS +# is set at the top of btrfs.sh (sourced in setup), so these pin behavior +# against the real default option string. + +@test "parse_btrfs_subvol_opts: no extra flags keeps the default opts" { + run parse_btrfs_subvol_opts "@home" "" + [ "$status" -eq 0 ] + [ "$output" = "subvol=@home,noatime,compress=zstd,space_cache=v2,discard=async" ] +} + +@test "parse_btrfs_subvol_opts: compress=no drops compress=zstd" { + run parse_btrfs_subvol_opts "@media" "compress=no" + [ "$output" = "subvol=@media,noatime,space_cache=v2,discard=async" ] +} + +@test "parse_btrfs_subvol_opts: nodatacow adds nodatacow and drops compress=zstd" { + run parse_btrfs_subvol_opts "@vms" "nodatacow" + [ "$output" = "subvol=@vms,noatime,space_cache=v2,discard=async,nodatacow" ] +} + +@test "parse_btrfs_subvol_opts: nosuid adds nosuid,nodev and keeps compression" { + run parse_btrfs_subvol_opts "@tmp" "nosuid" + [ "$output" = "subvol=@tmp,noatime,compress=zstd,space_cache=v2,discard=async,nosuid,nodev" ] +} + +@test "parse_btrfs_subvol_opts: nodatacow and nosuid combine" { + run parse_btrfs_subvol_opts "@x" "nodatacow,nosuid" + [ "$output" = "subvol=@x,noatime,space_cache=v2,discard=async,nodatacow,nosuid,nodev" ] +} diff --git a/tests/unit/test_build_aur.bats b/tests/unit/test_build_aur.bats index 4da66fe..360f2b7 100644 --- a/tests/unit/test_build_aur.bats +++ b/tests/unit/test_build_aur.bats @@ -15,12 +15,12 @@ setup() { # aur_v1_packages — single source of truth for the v1 build set ############################# -@test "aur_v1_packages lists the nine audited v1 packages" { +@test "aur_v1_packages lists the eight audited v1 packages" { run aur_v1_packages [ "$status" -eq 0 ] - [ "$(echo "$output" | wc -l)" -eq 9 ] + [ "$(echo "$output" | wc -l)" -eq 8 ] for pkg in downgrade yay informant zrepl pacman-cleanup-hook \ - sanoid zfs-auto-snapshot topgrade ventoy-bin; do + zfs-auto-snapshot topgrade ventoy-bin; do [[ "$output" == *"$pkg"* ]] done } @@ -28,9 +28,12 @@ setup() { @test "aur_v1_packages excludes the vNext-deferred packages" { run aur_v1_packages [ "$status" -eq 0 ] - # paru (second helper) and mkinitcpio-firmware (AUR-of-AUR deps) are vNext + # paru (second helper), mkinitcpio-firmware, and sanoid all pull AUR-of-AUR + # deps (sanoid needs perl-config-inifiles, AUR-only) — deferred to the + # vNext helper-driven dependency-resolution work. [[ "$output" != *"paru"* ]] [[ "$output" != *"mkinitcpio-firmware"* ]] + [[ "$output" != *"sanoid"* ]] } @test "aur_v1_packages emits one package per line" { diff --git a/tests/unit/test_common.bats b/tests/unit/test_common.bats index 6f9d1b1..c76b6a4 100644 --- a/tests/unit/test_common.bats +++ b/tests/unit/test_common.bats @@ -666,6 +666,26 @@ Boot0001* ZFSBootMenu" rm -f "$f" } +@test "strip_repo_stanza preserves the target file mode (no 0600 clobber)" { + local f + f=$(mktemp) + printf '%s\n' '[core]' '[aur]' 'Server = file:///usr/share/aur-packages' '[extra]' > "$f" + chmod 644 "$f" + strip_repo_stanza aur "$f" + [ "$(stat -c %a "$f")" = "644" ] + rm -f "$f" +} + +@test "strip_repo_stanza preserves a non-default file mode" { + local f + f=$(mktemp) + printf '%s\n' '[core]' '[aur]' 'Server = x' '[extra]' > "$f" + chmod 640 "$f" + strip_repo_stanza aur "$f" + [ "$(stat -c %a "$f")" = "640" ] + rm -f "$f" +} + ############################# # aur_repo_available ############################# @@ -720,3 +740,53 @@ Boot0001* ZFSBootMenu" [ "$status" -eq 0 ] [ -z "$output" ] } + +############################# +# aur_zfs_only_packages / filter_aur_for_fs +############################# + +@test "aur_zfs_only_packages lists zfs-auto-snapshot and zrepl" { + run aur_zfs_only_packages + [ "$status" -eq 0 ] + [[ "$output" == *"zfs-auto-snapshot"* ]] + [[ "$output" == *"zrepl"* ]] +} + +@test "filter_aur_for_fs zfs keeps every package including zfs-only tooling" { + run filter_aur_for_fs zfs downgrade yay zrepl zfs-auto-snapshot topgrade + [ "$status" -eq 0 ] + [ "${#lines[@]}" -eq 5 ] + [[ "$output" == *"zfs-auto-snapshot"* ]] + [[ "$output" == *"zrepl"* ]] + [[ "$output" == *"yay"* ]] +} + +@test "filter_aur_for_fs btrfs drops zfs-only tooling, keeps the rest" { + run filter_aur_for_fs btrfs downgrade yay zrepl zfs-auto-snapshot topgrade + [ "$status" -eq 0 ] + [ "${#lines[@]}" -eq 3 ] + [[ "$output" != *"zfs-auto-snapshot"* ]] + [[ "$output" != *"zrepl"* ]] + [[ "$output" == *"downgrade"* ]] + [[ "$output" == *"yay"* ]] + [[ "$output" == *"topgrade"* ]] +} + +@test "filter_aur_for_fs btrfs with only zfs-only tooling prints nothing" { + run filter_aur_for_fs btrfs zfs-auto-snapshot zrepl + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "filter_aur_for_fs with no package arguments prints nothing" { + run filter_aur_for_fs btrfs + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "filter_aur_for_fs preserves input order" { + run filter_aur_for_fs zfs yay downgrade topgrade + [ "$status" -eq 0 ] + [ "${lines[0]}" = "yay" ] + [ "${lines[2]}" = "topgrade" ] +} diff --git a/tests/unit/test_config.bats b/tests/unit/test_config.bats index af23e4a..4169c5e 100644 --- a/tests/unit/test_config.bats +++ b/tests/unit/test_config.bats @@ -5,6 +5,8 @@ setup() { # shellcheck disable=SC1091 source "${BATS_TEST_DIRNAME}/../../installer/lib/common.sh" # shellcheck disable=SC1091 + source "${BATS_TEST_DIRNAME}/../../installer/lib/raid.sh" + # shellcheck disable=SC1091 source "${BATS_TEST_DIRNAME}/../../installer/lib/config.sh" } @@ -93,6 +95,62 @@ EOF [[ "$output" == *"4 error"* ]] } +@test "validate_config under set -e reports every error, not just the first" { + # Reproduces the monolith's call structure: `set -e` is active and + # validate_config is invoked as the final command of an && list. A + # post-increment that returns the pre-increment value (0 on the first + # error) trips set -e and aborts the function after one warning. This + # test runs outside bats' `run` shield (which sets +e) so the real + # accumulate-and-report behavior is exercised. + run bash -c ' + set -e + source "'"${BATS_TEST_DIRNAME}"'/../../installer/lib/common.sh" + source "'"${BATS_TEST_DIRNAME}"'/../../installer/lib/raid.sh" + source "'"${BATS_TEST_DIRNAME}"'/../../installer/lib/config.sh" + HOSTNAME=""; TIMEZONE=""; SELECTED_DISKS=(); ROOT_PASSWORD="" + UNATTENDED=true + [[ "$UNATTENDED" == true ]] && validate_config + ' + [ "$status" -eq 1 ] + [[ "$output" == *"HOSTNAME not set"* ]] + [[ "$output" == *"TIMEZONE not set"* ]] + [[ "$output" == *"No disks selected"* ]] + [[ "$output" == *"ROOT_PASSWORD not set"* ]] + [[ "$output" == *"4 error"* ]] +} + +@test "validate_config rejects a RAID_LEVEL invalid for the disk count" { + HOSTNAME=h + TIMEZONE=UTC + ROOT_PASSWORD=x + SELECTED_DISKS=(/dev/sda /dev/sdb) + RAID_LEVEL=raidz1 + run validate_config + [ "$status" -eq 1 ] + [[ "$output" == *"Invalid RAID_LEVEL"* ]] + [[ "$output" == *"raidz1"* ]] +} + +@test "validate_config accepts a RAID_LEVEL valid for the disk count" { + HOSTNAME=h + TIMEZONE=UTC + ROOT_PASSWORD=x + SELECTED_DISKS=(/dev/sda /dev/sdb /dev/sdc) + RAID_LEVEL=raidz1 + run validate_config + [[ "$output" != *"Invalid RAID_LEVEL"* ]] +} + +@test "validate_config accepts an empty RAID_LEVEL for a single disk" { + HOSTNAME=h + TIMEZONE=UTC + ROOT_PASSWORD=x + SELECTED_DISKS=(/dev/sda) + RAID_LEVEL="" + run validate_config + [[ "$output" != *"Invalid RAID_LEVEL"* ]] +} + @test "validate_config rejects an invalid timezone" { HOSTNAME="h" TIMEZONE="Not/A_Real_Zone_xyz" diff --git a/tests/unit/test_test_install.bats b/tests/unit/test_test_install.bats index f339baf..bf43dd8 100644 --- a/tests/unit/test_test_install.bats +++ b/tests/unit/test_test_install.bats @@ -300,3 +300,53 @@ error: failed to commit transaction (invalid or corrupted package (checksum)) run is_archzfs_cache_corruption "" [ "$status" -eq 1 ] } + +############################# +# INSTALLED_PASSWORD scoping +############################# +# After the reboot step, run_test switches ssh_cmd over to the installed +# system's root password. That value must not outlive the test. When it leaks +# into the next scenario, ssh_cmd presents the installed password to the *live +# ISO* — whose password is different — so every SSH call fails instantly and +# the install dies with no output and no package requests. +# +# That is exactly what happened on 2026-08-01: the reset was a single `unset` +# on the success path, three failure paths returned early past it, and one +# flaky check cascaded into six silent ZFS install failures. The fix declares +# it `local` in run_test so bash clears it on every return path. + +@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. + sshpass() { echo "$2"; } + ssh() { :; } + caller_with_local() { + local INSTALLED_PASSWORD="installed-secret" + ssh_cmd true + } + run caller_with_local + [[ "$output" == *"installed-secret"* ]] +} + +@test "a caller-scoped INSTALLED_PASSWORD does not leak past a failed return" { + sshpass() { echo "$2"; } + ssh() { :; } + SSH_PASSWORD="live-iso-password" + failing_caller() { + local INSTALLED_PASSWORD="installed-secret" + return 1 + } + failing_caller || true + run ssh_cmd true + [[ "$output" == *"live-iso-password"* ]] + [[ "$output" != *"installed-secret"* ]] +} + +@test "run_test declares INSTALLED_PASSWORD local and never exports it" { + # Structural guard: run_test itself drives qemu and ssh, so this file + # can't exercise it directly. An export here would silently restore the + # cascade, so pin the shape that prevents it. + local src="${BATS_TEST_DIRNAME}/../../scripts/test-install.sh" + grep -qE '^[[:space:]]*local INSTALLED_PASSWORD=' "$src" + ! grep -qE '^[[:space:]]*export INSTALLED_PASSWORD' "$src" +} |
