diff --git a/README.md b/README.md index 1ec23ea0f..b7edd96de 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Omarchy ISO -The Omarchy ISO is the only supported way to install Omarchy. It ships the Omarchy Configurator, installs Arch Linux, installs the Omarchy packages from the bundled mirror, runs target system setup in the chroot, creates the user, and runs `omarchy-setup-user` for that user. +The Omarchy ISO is the only supported way to install Omarchy. It ships the Omarchy Configurator, unpacks a pre-built Arch Linux + Omarchy root image onto the target with `btrfs receive`, installs the per-machine packages (kernel, microcode, audio firmware) from the bundled mirror, runs target system setup in the chroot, creates the user, and runs `omarchy-setup-user` for that user. ## Downloading the latest ISO @@ -130,6 +130,8 @@ Scenarios under `test/integration.d/` boot a real ISO install in QEMU and assert 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. +`corrupt-image` proves a bad install medium is refused before the disk is touched. It copies the ISO and flips one byte a megabyte into the root image stream itself — addressed from the extent xorriso reports out of the ISO9660 directory records, so the damage lands in the shipped bytes and nowhere else — then autoinstalls from that copy: `omarchy-root-image-verify.service` (which hashes the image at boot, while a user would be in the configurator) must fail, the install must halt in its pre-flight phase with the "re-flash" advice on screen, and the target disk must still have no partition table. It boots the ISO itself rather than the base image, so it also works standalone with `OMARCHY_INTEGRATION_ISO` set. + Artifacts — screenshots, the fixtured/staged/final `limine.conf`, the reset typescript, and the factory-reset log — land under `test-runs/-integration/runs/-/`, and `--no-preview` skips the `imv` review just like the acceptance harness. ## Signing the ISO diff --git a/bin/lib/netboot.sh b/bin/lib/netboot.sh new file mode 100644 index 000000000..43b921d6c --- /dev/null +++ b/bin/lib/netboot.sh @@ -0,0 +1,53 @@ +# Shared netboot rig: the TFTP root and NBD export that fake a PXE LAN +# inside QEMU's user-mode networking. Sourced by test/integration.d/ +# pxe-test.sh (the scenario) and bin/omarchy-iso-boot-pxe (its hands-on +# twin) so the two can never drift apart — what the test proves is exactly +# what the tool boots. + +NBD_PORT=10809 # not configurable: archiso_nbd_srv carries an address only +NBD_PID="" + +# The ISO's own boot trees plus the one-line PXELINUX config entry point +# every real deployment writes: PXELINUX looks for pxelinux.cfg/default +# next to lpxelinux.0, and the INCLUDE routes it into the ISO's own config, +# whose whichsys.c32 line detects PXELINUX and switches to the PXE menu. +# Only the boot trees are extracted — the airootfs and the root image stay +# on the ISO and travel over NBD. Rebuilt when the ISO changes, keyed on +# size+mtime: a pathname key would keep serving a previous build's kernel +# and initramfs after a rebuild to the same release/*.iso name. +assemble_tftp_root() { + local iso="$1" root="$2" stamp + stamp="$iso $(stat -c '%s %Y' "$iso")" + if [[ -f "$root/.from-iso" && $(cat "$root/.from-iso") == "$stamp" ]]; then + return 0 + fi + rm -rf "$root" + mkdir -p "$root" + bsdtar -xf "$iso" -C "$root" boot arch/boot + # The ISO's read-only modes would block the glue file below. + chmod -R u+w "$root" + mkdir -p "$root/boot/syslinux/pxelinux.cfg" + echo "INCLUDE syslinux.cfg" >"$root/boot/syslinux/pxelinux.cfg/default" + echo "$stamp" >"$root/.from-iso" +} + +# Serve the ISO as the NBD export the initramfs dials back to: name +# 'archiso' on the well-known port, bound to the host loopback — which is +# where the guest's 10.0.2.2 (and therefore the hook's ${pxeserver}) lands. +# Sets NBD_PID; the caller's exit trap owns the kill. +serve_nbd() { + local iso="$1" + if [[ -n $(ss -Htln "sport = :$NBD_PORT") ]]; then + echo "Port $NBD_PORT is already taken (the port nbd-client dials is fixed); stop whatever serves it first." >&2 + return 1 + fi + qemu-nbd --read-only --persistent --export-name=archiso \ + --bind=127.0.0.1 --port="$NBD_PORT" "$iso" & + NBD_PID=$! + sleep 1 + if ! kill -0 "$NBD_PID" 2>/dev/null; then + NBD_PID="" + echo "qemu-nbd could not serve 127.0.0.1:$NBD_PORT." >&2 + return 1 + fi +} diff --git a/bin/omarchy-iso-boot-pxe b/bin/omarchy-iso-boot-pxe new file mode 100755 index 000000000..884a6c10c --- /dev/null +++ b/bin/omarchy-iso-boot-pxe @@ -0,0 +1,139 @@ +#!/bin/bash +# +# Boot an Omarchy ISO over PXE in a visible QEMU VM: real display, real +# keyboard, no install medium attached. The LAN is faked the same way +# test/integration.d/pxe-test.sh does it — QEMU's built-in TFTP server hands +# iPXE lpxelinux.0 and the boot files out of the ISO's own trees, and +# qemu-nbd serves the ISO as the NBD export the initramfs dials back to (the +# guest reaches the host at 10.0.2.2). BIOS/SeaBIOS only: that is the ISO's +# netboot path. Drive the install by hand; after "Reboot Now" the VM boots +# the installed disk (the NBD server outlives the reboot). +# +# The PXE menu has no auto-boot timeout — press Enter on the NBD entry. + +set -e + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/lib/netboot.sh" + +ISO="" +REUSE="" +SSH_PORT="${OMARCHY_VM_SSH_PORT:-2222}" +WORK="${OMARCHY_VM_PXE_DIR:-/tmp/omarchy-pxe-boot}" +EXTRA_QEMU_ARGS=() + +usage() { + cat < 0)); do + case "$1" in + --reuse | reuse) + REUSE="reuse" + shift + ;; + --ssh-port) + SSH_PORT="${2:-}" + shift 2 + ;; + --work) + WORK="${2:-}" + shift 2 + ;; + -h | --help) + usage + exit 0 + ;; + --) + shift + EXTRA_QEMU_ARGS+=("$@") + break + ;; + *) + if [[ -z $ISO ]]; then + ISO="$1" + else + echo "Unexpected argument: $1" >&2 + usage >&2 + exit 1 + fi + shift + ;; + esac +done + +if [[ -z $ISO && -t 0 ]]; then + ISO=$(ls -t release/*.iso 2>/dev/null | gum choose --header "Select ISO to netboot") + if gum confirm --default=false "Reuse existing disk?"; then + REUSE="reuse" + fi +fi + +if [[ ! -f ${ISO:-} ]]; then + usage >&2 + exit 1 +fi +ISO=$(realpath "$ISO") + +omarchy-pkg-add qemu-full + +# QEMU needs the desktop session for its SDL window (same dance as +# omarchy-iso-boot). +if [[ -z ${WAYLAND_DISPLAY:-} && -z ${DISPLAY:-} ]]; then + while IFS='=' read -r name value; do + case "$name" in + WAYLAND_DISPLAY | DISPLAY | XAUTHORITY | XDG_SESSION_TYPE) + export "$name=$value" + ;; + esac + done < <(systemctl --user show-environment 2>/dev/null || true) +fi + +if [[ -z ${WAYLAND_DISPLAY:-} && -z ${DISPLAY:-} ]]; then + echo "Unable to find a graphical session for QEMU." >&2 + echo "Run omarchy-iso-boot-pxe from the Omarchy desktop session." >&2 + exit 1 +fi + +TFTP_ROOT="$WORK/tftp" +DISK="$WORK/disk.qcow2" +mkdir -p "$WORK" + +echo "Assembling the TFTP root from $ISO (cached until the ISO changes)" +assemble_tftp_root "$ISO" "$TFTP_ROOT" + +echo "Serving the ISO as NBD export 'archiso' on 127.0.0.1:$NBD_PORT" +serve_nbd "$ISO" +trap 'kill $NBD_PID 2>/dev/null || true' EXIT + +if [[ ! -f $DISK || $REUSE != "reuse" ]]; then + qemu-img create -f qcow2 "$DISK" 40G +fi + +echo "SSH forwarding enabled: ssh -p $SSH_PORT @localhost" + +# SeaBIOS, no pflash: BIOS is the ISO's only netboot path. The empty disk is +# first in the boot order so the post-install reboot lands on it; SeaBIOS +# falls through to the NIC while the disk has no boot sector. +qemu-system-x86_64 \ + -cpu host -enable-kvm -machine q35,accel=kvm \ + -smp "$(($(nproc) < 8 ? $(nproc) : 8))" \ + -m 8192 \ + -drive file="$DISK",format=qcow2,if=none,id=drive0 \ + -device virtio-blk-pci,drive=drive0,bootindex=1 \ + -device virtio-vga-gl \ + -display sdl,gl=on \ + -usb -device usb-tablet \ + -serial none \ + -netdev "user,id=net0,hostfwd=tcp:127.0.0.1:$SSH_PORT-:22,tftp=$TFTP_ROOT,bootfile=/boot/syslinux/lpxelinux.0" \ + -device virtio-net-pci,netdev=net0,bootindex=2 \ + -boot menu=on \ + "${EXTRA_QEMU_ARGS[@]}" diff --git a/bin/omarchy-iso-test b/bin/omarchy-iso-test index 8a0677f1d..75f389f37 100755 --- a/bin/omarchy-iso-test +++ b/bin/omarchy-iso-test @@ -16,6 +16,9 @@ # --encrypt Drive the encrypted install flow (default: unencrypted) # --provision Drive the deferred-provisioning install flow: no user during install, the # first boot runs omarchy-provision-owner and creates it there +# --free-space Install into the free space of a synthetic Windows-style +# disk (existing ESP + data partition kept) and verify the +# existing partitions survive # --reuse-base Skip the install phase if a base image already exists # --install-only Stop after producing the installed base image # --sync-omarchy DIR Push DIR's test suite into the guest before running @@ -40,6 +43,7 @@ INSTALL_TIMEOUT=2400 BOOT_TIMEOUT=600 ENCRYPT=false PROVISION=false +FREE_SPACE=false REUSE_BASE=false INSTALL_ONLY=false KEEP_RUNNING=false @@ -55,6 +59,7 @@ while (($#)); do case "$1" in --encrypt) ENCRYPT=true ;; --provision) PROVISION=true ;; + --free-space) FREE_SPACE=true ;; --reuse-base) REUSE_BASE=true ;; --install-only) INSTALL_ONLY=true ;; --keep-running) KEEP_RUNNING=true ;; @@ -92,6 +97,7 @@ OVMF_VARS_TEMPLATE="/usr/share/edk2/x64/OVMF_VARS.4m.fd" BASE_NAME="$(basename "$ISO" .iso)" $ENCRYPT && BASE_NAME+="-encrypted" $PROVISION && BASE_NAME+="-provision" +$FREE_SPACE && BASE_NAME+="-free-space" BASE_DIR="$REPO_DIR/test-runs/$BASE_NAME" RUN_DIR="$BASE_DIR/runs/$(date +%Y%m%d-%H%M%S)" BASE_DISK="$BASE_DIR/base.qcow2" @@ -696,6 +702,26 @@ drive_configurator() { capture_console "success-installer-10-install-disk" press ret # the single virtio disk + if $FREE_SPACE; then + # The picker only appears when the disk has usable free space on UEFI. + wait_for_screen "installation mode" 60 + capture_console "success-installer-11-install-mode" + press down # "Free space install (alongside existing data)" + press ret + # BitLocker/ESP checks and the free-space analysis run, then the confirm. + wait_for_screen "of free space" 120 + capture_console "success-installer-12-free-space-confirm-encrypted" + if ! $ENCRYPT; then + press ctrl-c # toggles the confirm to the unencrypted flow + # Same highlighted button OCR cannot read as the full-disk confirm below. + sleep 2 + capture_console "success-installer-13-free-space-confirm-unencrypted" + fi + press ret + sleep 2 + capture_console "success-installer-14-install-started" + return 0 + fi wait_for_screen "installation mode\|recovery possible" 60 if ocr_screen | grep -qi "installation mode"; then capture_console "success-installer-11-install-mode" @@ -981,13 +1007,71 @@ establish_session() { # -------------------------------------------------------------------- phases +# A synthetic Windows-style disk for the free-space install: a FAT32 ESP and +# an ext4 data partition carrying a marker file, followed by ~76GiB of +# unallocated space. Built without root: parted partitions the raw file, the +# filesystems are made straight into it at their offsets. Unlike +# omarchy-iso-test-windows-disk it has no EFI/Microsoft directory (that needs +# mtools or a mount), so the configurator reports no Windows ESP; the +# free-space mechanics it exercises are the same either way. +create_free_space_disk() { + local disk="$1" raw="$1.raw" populate="$RUN_DIR/.preserve" + local esp_start_mib=1 esp_size_mib=512 data_end_mib=$((20 * 1024)) + + rm -f "$disk" "$raw" + truncate -s 96G "$raw" + parted --script "$raw" \ + mklabel gpt \ + mkpart WINDOWS_ESP fat32 "${esp_start_mib}MiB" "$((esp_start_mib + esp_size_mib))MiB" \ + set 1 esp on \ + mkpart WINDOWS_DATA ext4 "$((esp_start_mib + esp_size_mib))MiB" "${data_end_mib}MiB" + + mkfs.fat -F32 -n WINDOWS_ESP -C "$RUN_DIR/.esp.img" $((esp_size_mib * 1024)) >/dev/null + dd if="$RUN_DIR/.esp.img" of="$raw" bs=1M seek="$esp_start_mib" conv=notrunc,sparse status=none + rm -f "$RUN_DIR/.esp.img" + + rm -rf "$populate" + mkdir -p "$populate" + echo "This file must survive the Omarchy install." >"$populate/OMARCHY-MUST-NOT-TOUCH.txt" + mkfs.ext4 -q -F -L WINDOWS_DATA -d "$populate" \ + -E offset=$(( (esp_start_mib + esp_size_mib) * 1024 * 1024 )) \ + "$raw" "$(( (data_end_mib - esp_start_mib - esp_size_mib) * 1024 ))k" + rm -rf "$populate" + + qemu-img convert -f raw -O qcow2 "$raw" "$disk" + rm -f "$raw" +} + +# The partitions the free-space install had to leave alone, checked from +# inside the installed system. +verify_preserved_partitions() { + log "Verifying the pre-existing partitions survived" + local report="$RUN_DIR/preserved-partitions.txt" + + ssh_guest "lsblk -o NAME,SIZE,FSTYPE,LABEL,MOUNTPOINTS" >"$report" 2>&1 || true + if ! ssh_guest "echo $GUEST_PASSWORD | sudo -S -p '' sh -c ' + blkid -L WINDOWS_ESP >/dev/null && + mkdir -p /run/preserve && mount -o ro LABEL=WINDOWS_DATA /run/preserve && + test -f /run/preserve/OMARCHY-MUST-NOT-TOUCH.txt; rc=\$?; umount /run/preserve 2>/dev/null; exit \$rc'"; then + capture_console "failure-free-space-partitions-lost" + echo "Pre-existing partitions were not preserved; see $report" >&2 + return 1 + fi + log "WINDOWS_ESP and WINDOWS_DATA (with its marker file) are intact" +} + install_phase() { log "Installing $(basename "$ISO") into a fresh VM (headless, driven via console)" [[ -f $SSH_KEY ]] || ssh-keygen -t ed25519 -N "" -q -C "omarchy-iso-test" -f "$SSH_KEY" - rm -f "$BASE_DISK" - qemu-img create -f qcow2 "$BASE_DISK" 40G >/dev/null + if $FREE_SPACE; then + log "Building the synthetic Windows-style disk" + create_free_space_disk "$BASE_DISK" + else + rm -f "$BASE_DISK" + qemu-img create -f qcow2 "$BASE_DISK" 40G >/dev/null + fi cp "$OVMF_VARS_TEMPLATE" "$BASE_OVMF" start_vm "$BASE_DISK" "$RUN_DIR/install-serial.log" \ @@ -1003,6 +1087,7 @@ install_phase() { unlock_luks fi bootstrap_ssh + $FREE_SPACE && verify_preserved_partitions # SSH can come up (console bootstrap logs in on a spare TTY) while # omarchy-provision-owner is still finalizing the user and re-keying LUKS. Saving diff --git a/builder/build-iso.sh b/builder/build-iso.sh index db8daa2c8..d7bdaa714 100755 --- a/builder/build-iso.sh +++ b/builder/build-iso.sh @@ -30,7 +30,7 @@ pacman --noconfirm -Sy archlinux-keyring # so this container can be months behind the mirror it installs from. A plain # -Sy install is then a partial upgrade — new packages linked against a glibc # the container doesn't have yet. -pacman --noconfirm -Syu archiso git sudo base-devel jq grub imagemagick neovim nodejs npm tree-sitter-cli +pacman --noconfirm -Syu archiso git sudo base-devel jq grub imagemagick neovim nodejs npm tree-sitter-cli btrfs-progs # Pre-import the omarchy signing key (so pacman trusts our [omarchy] repo # during the build without keyserver lookups). @@ -102,6 +102,14 @@ fi if [[ -d /omarchy-source && -d /omarchy-pkgs ]]; then bash /builder/build-omarchy-packages.sh "$offline_mirror_dir" LOCAL_OMARCHY_BUILD=1 + # A rebuild of the same revision produces the same filename with different + # bytes. pacman consults /var/cache/pacman/pkg (the host's cache, kept across + # --keep-pkg-cache builds) before the mirror, and a stale copy from an + # earlier build fails the checksum against the mirror's fresh db. + for local_package_name in \ + "$OMARCHY_RUNTIME_PACKAGE" "$OMARCHY_SETTINGS_PACKAGE" "$OMARCHY_NVIM_PACKAGE"; do + rm -f /var/cache/pacman/pkg/"$local_package_name"-*.pkg.tar.zst{,.sig} + done fi # Node.js binary for offline mise install. @@ -194,6 +202,9 @@ mapfile -t all_packages < <( cat "$build_cache_dir/packages.x86_64" grep -hv '^#\|^$' "${base_pkg_lists[@]}" grep -hv '^#\|^$' /builder/archinstall.packages + # The root image is pacstrapped from the pruned mirror, so its packages + # must be in the keep-set even when nothing in the lists depends on them. + grep -hv '^#\|^$' /builder/image.packages # Always include the selected Omarchy packages so the target install can # find the runtime and companion packages in the offline mirror. printf '%s\n' "$OMARCHY_RUNTIME_PACKAGE" "$OMARCHY_SETTINGS_PACKAGE" "$OMARCHY_NVIM_PACKAGE" @@ -228,10 +239,23 @@ if ! download_offline_packages; then download_offline_packages fi +# mkarchiso expects the mirror at /var/cache/omarchy/mirror/offline inside the +# container (the airootfs path), and the root image is pacstrapped through the +# same path; symlink rather than duplicate. +mkdir -p /var/cache/omarchy/mirror +ln -sfn "$offline_mirror_dir" /var/cache/omarchy/mirror/offline + +rebuild_offline_repo_db() { + rm -f "$offline_mirror_dir"/offline.db* "$offline_mirror_dir"/offline.files* + repo-add -q "$offline_mirror_dir/offline.db.tar.gz" "$offline_mirror_dir/"*.pkg.tar.zst +} + # Resolve the exact filenames chosen by the same synced package databases used # for the download. Pruning by this transaction (rather than merely keeping the # newest version of every cached package name) removes packages that have left -# the lists or dependency closure, such as an old Electron major version. +# the lists or dependency closure, such as an old Electron major version. This +# is the build cache: it keeps the whole closure so the next build downloads +# nothing; what the ISO ships is decided below. if ! resolved_package_files="$( pacman --config "/configs/pacman-online-${OMARCHY_MIRROR}.conf" --noconfirm \ --dbpath /tmp/offlinedb -S --print --print-format '%f' "${all_packages[@]}" @@ -244,6 +268,9 @@ mapfile -t required_package_files <<< "$resolved_package_files" # The online transaction intentionally excludes packages built from the local # checkouts. Add those exact artifacts back to the keep-set after verifying # that the local build left exactly one file for each selected package name. +# The settings package is a live ISO package; the runtime and nvim packages +# are only in the image now, but keeping all three in the mirror leaves +# omarchy-apply-system able to resolve them like any published build. if [[ -n ${LOCAL_OMARCHY_BUILD:-} ]]; then for local_package_name in \ "$OMARCHY_RUNTIME_PACKAGE" "$OMARCHY_SETTINGS_PACKAGE" "$OMARCHY_NVIM_PACKAGE"; do @@ -269,45 +296,135 @@ fi printf '%s\n' "${required_package_files[@]}" | bash /builder/prune-offline-mirror.sh "$offline_mirror_dir" -# Rebuild the offline repo db from scratch so size/checksum/depends entries -# always reflect only the package files selected for this build. -rm -f "$offline_mirror_dir"/offline.db* "$offline_mirror_dir"/offline.files* -repo-add "$offline_mirror_dir/offline.db.tar.gz" "$offline_mirror_dir/"*.pkg.tar.zst - -# mkarchiso expects the mirror at /var/cache/omarchy/mirror/offline inside the -# container (the airootfs path); symlink rather than duplicate. -mkdir -p /var/cache/omarchy/mirror -ln -sf "$offline_mirror_dir" /var/cache/omarchy/mirror/offline +# Index the mirror only now, from scratch, so size/checksum/depends entries +# reflect only the package files selected for this build. The cache persists +# across builds and can hold several versions of a package; repo-add keeps +# whichever it processes last (a warning on downgrade, hidden by -q), and the +# glob orders by name, not version. Indexing the unpruned cache could build the +# root image from an older package than the mirror beside it advertises. +rebuild_offline_repo_db + +# Packages that differ per machine and therefore stay out of the root image: +# the installer pacstraps them on the target after unpacking the image, and +# omarchy-apply-system installs more from omarchy-other.packages as the +# hardware dictates. Everything else the target gets is in the image. +hardware_packages=(linux linux-t2 amd-ucode intel-ucode sof-firmware alsa-firmware tailscale) + +mapfile -t image_packages < <( + { + grep -hv '^#\|^$' /builder/archinstall.packages + grep -hv '^#\|^$' /builder/image.packages + # The shipped copy, which is what the install reads too. + grep -hv '^#\|^$' "$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-base.packages" + printf '%s\n' "$OMARCHY_RUNTIME_PACKAGE" "$OMARCHY_SETTINGS_PACKAGE" "$OMARCHY_NVIM_PACKAGE" + } | sort -u | grep -Fxv "${hardware_packages[@]/#/-e}" +) -# Denominator for the install dashboard's progress bar. Resolving the mirror's -# own package lists against the mirror we just indexed, with an empty local db, -# is the question pacstrap asks at install time — same resolver, same repo, same -# lists — so no hand-kept constant can drift. +# pacstrap resolves the image against the offline repo and, with CacheDir on +# the mirror itself, extracts the package files in place instead of copying +# several GiB into a cache first (the same trick the installer's bind mount +# plays on the target). +image_pacman_conf="$build_cache_dir/pacman-root-image.conf" +sed "/^\[options\]/a CacheDir = /var/cache/omarchy/mirror/offline/" \ + "$build_cache_dir/pacman-offline.conf" >"$image_pacman_conf" + +# The stream ships as a plain file in the ISO9660 tree next to airootfs.sfs, +# not inside the squashfs: mkarchiso packs its work/iso directory as is, so a +# file seeded there ends up on the ISO with the boot records intact. Read +# straight off the boot medium it skips squashfs's per-block copy (~10% off +# the unpack), mkarchiso no longer copies 3GB into the squashfs, and the +# image can be pulled out of the ISO with any ISO9660 tool. The live system +# finds it at /run/archiso/bootmnt///. # -# It over-counts by ~1 in 925: archinstall.packages lists both amd-ucode and -# intel-ucode because the mirror must contain either. phases.py records expected -# and actual in the timing JSON, so growing drift shows up in acceptance runs. -# The early-bootstrap set is already inside this closure, so restating it would -# only add a second list to drift. +# profiledef.sh cannot be sourced standalone (its file_permissions needs +# mkarchiso's declare -A first); read the two values it sets instead. +iso_subdir="$(sed -n 's/^install_dir="\(.*\)"$/\1/p' "$build_cache_dir/profiledef.sh")/$(sed -n 's/^arch="\(.*\)"$/\1/p' "$build_cache_dir/profiledef.sh")" +[[ $iso_subdir == */x86_64 ]] || { echo "ERROR: could not read install_dir/arch from profiledef.sh: '$iso_subdir'" >&2; exit 1; } +root_image_dir="$build_cache_dir/work/iso/$iso_subdir" +root_image_stream="$root_image_dir/omarchy-root.btrfs.zst" +mkdir -p "$root_image_dir" +# Builds before the move left the stream (and, briefly, its checksum) in the +# persistent build cache, where they would ship inside the squashfs alongside +# the new one. +rm -f "$build_cache_dir/airootfs/var/cache/omarchy/rootfs/omarchy-root.btrfs"* + +image_localdb=/tmp/omarchy-root-image-localdb +echo "[timing] root image start $(date +%s)" +OMARCHY_IMAGE_LOCALDB_COPY="$image_localdb" \ + bash /builder/build-root-image.sh "$image_pacman_conf" "$root_image_stream" "${image_packages[@]}" +echo "[timing] root image end $(date +%s)" + +# The installer verifies the stream against this before it touches the disk +# (orchestrator prepare_install_target), so a truncated copy on a badly +# flashed USB fails the install while it is still free to fail. Next to the +# stream, so it ships on the ISO beside it. +(cd "$root_image_dir" && sha256sum "${root_image_stream##*/}" >"$root_image_stream.sha256") + +# Bound the boot-time hash: Type=oneshot units are exempt from systemd's +# default start timeout, so a stick that stalls reads instead of returning an +# error would hang omarchy-root-image-verify.service -- and with it the +# install -- forever. Budget a floor of 2 MiB/s over the image size (a USB 2.0 +# port sustains ~30 MB/s; the margin covers the idle-class hash yielding to +# the live system's page-ins) plus ten minutes of slack for boot. On timeout +# systemd sets Result=timeout and omarchy-wait-root-image-verify turns that +# into a "medium too slow" message instead of the corrupt-medium one. +root_image_bytes=$(stat -c %s "$root_image_stream") +verify_dropin_dir="$build_cache_dir/airootfs/etc/systemd/system/omarchy-root-image-verify.service.d" +mkdir -p "$verify_dropin_dir" +cat >"$verify_dropin_dir/50-size-timeout.conf" <"$shipped_list" +# grep -c exits 1 on no match; the count check below wants the 0. +shipped_count=$(grep -c . "$shipped_list" || true) +mirror_count=$(mirror_package_index | wc -l) +if (( shipped_count == 0 || shipped_count >= mirror_count )); then + echo "ERROR: the shipped-mirror selection looks wrong: $shipped_count of $mirror_count packages" >&2 + exit 1 +fi +echo "Shipping $shipped_count of $mirror_count mirror packages; the root image provides the rest." + +# Denominator for the install dashboard's progress bar: what the image holds +# plus the kernel's closure on top of it, resolved against the image's own +# local db with the pruned mirror, which is the question the installer's delta +# pacstrap asks. Plus one for the microcode package every real machine gets. +# phases.py records expected and actual in the timing JSON, so drift shows up +# in acceptance runs. resolve_expected_packages() { local resolve_root=/tmp/omarchy-expected-packages - local resolved - local -a targets + local image_count resolved rm -rf "$resolve_root" mkdir -p "$resolve_root/var/lib/pacman" - - mapfile -t targets < <( - { - grep -hv '^#\|^$' /builder/archinstall.packages - # Read the shipped copy, which is what _runtime_package_list reads at - # install time, not the build-time source it came from. - grep -hv '^#\|^$' \ - "$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-base.packages" - printf '%s\n' "$OMARCHY_RUNTIME_PACKAGE" "$OMARCHY_SETTINGS_PACKAGE" \ - "$OMARCHY_NVIM_PACKAGE" - } | sort -u - ) + cp -a "$image_localdb/local" "$resolve_root/var/lib/pacman/" + image_count=$(find "$resolve_root/var/lib/pacman/local" -mindepth 1 -maxdepth 1 -type d | wc -l) pacman --config "$build_cache_dir/pacman-offline.conf" \ --root "$resolve_root" --dbpath "$resolve_root/var/lib/pacman" \ @@ -318,36 +435,41 @@ resolve_expected_packages() { # dashboard's fallback. resolved="$(pacman --config "$build_cache_dir/pacman-offline.conf" \ --root "$resolve_root" --dbpath "$resolve_root/var/lib/pacman" \ - --noconfirm -S --print --print-format '%n' "${targets[@]}")" || return 1 + --noconfirm -S --print --print-format '%n' --needed linux)" || return 1 - printf '%s\n' "$resolved" | sort -u | grep -c . + echo $(( image_count + $(printf '%s\n' "$resolved" | sort -u | grep -c .) + 1 )) } -# Worth failing the build over: -S --print only aborts when a target is missing -# from the offline repo, which would fail pacstrap the same way. A count that -# merely looks wrong is not — the dashboard falls back without the file. +# Both failures are worth the build: -S --print only aborts when a target is +# missing from the offline repo, which would fail the delta pacstrap the same +# way, and the count is image packages plus the kernel closure, so one outside +# the plausible range means the root image itself is short of packages. (The +# dashboard falls back to a time-based curve without the file, but nothing +# else would catch a short image before an install.) if ! expected_packages="$(resolve_expected_packages)"; then echo "ERROR: could not resolve the target package count from the offline mirror." >&2 echo " pacman -S --print aborts the whole transaction if any single target" >&2 - echo " is missing, so this almost certainly means pacstrap would fail the" >&2 - echo " same way at install time." >&2 + echo " is missing, so this almost certainly means the installer's delta" >&2 + echo " pacstrap would fail the same way." >&2 exit 1 fi if (( expected_packages < 600 || expected_packages > 2000 )); then - echo "WARNING: resolved target package count $expected_packages is outside the" >&2 - echo " expected 600-2000 range; shipping no denominator so the install" >&2 - echo " dashboard falls back to its time-based curve." >&2 -else - printf '%s\n' "$expected_packages" \ - >"$build_cache_dir/airootfs/usr/share/omarchy-iso/expected-packages" - echo "Target install resolves to $expected_packages packages." + echo "ERROR: resolved target package count $expected_packages is outside the" >&2 + echo " expected 600-2000 range. The count is the image's package count" >&2 + echo " plus the kernel closure, so this means the root image is short." >&2 + exit 1 fi +printf '%s\n' "$expected_packages" \ + >"$build_cache_dir/airootfs/usr/share/omarchy-iso/expected-packages" +echo "Target install resolves to $expected_packages packages." # Live ISO uses the same offline pacman.conf. cp "$build_cache_dir/pacman-offline.conf" "$build_cache_dir/airootfs/etc/pacman.conf" # Build the ISO. +echo "[timing] mkarchiso start $(date +%s)" mkarchiso -v -w "$build_cache_dir/work/" -o /out/ "$build_cache_dir/" +echo "[timing] mkarchiso end $(date +%s)" # Match host UID/GID on output. if [[ -n $HOST_UID && -n $HOST_GID ]]; then diff --git a/builder/build-root-image.sh b/builder/build-root-image.sh new file mode 100755 index 000000000..9457bb3cb --- /dev/null +++ b/builder/build-root-image.sh @@ -0,0 +1,209 @@ +#!/bin/bash +# +# Build the root filesystem image the installer unpacks instead of pacstrapping +# every package on the target machine. +# +# The target install is the same ~940 packages for everyone; only the kernel, +# CPU microcode, audio firmware and Tailscale vary per machine. So pacstrap the +# invariant set once here, at build time, into a btrfs subvolume compressed at +# a high zstd level (see IMAGE_COMPRESS below — higher than the installer's own +# compress=zstd, since the level only costs build time), and ship it as a +# `btrfs send --compressed-data` stream. At install time `btrfs receive` writes +# the compressed extents straight to disk (no decompress/recompress), which +# measured at roughly half the time of extracting the same packages with pacman, +# independent of CPU count; the per-machine delta is a small pacstrap after. +# +# The stream itself then gets an outer zstd layer (STREAM_COMPRESS below). The +# per-extent compression above cannot see past a 128 KiB extent, so the send +# stream still carries its protocol framing uncompressed plus redundancy that +# only exists across extents; one whole-stream pass reclaims ~11% (measured on +# a 3.75 GB stream — and the level barely matters, the long-range window does). +# The installer decompresses it in the receive pipe, where zstd -d is orders of +# magnitude faster than any install medium; the extents inside still land on +# disk as they are. +# +# Usage: build-root-image.sh ... +# +# pacman.conf Config whose repositories resolve every package; its +# CacheDir must hold the package files so nothing is copied. +# output-stream Where to write the send stream. +# package... Packages to pacstrap into the image. +# +# Environment: +# OMARCHY_IMAGE_LOCALDB_COPY Directory to copy the image's pacman local db +# into, so the caller can resolve the per-machine +# delta against what the image already holds. +# OMARCHY_IMAGE_SIZE Sparse backing file size (default 24G). Only the +# written extents cost anything. +# +# Needs a privileged environment: loop devices and a btrfs mount. + +set -euo pipefail + +pacman_conf="${1:-}" +output="${2:-}" +shift 2 || true +packages=("$@") + +if [[ -z $pacman_conf || -z $output || ${#packages[@]} -eq 0 ]]; then + echo "Usage: build-root-image.sh ..." >&2 + exit 1 +fi +if [[ ! -r $pacman_conf ]]; then + echo "ERROR: pacman config not readable: $pacman_conf" >&2 + exit 1 +fi + +# Forced zstd at a higher level than the installer's own compress=zstd (level +# 3): btrfs's incompressibility heuristic declines a lot of data in this tree +# that zstd handles fine, and the level only costs build time. btrfs receive +# stores the extents as they arrive, so neither choice affects install speed; +# the installed system writes new data at its own mount option either way. +IMAGE_COMPRESS="compress-force=zstd:15" +# Outer layer over the whole send stream. Level 15 and the 128 MiB long-range +# window are each worth a few hundred MB/-tens of MB respectively over the +# defaults; beyond either lies ~0.3% for minutes of build time (level 19+) or +# a stream a stock `zstd -d` refuses (--long>27 exceeds the decoder's default +# window limit). -T0 is a pure win: ~25 s on a many-core builder, same bytes. +STREAM_COMPRESS=(zstd -q -15 --long=27 -T0) +# Name of the subvolume inside the stream. The orchestrator looks for this name +# after `btrfs receive` (phases_impl.ROOT_IMAGE_SUBVOLUME). +IMAGE_SUBVOLUME="omarchy-root" + +# Boot-image pacman hooks that must not run while the image is built: there is +# no kernel in the image and no ESP to deploy to. pacstrap reads hooks from the +# building system's /etc/pacman.d/hooks (pacman.conf(5): HookDir is absolute, +# the target root is not prepended), so mask them here, the same way the +# orchestrator masks the live ISO's around its pacstrap. Keep in step with +# DEFERRED_BOOT_HOOKS in +# configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py. +deferred_boot_hooks=( + 60-mkinitcpio-remove.hook + 60-limine-mkinitcpio-remove-pre.hook + 80-limine-efi-deploy.hook + 90-limine-mkinitcpio-remove-post.hook + 90-mkinitcpio-install.hook +) + +work=$(mktemp -d /tmp/omarchy-root-image.XXXXXX) +backing="$work/image.btrfs" +mnt="$work/mnt" +loop="" +masked_hooks=() + +# Anything that touches a keyring under the image (pacman-key in a scriptlet, +# say) starts a gpg-agent for that homedir and leaves it running; until it is +# gone the image cannot be unmounted. Nothing should, as no keyring exists in +# the image, but kill whatever did. +stop_image_gpg_agents() { + gpgconf --homedir "$mnt/$IMAGE_SUBVOLUME/etc/pacman.d/gnupg" --kill all 2>/dev/null || true + pkill -f "$mnt/$IMAGE_SUBVOLUME/etc/pacman.d/gnupg" 2>/dev/null || true +} + +cleanup() { + local status=$? + set +e + for hook in "${masked_hooks[@]}"; do + rm -f "/etc/pacman.d/hooks/$hook" + if [[ -e "/etc/pacman.d/hooks/$hook.omarchy-backup" ]]; then + mv -f "/etc/pacman.d/hooks/$hook.omarchy-backup" "/etc/pacman.d/hooks/$hook" + fi + done + if mountpoint -q "$mnt" 2>/dev/null; then + stop_image_gpg_agents + sleep 1 + if ! umount -R "$mnt"; then + echo "WARNING: $mnt is still mounted; leaving $work in place" >&2 + return $status + fi + fi + if [[ -n $loop ]]; then + losetup -d "$loop" || true + fi + rm -rf "$work" + return $status +} +trap cleanup EXIT + +mkdir -p /etc/pacman.d/hooks +for hook in "${deferred_boot_hooks[@]}"; do + path="/etc/pacman.d/hooks/$hook" + # Already masked: an earlier run died before its cleanup. Moving the mask + # over that run's backup would lose the real hook for good, so leave both + # for it to be restored by hand (the orchestrator's _is_devnull_symlink + # makes the same call). + if [[ -L $path && $(readlink "$path") == /dev/null ]]; then + echo "WARNING: $path is already masked; leaving it and any backup alone" >&2 + continue + fi + if [[ -e $path || -L $path ]]; then + mv -f "$path" "$path.omarchy-backup" + fi + ln -s /dev/null "$path" + masked_hooks+=("$hook") +done + +echo "Building root image with ${#packages[@]} packages" +truncate -s "${OMARCHY_IMAGE_SIZE:-24G}" "$backing" +# Docker fills the container's /dev once, at start. A loop device the kernel +# creates afterwards (loop-control hands one out on demand, loading the module +# on a host that had none) exists on the host but has no node in here, so make +# the nodes ourselves when they are missing. +[[ -e /dev/loop-control ]] || mknod /dev/loop-control c 10 237 +# losetup reports a device whose node is missing as "/dev/loopN (lost)". +loop=$(losetup --find | awk '{ print $1 }') +[[ $loop == /dev/loop[0-9]* ]] || { echo "ERROR: no free loop device: $loop" >&2; exit 1; } +[[ -b $loop ]] || mknod "$loop" b 7 "${loop#/dev/loop}" +losetup "$loop" "$backing" +mkfs.btrfs -q -L omarchy-root-image "$loop" +mkdir -p "$mnt" +mount -o "$IMAGE_COMPRESS" "$loop" "$mnt" +btrfs subvolume create "$mnt/$IMAGE_SUBVOLUME" >/dev/null +root="$mnt/$IMAGE_SUBVOLUME" + +# -c: packages come straight from the config's CacheDir (the offline mirror), +# so nothing is copied into the image's own cache first. +# -G: no pacman keyring in the image, neither the builder's copied in nor one +# initialised here (-K): its master key would be the same on every +# install, and a shared signing key must never be distributed. The +# installer initialises and populates a per-machine keyring on the target +# (phases_impl._init_target_keyring), as pacstrap -K plus the keyring +# packages' scriptlets did when the target was pacstrapped directly. +# -M: the builder's mirrorlist means nothing to an installed system; the +# installer writes the target's own. +pacstrap -C "$pacman_conf" -c -G -M "$root" "${packages[@]}" +stop_image_gpg_agents +# The keyring packages' scriptlets only populate an existing keyring, so with +# -G they have nothing to do; remove whatever a scriptlet may have seeded +# anyway rather than trust that. +rm -rf "$root/etc/pacman.d/gnupg" + +# Per-machine identity is the installer's job, never the image's: it runs +# systemd-machine-id-setup on the target after unpacking. Leave the id +# uninitialised rather than shipping one every install would share. +: >"$root/etc/machine-id" + +# /var/log is its own subvolume on installed systems and mounts over this +# directory, hiding anything left here. The orchestrator copies the image's +# pacman.log into that subvolume so the installed system keeps its history; +# everything else from the build is noise. +find "$root/var/log" -mindepth 1 ! -name pacman.log -delete + +# Nothing downloaded, nothing to keep; and no build-host resolv.conf. +rm -rf "$root/var/cache/pacman/pkg"/* "$root/etc/resolv.conf" + +if [[ -n ${OMARCHY_IMAGE_LOCALDB_COPY:-} ]]; then + rm -rf "$OMARCHY_IMAGE_LOCALDB_COPY" + mkdir -p "$OMARCHY_IMAGE_LOCALDB_COPY" + cp -a "$root/var/lib/pacman/local" "$OMARCHY_IMAGE_LOCALDB_COPY/" +fi + +installed=$(find "$root/var/lib/pacman/local" -mindepth 1 -maxdepth 1 -type d | wc -l) +echo "Root image holds $installed packages" + +sync +btrfs property set -ts "$root" ro true +rm -f "$output" +mkdir -p "$(dirname "$output")" +btrfs send -q --compressed-data "$root" | "${STREAM_COMPRESS[@]}" -o "$output" +echo "Root image stream: $(du -h "$output" | cut -f1) at $output" diff --git a/builder/image.packages b/builder/image.packages new file mode 100644 index 000000000..a9c11c1c4 --- /dev/null +++ b/builder/image.packages @@ -0,0 +1,33 @@ +# Packages the installer used to add on top of the package lists at install +# time, now baked into the root image by build-root-image.sh. Hardware-specific +# packages (kernels, microcode, audio firmware, Tailscale) stay out of the image +# and are installed per machine; see hardware_packages in build-iso.sh. + +# archinstall's base set (Installer.__packages__) and what it adds for a btrfs +# root with zram swap. +base +sudo +linux-firmware +mkinitcpio +btrfs-progs +dosfstools +zram-generator + +# archinstall's PipeWire selection (AudioApp.pipewire_packages). +pipewire +pipewire-alsa +pipewire-jack +pipewire-pulse +gst-plugin-pipewire +libpulse +wireplumber + +# The orchestrator's early bootstrap set: Limine, the keyring, and what seeds +# /etc/skel before useradd. +base-devel +git +limine +efibootmgr +omarchy-keyring +lua51 +luarocks diff --git a/configs/airootfs/etc/systemd/system/multi-user.target.wants/omarchy-root-image-verify.service b/configs/airootfs/etc/systemd/system/multi-user.target.wants/omarchy-root-image-verify.service new file mode 120000 index 000000000..3afc651ff --- /dev/null +++ b/configs/airootfs/etc/systemd/system/multi-user.target.wants/omarchy-root-image-verify.service @@ -0,0 +1 @@ +/etc/systemd/system/omarchy-root-image-verify.service \ No newline at end of file diff --git a/configs/airootfs/etc/systemd/system/omarchy-root-image-verify.service b/configs/airootfs/etc/systemd/system/omarchy-root-image-verify.service new file mode 100644 index 000000000..02e9da73e --- /dev/null +++ b/configs/airootfs/etc/systemd/system/omarchy-root-image-verify.service @@ -0,0 +1,29 @@ +[Unit] +Description=Verify the Omarchy root image on the install medium +# The root image and its checksum are plain files on the ISO, which the archiso +# initramfs mounts before pivoting, so they are there from the first unit on. +# Starting the hash here, rather than when the install begins, hides the read +# of a multi-GB stream behind the time the user spends in the configurator. +# This is the only verifier: the orchestrator's prepare_install_target phase +# collects the verdict, waits for the unit if it is still running, and fails +# the install if it could not run. +ConditionPathExists=/run/archiso/bootmnt/arch/x86_64/omarchy-root.btrfs.zst +ConditionPathExists=/run/archiso/bootmnt/arch/x86_64/omarchy-root.btrfs.zst.sha256 + +[Service] +Type=oneshot +RemainAfterExit=yes +# TimeoutStartSec comes from a build-time drop-in sized to the image +# (build-iso.sh writes 50-size-timeout.conf next to this unit): oneshot units +# have no default start timeout, and a medium that stalls reads instead of +# erroring would otherwise hang the hash, and the install waiting on it, +# forever. On timeout Result=timeout tells the wait helper to report a slow +# medium rather than a corrupt one. +WorkingDirectory=/run/archiso/bootmnt/arch/x86_64 +ExecStart=/usr/bin/sha256sum --check --strict omarchy-root.btrfs.zst.sha256 +# Background work: never compete with the console or anything interactive. +Nice=19 +IOSchedulingClass=idle + +[Install] +WantedBy=multi-user.target diff --git a/configs/airootfs/etc/udev/rules.d/71-omarchy-iso-boot-medium-bfq.rules b/configs/airootfs/etc/udev/rules.d/71-omarchy-iso-boot-medium-bfq.rules new file mode 100644 index 000000000..6c97b9efa --- /dev/null +++ b/configs/airootfs/etc/udev/rules.d/71-omarchy-iso-boot-medium-bfq.rules @@ -0,0 +1,20 @@ +# Put the live ISO's boot medium on the BFQ I/O scheduler. +# +# The live system pages its airootfs in lazily from the boot medium (copytoram +# is pinned off because the root image is streamed from the same medium), and +# omarchy-root-image-verify.service hashes that multi-GB image from boot with +# IOSchedulingClass=idle. Only BFQ implements I/O priority classes; under the +# default mq-deadline the hash's sequential reads compete as equals with every +# squashfs page-in, and on a USB 2.0 stick the boot crawls for the two minutes +# the hash takes. With BFQ the idle-class hash yields to the live system and +# fills the gaps instead. +# +# Matched wider than just the boot medium, on purpose (udev cannot know which +# disk we booted from): every USB whole disk, including a USB install target +# or a cidata drive; optical drives; and every mmcblk node, including internal +# eMMC and its boot0/rpmb partitions. The consequence is only the scheduler +# choice, and BFQ is a fine default on all of them. Internal SATA/NVMe keep +# their default. +# 71- so it runs after 60-persistent-storage.rules, which sets ID_BUS. +ACTION=="add|change", SUBSYSTEM=="block", ENV{DEVTYPE}=="disk", ENV{ID_BUS}=="usb", ATTR{queue/scheduler}="bfq" +ACTION=="add|change", SUBSYSTEM=="block", ENV{DEVTYPE}=="disk", KERNEL=="sr[0-9]*|mmcblk[0-9]*", ATTR{queue/scheduler}="bfq" diff --git a/configs/airootfs/root/.automated_script.sh b/configs/airootfs/root/.automated_script.sh index 16904f879..22b68fe84 100644 --- a/configs/airootfs/root/.automated_script.sh +++ b/configs/airootfs/root/.automated_script.sh @@ -57,12 +57,13 @@ if [[ ${OMARCHY_INSTALL_DEBUG:-} == "1" ]]; then echo "================================" fi -# Warm the page cache for the bundled packages while the user works through the -# wizard. The install reads ~3GB out of the offline mirror, and pacman consumes -# it at only ~33MB/s, so on media slower than that the install is read-bound and -# every byte cached here is a byte it never waits for. On faster media this costs -# nothing but otherwise-idle bandwidth: the medium is untouched while the user -# types, and the target disk it writes to later is a different device. +# Warm the page cache for the root image and bundled packages while the user +# works through the wizard. The install streams a multi-GB root image into +# btrfs receive and then pacstraps a few packages out of the offline mirror; on media +# slower than the unpack the install is read-bound and every byte cached here +# is a byte it never waits for. On faster media this costs nothing but +# otherwise-idle bandwidth: the medium is untouched while the user types, and +# the target disk it writes to later is a different device. # # Clean page cache only, so the kernel reclaims it under pressure instead of # OOMing, and a budget so small machines never evict what was just warmed. @@ -70,15 +71,36 @@ fi warm_offline_mirror() { local mirror=/var/cache/omarchy/mirror/offline local budget_kb spent_kb=0 size_kb path + # The orchestrator's ROOT_IMAGE_STREAM. + local image=/run/archiso/bootmnt/arch/x86_64/omarchy-root.btrfs.zst [[ ${OMARCHY_NO_PREFETCH:-} == 1 ]] && return 0 - [[ -d $mirror ]] || return 0 budget_kb=$(($(awk '/^MemAvailable:/ { print $2 }' /proc/meminfo) / 2)) ((budget_kb > 262144)) || return 0 - # Largest first: the install reads most of the mirror, so when the budget - # cannot cover all of it this still front-loads the bytes that dominate. + # omarchy-root-image-verify.service (started at boot) reads the whole image + # front to back as it hashes it, and two readers on one USB stick seek + # against each other: let it finish first. Its pass is the warm-up for the + # image too; the head read below is then a cache hit where the image fit, + # and re-warms the front where a small budget made the kernel drop it. + while [[ $(systemctl is-active omarchy-root-image-verify.service 2>/dev/null) == activating ]]; do + sleep 1 + done + + # The image first, and only as much of it as fits: btrfs receive reads it + # front to back, so the leading bytes are the ones worth having cached. + if [[ -f $image ]]; then + size_kb=$(du -k -- "$image" | cut -f1) + ((size_kb > budget_kb)) && size_kb=$budget_kb + head -c "$((size_kb * 1024))" -- "$image" >/dev/null 2>&1 || true + spent_kb=$size_kb + fi + + [[ -d $mirror ]] || return 0 + + # Then the mirror, largest first: when the budget cannot cover all of it + # this still front-loads the bytes that dominate. while read -r size_kb path; do ((spent_kb + size_kb > budget_kb)) && continue cat -- "$path" >/dev/null 2>&1 || true diff --git a/configs/airootfs/root/configurator b/configs/airootfs/root/configurator index 79238d860..9a8bce8f1 100644 --- a/configs/airootfs/root/configurator +++ b/configs/airootfs/root/configurator @@ -1083,6 +1083,23 @@ clear write_user_files if [[ $install_target == "free_space" ]]; then + # Refuse a corrupt or missing install medium before we partition or format + # anything. The full-disk path clears this in the orchestrator before + # archinstall formats; the free-space path formats here, in the configurator, + # so it runs the same gate first. omarchy-wait-root-image-verify collects the + # boot-time hasher's verdict, waiting for it if it is still running. (2>&1 + # 1>/dev/null captures stderr, where the failure message is, and drops the + # boot-medium/scheduler line on stdout — the orchestrator logs that later. + # OMARCHY_VERIFY_PROGRESS routes a live percent line straight to the + # terminal while the hash is still running, so a wait on a slow medium + # shows movement instead of a frozen step header.) + if [[ ${1:-} != "dry" ]]; then + step "Verifying the install medium" + if ! verify_error=$(OMARCHY_VERIFY_PROGRESS=/dev/tty omarchy-wait-root-image-verify 2>&1 1>/dev/null); then + abort "$(head -1 <<<"$verify_error")" + fi + fi + run_partition_execute "$@" if [[ ${1:-} == "dry" ]]; then diff --git a/configs/airootfs/root/customize_airootfs.sh b/configs/airootfs/root/customize_airootfs.sh new file mode 100755 index 000000000..2af20d6a3 --- /dev/null +++ b/configs/airootfs/root/customize_airootfs.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# +# Runs inside the live root while mkarchiso builds it, after the live packages +# were pacstrapped from the bundled mirror. Drop the package files the root +# image already provides (build-iso.sh lists what to keep); nothing at install +# time can download them, since the same versions are installed from the +# image. The repo db is left complete so `pacman -S --needed` over package +# lists that mix image packages with new ones still resolves every name. + +set -euo pipefail + +mirror=/var/cache/omarchy/mirror/offline +shipped_list=/usr/share/omarchy-iso/offline-mirror.shipped + +[[ -f $shipped_list && -d $mirror ]] || exit 0 + +declare -A shipped=() +while IFS= read -r filename; do + [[ -n $filename ]] && shipped["$filename"]=1 +done <"$shipped_list" + +if (( ${#shipped[@]} == 0 )); then + echo "ERROR: refusing to prune the shipped mirror with an empty selection" >&2 + exit 1 +fi +for filename in "${!shipped[@]}"; do + if [[ ! -f $mirror/$filename ]]; then + echo "ERROR: shipped mirror selection names a missing package: $filename" >&2 + exit 1 + fi +done + +removed=0 +for path in "$mirror"/*.pkg.tar.*; do + filename=${path##*/} + [[ $filename == *.sig ]] && continue + [[ -n ${shipped[$filename]+x} ]] && continue + rm -f -- "$path" "$path.sig" + removed=$((removed + 1)) +done +echo "Removed $removed package files the root image already provides; ${#shipped[@]} remain." diff --git a/configs/airootfs/usr/local/bin/omarchy-install-dashboard b/configs/airootfs/usr/local/bin/omarchy-install-dashboard index 91e960ba8..47544346b 100755 --- a/configs/airootfs/usr/local/bin/omarchy-install-dashboard +++ b/configs/airootfs/usr/local/bin/omarchy-install-dashboard @@ -161,7 +161,9 @@ LAST_PM=10 # one button — so the last thing the installer shows matches the first. It runs # one row taller: `gum confirm` always paints prompt/blank/button and clears all # three, so the button cannot sit closer than two rows under a title we drew. -FINISH_HEIGHT=$((LOGO_HEIGHT + 5)) +# Two more rows between title and button: a blank and the medium-removal +# note — above the prompt, because gum clears everything under its view. +FINISH_HEIGHT=$((LOGO_HEIGHT + 7)) FINISH_TOP_ROW=2 # ── Install progress ───────────────────────────────────────────────────────── @@ -175,6 +177,9 @@ FINISH_TOP_ROW=2 # the install). libalpm makes one directory per package under # /var/lib/pacman/local; the denominator comes from # build-iso.sh, so there is no second package list to drift. +# The root image unpack that precedes the package delta publishes +# phase_progress (stream bytes consumed) in the state file; it drives +# the same band until the local db fills in when the unpack lands. # # Per-mille because a whole-percent asymptote over a 2-point band has two # reachable values and stops moving. No give-up predicate: two discontinuous @@ -196,6 +201,7 @@ PKG_FLOOR_TAU=900 # with a work signal, time is only a backstop until the POS=10 # position, per-mille, monotonic PROGRESS_PM=10 +VERIFY_PCT=-1 # honest percent of the pre-flight verify, -1 when idle NOW=0 # monotonic seconds, refreshed once per frame DASH_T0=0 PHASE_NAME="" # last phase this process observed @@ -293,10 +299,11 @@ count_packages() { # here would land on the TTY between the cursor escape and the bar. install_progress() { local phase finished index total target - local lo hi tau span t pos work gap n pkg_signal=0 + local lo hi tau span t pos work gap n pkg_signal=0 progress_pm local -a state PROGRESS_PM=$POS + VERIFY_PCT=-1 [[ -f $STATE_FILE ]] || return 0 mapfile -t state < <(jq -r ' @@ -304,11 +311,14 @@ install_progress() { ((.finished_at // 0) | tonumber? // 0 | floor), ((.current_index // -1) | tonumber? // -1 | floor), ((.total_phases // 0) | tonumber? // 0 | floor), - (.target // "/mnt") + (.target // "/mnt"), + ((.phase_progress // -1) | tonumber? // -1 | . * 1000 | floor) ' "$STATE_FILE" 2>/dev/null) - (( ${#state[@]} == 5 )) || return 0 + (( ${#state[@]} == 6 )) || return 0 phase="${state[0]}" finished="${state[1]}" index="${state[2]}" total="${state[3]}" target="${state[4]}" + progress_pm="${state[5]}" + [[ $progress_pm =~ ^-?[0-9]+$ ]] || progress_pm=-1 [[ -n $target ]] || target="/mnt" if (( finished > 0 )) || [[ $phase == "Installation complete" ]]; then @@ -393,6 +403,17 @@ install_progress() { work=$(( lo + (span - 1 - PKG_TAIL_PM) * PKG_SEEN / PKG_TOTAL + PKG_BONUS )) (( work > pos )) && pos=$work fi + if (( progress_pm >= 0 )); then + (( progress_pm > 1000 )) && progress_pm=1000 + # The pre-flight band is 15 per-mille wide (the phase is subsecond once + # the boot-time hash has finished), so real progress mapped into it moves + # the bar about one cell over minutes and reads as a hang. Surface the + # honest percent as a status line instead (render_dynamic); the unpack's + # band is wide enough that its progress shows in the bar itself. + [[ $phase == "Preparing install target" ]] && VERIFY_PCT=$(( progress_pm / 10 )) + work=$(( lo + (span - 1 - PKG_TAIL_PM) * progress_pm / 1000 )) + (( work > pos )) && pos=$work + fi (( pos > hi - 1 )) && pos=$(( hi - 1 )) (( pos > POS )) && POS=$pos @@ -457,7 +478,13 @@ render_dynamic() { line_at "$CONTENT_WIDTH" $(( (CONTENT_WIDTH - 34) / 2 )) "" progress_bar "$pm" 34 printf '\n' - blank_line + # Same row either way, so the layout height never changes: blank normally, + # the verify's honest percent while the pre-flight gate waits on the hash. + if (( VERIFY_PCT >= 0 )); then + center "${DIM}verifying the install medium: ${VERIFY_PCT}%${RESET}" "$CONTENT_WIDTH" + else + blank_line + fi center "${DIM}Tip:${RESET} ${GREEN}$(current_tip)${RESET}" "$CONTENT_WIDTH" printf '%s' "$CLEAR_TO_END" } @@ -513,6 +540,17 @@ render_finish() { blank_line center "Installed Omarchy in ${duration}" "$CONTENT_WIDTH" blank_line + # Above the button, not below: gum clears everything under its view on + # each repaint, so a line below the prompt would be erased. True because + # of release_target + warm_reboot_path: from here to the reboot syscall + # nothing reads the install medium again — but only when the release + # actually happened, so a failed one withdraws the offer instead of + # printing a promise the machine is not keeping. + if [[ $TARGET_RELEASED == yes ]]; then + center "${DIM}You can now remove the install medium${RESET}" "$CONTENT_WIDTH" + else + center "${DIM}Leave the install medium in until the reboot completes${RESET}" "$CONTENT_WIDTH" + fi } >"$TTY_PATH" if [[ -f $LOGO_PATH ]]; then @@ -538,6 +576,86 @@ render_finish() { fi } +# ── Medium-free reboot ────────────────────────────────────────────────────── +# +# The live root pages from the install medium on demand (copytoram=n keeps +# the root image reachable during the install), so pulling the stick is only +# safe once nothing will fault a cold read anymore. A graceful reboot is the +# opposite of that: its stop jobs page unit files and ExecStop binaries. So, +# before the finish screen offers the medium's removal: release the target — +# fully written to its own disk from that moment, whatever happens after — +# and pre-fault the binaries the finish screen still runs. The medium is +# read-only and the live logs are tmpfs, so past the umount nothing anywhere +# is dirty, which is what makes the immediate reboot at the press clean. +# Debugging keeps its access: the installed tree is no longer pre-mounted on +# the other ttys, but one mount command away for whoever wants it. +# +# If the release fails, the offer is withheld: the finish screen says to +# leave the medium in instead, and the button press falls back to a graceful +# reboot — the medium was never offered for removal, so its stop jobs can +# still page from it, and systemd gets its own chance at the unmount the +# release could not do. + +TARGET_RELEASED=yes + +release_target() { + local target out line + target=$(jq -r '.target // "/mnt"' "$STATE_FILE" 2>/dev/null) || target=/mnt + # Swapoff, umount, mapper close — shared with the orchestrator's failure + # cleanup. On failure the script names the holders on stderr; forward that + # into the install log, where the diagnosis is worth more than a shrug. + if ! out=$(omarchy-release-install-target "$target" 2>&1); then + dashboard_log "release: could not release $target" + TARGET_RELEASED=no + fi + while IFS= read -r line; do + [[ -n $line ]] && dashboard_log "release: $line" + done <<<"${out:-}" +} + +warm_reboot_path() { + local binary path dep + # Everything the post-note window still execs: the note goes up inside + # render_finish, so the logo effect (up to 8s of it), the timeout in front + # of it, the stty that sizes the prompt, and the button gum paints are all + # still ahead of the moment the user is invited to pull the stick. + for binary in systemctl ttfx gum timeout stty; do + path=$(command -v "$binary" 2>/dev/null) || continue + cat "$path" >/dev/null 2>&1 || true + while IFS= read -r dep; do + [[ -f $dep ]] && cat "$dep" >/dev/null 2>&1 + done < <(ldd "$path" 2>/dev/null | awk '{ for (i = 1; i <= NF; i++) if ($i ~ /^\//) print $i }') + done + + # cat+ldd alone still leaves ~1.4 MiB of exec-time reads ldd cannot list + # (loader path, locale, runtime data) — measured on a dropped cache in the + # live VM: timeout+ttfx 880 KiB, stty 320 KiB, gum 188 KiB, each a + # one-time fault. Dry-running the real commands takes those reads now, + # while the medium is still welcome. ttfx --version rather than a hidden + # effect run: measured to cover the effect's read set to within ~12 KiB, + # and it returns in milliseconds instead of burning a capped second. + # systemctl needs no dry run: cat+ldd covers the reboot-shaped invocation + # (verified via is-system-running: zero reads). + cat "$LOGO_PATH" >/dev/null 2>&1 || true + ttfx --version >/dev/null 2>&1 || true + stty size <"$TTY_PATH" >/dev/null 2>&1 || true + gum --version >/dev/null 2>&1 || true +} + +reboot_now() { + # Which reboot the button gets is the other half of the release's verdict. + # Released: nothing anywhere is dirty and the medium may already be gone, + # so skip the stop jobs that would page it back in. Not released: the + # finish screen told the user to leave the medium in, which keeps the + # graceful path's cold reads safe, and systemd gets its own chance at the + # unmount the release could not do. + if [[ $TARGET_RELEASED == yes ]]; then + systemctl reboot -ff 2>/dev/null || reboot -f 2>/dev/null || true + else + systemctl reboot 2>/dev/null || reboot 2>/dev/null || true + fi +} + reboot_prompt() { local prompt_pad @@ -546,9 +664,9 @@ reboot_prompt() { interactive || return 0 # gum paints its prompt row at the cursor and the button two rows under it, so - # anchor on the row right below the title to put the button as close to it as - # gum allows. FINISH_HEIGHT budgets for the same three rows. - printf '%s%d;1H%s' "$CSI" "$((FINISH_TOP_ROW + LOGO_HEIGHT + 2))" "$CLEAR_LINE" >"$TTY_PATH" + # anchor on the row right below the removal note to put the button as close + # to it as gum allows. FINISH_HEIGHT budgets for the same three rows. + printf '%s%d;1H%s' "$CSI" "$((FINISH_TOP_ROW + LOGO_HEIGHT + 4))" "$CLEAR_LINE" >"$TTY_PATH" prompt_pad=$(( ( $(term_cols) - 18 ) / 2 )) (( prompt_pad < 0 )) && prompt_pad=0 @@ -703,9 +821,14 @@ render_failure() { if [[ -n $state_summary ]]; then blank_line + # The failed phase's error is the one thing the user must read, so wrap + # it at word boundaries rather than clip it: a multi-line error keeps its + # own line breaks, and an over-long line folds instead of losing its end. while IFS= read -r line; do [[ -n $line ]] || continue - center "${DIM}$(truncate_for_width "$line" "$CONTENT_WIDTH")${RESET}" "$CONTENT_WIDTH" + while IFS= read -r part; do + center "${DIM}${part}${RESET}" "$CONTENT_WIDTH" + done < <(fold -s -w "$CONTENT_WIDTH" <<<"$line") done <<<"$state_summary" fi @@ -865,6 +988,12 @@ if [[ ${OMARCHY_UI_DEFER_PROVISIONING:-} == "yes" ]]; then exit 0 fi +# Before the screen that offers the medium's removal: the target is released +# first, so the install is fully on its own disk before the user can act on +# the note; the warm makes the button press itself medium-free. +release_target 2>/dev/null || true +warm_reboot_path 2>/dev/null || true + set +e render_finish finish_render_status=$? @@ -882,7 +1011,7 @@ if reboot_prompt; then exit 0 fi printf '%s' "$CLEAR" >"$TTY_PATH" 2>/dev/null || true - reboot 2>/dev/null || systemctl reboot 2>/dev/null || true + reboot_now fi exit 0 diff --git a/configs/airootfs/usr/local/bin/omarchy-release-install-target b/configs/airootfs/usr/local/bin/omarchy-release-install-target new file mode 100755 index 000000000..d9a251650 --- /dev/null +++ b/configs/airootfs/usr/local/bin/omarchy-release-install-target @@ -0,0 +1,124 @@ +#!/bin/bash +# +# Release the install target: deactivate every swap area under it, +# recursively unmount it, and close the LUKS mappers that backed it. The one +# source of truth for letting go of the target — the dashboard runs it +# before offering the install medium's removal, and the orchestrator's +# protected-failure cleanup runs it naming the mapper it may have opened. +# +# The swapoff comes first because active swap is an invisible holder: the +# hibernation setup's swapon in the chroot lands in the shared kernel, fuser +# then shows no process and the submounts unmount fine, yet the filesystem +# reports busy until the swap is off. The mappers to close are read off the +# mount table before the unmount empties it, and each one's device-mapper +# UUID must carry the CRYPT- prefix before the close is attempted — +# cryptsetup close happily removes a non-crypt mapping (LVM, linear), so the +# sweep is bounded by that check, not by trust in a refusal. A busy crypt +# mapper still fails its close, and the failure is reported. +# +# Extra arguments name mappers to close beyond what the mount table shows: +# a failure between luksOpen and mount leaves a mapper open with nothing +# mounted from it, which the table sweep cannot see. +# +# Idempotent: a target with nothing mounted and no mapper left open exits 0. +# Exits nonzero when the tree stayed mounted (umount -R failed) or a crypt +# mapper stayed open — either way the target is not fully let go, and the +# dashboard's removal offer and immediate-reboot choice key on exactly that. + +set -uo pipefail + +# device-mapper's word on a mapper: prints its UUID and returns 0 when it +# exists, 1 when it provably does not ("Device does not exist." is the +# driver's own answer for ENXIO), and 2 when dmsetup could not answer at all +# (control node unreachable, driver not responding) — the same rc 1 as a +# missing mapper, told apart only by the message. The distinction matters +# because 1 is the idempotent no-op and 2 must never pass for it: a mapper +# that cannot be proven gone has to fail the release, and this exit status +# is what stands between the user and pulling the medium. +dm_err=$(mktemp) +trap 'rm -f "$dm_err"' EXIT +mapper_uuid() { + local uuid + if uuid=$(dmsetup info -c --noheadings -o uuid "$1" 2>"$dm_err"); then + printf '%s\n' "$uuid" + return 0 + fi + grep -q "Device does not exist" "$dm_err" && return 1 + echo "could not query mapper $1:" >&2 + cat "$dm_err" >&2 + return 2 +} + +target="${1:?usage: omarchy-release-install-target [mapper ...]}" +shift +mappers=("$@") + +sync + +while IFS=' ' read -r swap_path _; do + [[ $swap_path == "$target"/* ]] && swapoff "$swap_path" 2>/dev/null +done < <(tail -n +2 /proc/swaps 2>/dev/null) + +while IFS= read -r source; do + source="${source%%\[*}" + [[ $source == /dev/mapper/* ]] && mappers+=("${source#/dev/mapper/}") +done < <(findmnt -rn -o SOURCE -R "$target" 2>/dev/null | sort -u) + +# On failure, name the holders on stderr: this is the diagnosis the swapfile +# hunt needed once, done by the machine. fuser over lsof because the live +# ISO ships psmisc and not lsof, and the holder table is what gets acted on. +if mountpoint -q "$target"; then + if ! umount -R "$target"; then + { + echo "could not unmount $target; holders:" + fuser -vm "$target" 2>&1 + echo "still mounted under it:" + findmnt -rn -R "$target" + echo "active swap:" + tail -n +2 /proc/swaps + } >&2 + exit 1 + fi +fi + +# A mapper can be mounted outside the target tree too: the orchestrator +# stage-mounts the same filesystem under /run/omarchy-install during some +# phases, and a mid-phase failure can leave one behind — the close would +# then fail silently on a device the target-tree unmount never saw. +close_failed=0 +for mapper in "${mappers[@]}"; do + while IFS= read -r mountpoint; do + umount -R "$mountpoint" 2>/dev/null + done < <(findmnt -rn -o TARGET -S "/dev/mapper/$mapper" 2>/dev/null) + # Crypt mappings only — a foreign mapper under the target (or a stale name + # passed as an argument) is not ours to tear down, and cryptsetup would + # remove it without complaint. A missing mapper falls out here too, which + # keeps the no-op run silent the way idempotency wants — but only on the + # driver's word for missing: an unanswerable query fails the release. + uuid=$(mapper_uuid "$mapper") + case $? in + 1) continue ;; + 2) close_failed=1; continue ;; + esac + [[ $uuid == CRYPT-* ]] || continue + # dmsetup, not the /dev/mapper node, decides whether the close took: the + # node is udev's and lags in both directions — still there for a moment + # after a good close (which would withhold the offer on a machine that is + # fine), and never there at all when device-mapper runs with udev disabled + # (which would hide a mapper that really did stay open). Only its provable + # absence counts as closed; an unanswerable probe fails like a held one. + if ! cryptsetup close "$mapper" 2>/dev/null; then + mapper_uuid "$mapper" >/dev/null + if (( $? != 1 )); then + { + echo "could not close mapper $mapper; holders:" + fuser -vm "/dev/mapper/$mapper" 2>&1 + dmsetup info "$mapper" 2>&1 + } >&2 + close_failed=1 + fi + fi +done + +sync +exit $close_failed diff --git a/configs/airootfs/usr/local/bin/omarchy-wait-root-image-verify b/configs/airootfs/usr/local/bin/omarchy-wait-root-image-verify new file mode 100755 index 000000000..7fc409618 --- /dev/null +++ b/configs/airootfs/usr/local/bin/omarchy-wait-root-image-verify @@ -0,0 +1,143 @@ +#!/bin/bash +# Single source of truth for validating the root image on the install medium +# before anything formats a disk. +# +# The root image is streamed from the boot medium (copytoram is off), so a bad +# medium must be caught before the full-disk orchestrator hands off to +# archinstall *and* before the free-space configurator partitions the target. +# omarchy-root-image-verify.service hashes the image in the background from boot +# (idle-class, so it yields to the live system under the boot-medium BFQ rule); +# this collects that verdict, waiting for the hash if it is still running and +# starting it if it never did. +# +# Also logs the boot device and its I/O scheduler to stdout (the idle class only +# takes effect under bfq) -- this folds in what used to be omarchy-iso-boot-medium. +# +# Exit 0: image verified (device/scheduler line on stdout). Non-zero: corrupt, +# missing, or unverifiable -- the message is on stderr with the first line +# written to headline the install dashboard. +set -uo pipefail + +UNIT=omarchy-root-image-verify.service +BOOT_MEDIUM_MOUNT=/run/archiso/bootmnt +STREAM="$BOOT_MEDIUM_MOUNT/arch/x86_64/omarchy-root.btrfs.zst" +CHECKSUM="$STREAM.sha256" + +fail() { echo "$1" >&2; exit 1; } + +# How far the hasher has read, as an integer percent: the unit's MainPID is +# sha256sum while it runs, and its fdinfo pos on the stream is the byte +# offset (the same source the orchestrator mirrors into the dashboard). +# Fails silently between opens or once the unit is done; callers just skip +# that sample. +hash_pct() { + local pid fd pos size + pid=$(systemctl show "$UNIT" -p MainPID --value 2>/dev/null) + [[ $pid =~ ^[0-9]+$ && $pid -gt 0 ]] || return 1 + size=$(stat -c %s "$STREAM" 2>/dev/null) || return 1 + ((size > 0)) || return 1 + for fd in /proc/"$pid"/fd/*; do + [[ $(readlink "$fd" 2>/dev/null) == "$STREAM" ]] || continue + pos=$(awk '/^pos:/ {print $2; exit}' "/proc/$pid/fdinfo/${fd##*/}" 2>/dev/null) + [[ -n $pos ]] && { echo $((pos * 100 / size)); return 0; } + done + return 1 +} + +# --- boot medium + scheduler (absorbed from omarchy-iso-boot-medium) --- +src=$(findmnt -no SOURCE "$BOOT_MEDIUM_MOUNT" 2>/dev/null || true) +if [[ -n $src ]]; then + disk=$(lsblk -no PKNAME "$src" 2>/dev/null | head -n1) + disk=${disk:-${src#/dev/}} + sched=unknown + [[ -r /sys/block/$disk/queue/scheduler ]] && sched=$(cat "/sys/block/$disk/queue/scheduler") + echo "boot medium: $src on /dev/$disk, scheduler: $sched" +fi + +if [[ ! -f $STREAM ]]; then + # The archiso hook unmounts the boot medium after copying the airootfs to RAM + # (copytoram). The boot entries pin copytoram=n, so this only happens when + # someone edits the kernel command line. + [[ -d $BOOT_MEDIUM_MOUNT ]] || fail "boot medium is not mounted at $BOOT_MEDIUM_MOUNT: the live system was copied to RAM (copytoram) and the medium released; boot with copytoram=n" + fail "root image stream missing: $STREAM" +fi +[[ -f $CHECKSUM ]] || fail "root image checksum missing: $CHECKSUM" + +[[ "$(systemctl show "$UNIT" -p LoadState --value 2>/dev/null)" == loaded ]] || + fail "$UNIT is not on this live system; cannot verify the root image" + +# inactive -> never started; `systemctl start` blocks until the oneshot exits. +[[ "$(systemctl show "$UNIT" -p ActiveState --value 2>/dev/null)" == inactive ]] && + systemctl start "$UNIT" >/dev/null 2>&1 + +reported=false +progress_drawn=false +settling=0 +# The \r progress line needs terminating before anything else writes to the +# terminal. Only a verified image gets the cosmetic 100%; a failure (which +# includes a timed-out, unfinished read) just ends the line. +finish_progress() { + [[ $progress_drawn == true ]] || return 0 + [[ ${1:-} == full ]] && + printf '\rverifying the install medium: 100%%' >>"$OMARCHY_VERIFY_PROGRESS" 2>/dev/null + printf '\n' >>"$OMARCHY_VERIFY_PROGRESS" 2>/dev/null || true +} +while :; do + case "$(systemctl show "$UNIT" -p ActiveState --value 2>/dev/null)" in + active) + finish_progress full + exit 0 + ;; + failed) + finish_progress + # Result=timeout: systemd killed the hash at the size-based + # TimeoutStartSec the build wrote into the unit (2 MiB/s floor over the + # image size). The medium is stalling reads, not returning bad data, so + # re-flashing the same stick is not the advice. + if [[ "$(systemctl show "$UNIT" -p Result --value 2>/dev/null)" == timeout ]]; then + fail "install medium is too slow: try another USB stick or port"$'\n'"reading the root image did not finish within its size-based timeout" + fi + # The dashboard centres each line in ~80 columns, so the action comes + # first and on its own line; the journal tail (the hasher's own output) + # follows for debugging. + msg="install medium is corrupt: re-flash it"$'\n'"sha256 mismatch on the root image" + tail=$(journalctl -b -u "$UNIT" _COMM=sha256sum --no-pager -o cat 2>/dev/null) + [[ -n $tail ]] && msg+=$'\n'"$tail" + fail "$msg" + ;; + activating) + if [[ $reported == false ]]; then + echo "waiting for $UNIT to finish hashing the root image..." + reported=true + fi + # Opt-in live progress for interactive callers (the free-space + # configurator gate): a \r-updating percent line written to the path in + # OMARCHY_VERIFY_PROGRESS (normally /dev/tty). Kept out of stdout, which + # the orchestrator logs line by line, and stderr, whose first line + # headlines the dashboard on failure. + if [[ -n ${OMARCHY_VERIFY_PROGRESS:-} ]] && pct=$(hash_pct); then + printf '\rverifying the install medium: %3d%%' "$pct" >>"$OMARCHY_VERIFY_PROGRESS" 2>/dev/null || true + progress_drawn=true + fi + sleep 0.5 + ;; + deactivating) + # A start timeout stops the unit before it settles into failed, and the + # stop is what the medium makes slow: SIGTERM does not land on a read + # stuck in the driver. Classifying this state now would answer "did not + # run" for the one case the size-based TimeoutStartSec exists to name, + # so wait for the terminal state -- bounded past DefaultTimeoutStopSec, + # because a read on a dead medium may never be reaped at all. + if ((settling < 240)); then + settling=$((settling + 1)) + sleep 0.5 + continue + fi + finish_progress + fail "install medium is too slow: try another USB stick or port"$'\n'"the root image read could not be stopped after its size-based timeout" + ;; + *) + fail "$UNIT did not run; cannot verify the root image" + ;; + esac +done diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/archinstall_adapter.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/archinstall_adapter.py index c6a279bb5..623535293 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/archinstall_adapter.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/archinstall_adapter.py @@ -17,6 +17,9 @@ inst.generate_key_files() # encrypted only inst.set_mirrors(handler, mirror_config, on_target=False) inst.minimal_installation(...) # base + linux pacstrap + # (we unpack the root image + # and run install_base_delta + # instead; see below) inst.set_mirrors(handler, mirror_config, on_target=True) inst.setup_swap(algo=...) inst.create_users(users) @@ -50,6 +53,7 @@ from archinstall.lib.mirror.mirror_handler import MirrorListHandler from archinstall.lib.models import Bootloader from archinstall.lib.models.device import DiskLayoutType, EncryptionType +from archinstall.lib.pacman.config import PacmanConfig from archinstall.lib.models.users import User from .ui import info @@ -142,6 +146,100 @@ def open_installer( yield installer +def target_has_package(target: Path, name: str) -> bool: + """Whether pacman's local db in the target records `name` as installed.""" + local_db = target / "var" / "lib" / "pacman" / "local" + for entry in local_db.glob(f"{name}-*"): + desc = entry / "desc" + if not desc.is_file(): + continue + lines = desc.read_text(errors="ignore").splitlines() + try: + if lines[lines.index("%NAME%") + 1] == name: + return True + except (ValueError, IndexError): + continue + return False + + +def install_base_delta( + installer: Installer, + arch_config: ArchConfig, + *, + hostname: str | None, + locale_config, +) -> None: + """Installer.minimal_installation for a target that already holds the root + image: everything it does around its base pacstrap, with the pacstrap + reduced to the base packages the image does not carry (the kernel and the + CPU microcode). + + Mirrors archinstall 4.4's minimal_installation step for step so the target + ends up as that call would leave it: filesystem/encryption preparation + (which also decides the mkinitcpio hooks), microcode detection, pacman.conf + handling, vconsole, hostname, locale, and the helper flags later Installer + methods look at. LVM layouts are not supported by the image path.""" + disk_config = installer._disk_config + if disk_config.lvm_config: + raise RuntimeError("root image install does not support LVM layouts") + + for mod in disk_config.device_modifications: + for part in mod.partitions: + if part.fs_type is None: + continue + installer._prepare_fs_type(part.fs_type, part.mountpoint) + if part in installer._disk_encryption.partitions: + installer._prepare_encrypt() + + if ucode := installer._get_microcode(): + (installer.target / "boot" / ucode).unlink(missing_ok=True) + installer._base_packages.append(ucode.stem) + + mirror_config = arch_config.mirror_config + pacman_conf = PacmanConfig(installer.target) + pacman_conf.enable(mirror_config.optional_repositories if mirror_config else []) + pacman_conf.apply() + + if locale_config: + installer.set_vconsole(locale_config) + + delta = [pkg for pkg in installer._base_packages if not target_has_package(installer.target, pkg)] + if delta: + installer.pacman.strap(delta) + installer._helper_flags["base-strapped"] = True + + pacman_conf.persist() + if arch_config.pacman_config: + pacman_conf.configure(arch_config.pacman_config) + + if not installer._disable_fstrim: + installer.enable_periodic_trim() + + if hostname: + installer.set_hostname(hostname) + + if locale_config: + installer.set_locale(locale_config) + installer.set_keyboard_language(locale_config.kb_layout) + + installer._helper_flags["base"] = True + + for function in installer.post_base_install: + function(installer) + + +def setup_zram_swap(installer: Installer) -> None: + """Installer.setup_swap without its pacman.strap('zram-generator'): the + root image carries the package. Enables the zram unit and records that + zram is on, as archinstall 4.4 does. Its /etc zram-generator.conf is not + reproduced: omarchy-settings ships the tuning as a vendor drop-in and the + orchestrator removed archinstall's copy anyway.""" + if not target_has_package(installer.target, "zram-generator"): + installer.pacman.strap("zram-generator") + installer.enable_service("systemd-zram-setup@zram0.service") + installer._zram_enabled = True + + def is_encrypted(arch_config: ArchConfig) -> bool: disk = arch_config.disk_config if not disk or not disk.disk_encryption: @@ -233,10 +331,30 @@ def install_applications(installer: Installer, arch_config: ArchConfig) -> None: users = arch_config.auth_config.users if arch_config.auth_config else None handler = _application_handler() install_applications_method = handler.install_applications - if _method_accepts_users(install_applications_method): - install_applications_method(installer, app_config, users) - else: - install_applications_method(installer, app_config) + + # The application installers strap their package sets unconditionally. + # The root image already carries those (PipeWire and friends) and the + # offline mirror no longer does, and pacstrap --needed still has to + # resolve every target before it can skip it. Strap only what the target + # lacks (the hardware-detected firmware), through the installer instance + # the handlers call into. + original_strap = installer.add_additional_packages + + def strap_missing(packages: str | list[str]) -> None: + if isinstance(packages, str): + packages = [packages] + missing = [pkg for pkg in packages if not target_has_package(installer.target, pkg)] + if missing: + original_strap(missing) + + installer.add_additional_packages = strap_missing # type: ignore[method-assign] + try: + if _method_accepts_users(install_applications_method): + install_applications_method(installer, app_config, users) + else: + install_applications_method(installer, app_config) + finally: + del installer.add_additional_packages def root_user(arch_config: ArchConfig) -> User | None: diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py index 9b6ebf5b5..87f6b027e 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py @@ -19,9 +19,10 @@ def build_phases(ctx: InstallContext): """Phase order. Each entry is (display name, callable taking InstallContext). - The ordering is the whole point of this orchestrator: package-install - hooks (limine-mkinitcpio-hook, in particular) and useradd happen at - points where their prerequisites are guaranteed to be in place. + The ordering is the whole point of this orchestrator: the root image + unpack, package-install hooks (limine-mkinitcpio-hook, in particular) and + useradd happen at points where their prerequisites are guaranteed to be + in place. Full-disk and protected installs use the same phase sequence. The configurator only changes the JSON input: full-disk asks archinstall to @@ -81,6 +82,7 @@ def main() -> int: cleanup_protected_state, cleanup_target_hook_masks, restore_cpu_governors, + stop_target_keyring_init, ) governors = boost_cpu_governor() @@ -101,6 +103,9 @@ def main() -> int: return 0 finally: restore_cpu_governors(governors) + # No-op after a completed install (create_factory_snapshot joined it); + # on a failure it ends the unit before the target is torn down. + stop_target_keyring_init(ctx) cleanup_bind_mounts(ctx) cleanup_target_hook_masks(ctx) if not success: diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases.py index cb8424470..46befebae 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases.py @@ -34,13 +34,16 @@ def run(ctx: InstallContext, phases: list[tuple[str, PhaseFn]]) -> None: "current_phase": "Starting installation", "phases": [], } - _write_state(state_path, state) + write_state(state_path, state) for index, (name, fn) in enumerate(phases): state["current_index"] = index state["current_phase"] = name state["phase_started_at"] = time.time() - _write_state(state_path, state) + # Phases that can measure themselves (the root image unpack) publish + # phase_progress for the dashboard; it means nothing across phases. + state.pop("phase_progress", None) + write_state(state_path, state) info(f"› {name}") started = time.time() @@ -54,7 +57,7 @@ def run(ctx: InstallContext, phases: list[tuple[str, PhaseFn]]) -> None: "elapsed": elapsed, "error": str(exc), }) - _write_state(state_path, state) + write_state(state_path, state) error(f"Phase '{name}' failed after {elapsed:.1f}s: {exc}") traceback.print_exc() @@ -62,7 +65,7 @@ def run(ctx: InstallContext, phases: list[tuple[str, PhaseFn]]) -> None: elapsed = time.time() - started state["phases"].append({"name": name, "status": "ok", "elapsed": elapsed}) - _write_state(state_path, state) + write_state(state_path, state) state["current_index"] = max(len(phases) - 1, 0) state["current_phase"] = "Installation complete" @@ -71,11 +74,11 @@ def run(ctx: InstallContext, phases: list[tuple[str, PhaseFn]]) -> None: # acceptance runs rather than only by watching a bar creep. state["installed_packages"] = _installed_package_count(ctx.target) state["expected_packages"] = _expected_package_count() - _write_state(state_path, state) + write_state(state_path, state) timing_path = ctx.target / "var" / "log" / "omarchy-install-timing.json" timing_path.parent.mkdir(parents=True, exist_ok=True) - _write_state(timing_path, state) + write_state(timing_path, state) def _installed_package_count(target: Path) -> int: @@ -97,7 +100,7 @@ def _expected_package_count() -> int: return 0 -def _write_state(path: Path, state: dict) -> None: +def write_state(path: Path, state: dict) -> None: # Dashboard polls this file while phases update it. Write atomically so the # reader never observes a truncated/partial JSON document and resets UI. tmp = path.with_name(f".{path.name}.tmp") diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py index 6d4862455..bc8e905e4 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py @@ -5,11 +5,16 @@ prepare_live → disk cleanup when wiping, load configurator handlers (archinstall patch happens in the wrapper before Python imports it) - prepare_install_target → verify pre-mounted target/ESP when the JSON uses - pre_mounted_config; no-op for full-disk installs + prepare_install_target → everything that can fail before the disk is + touched: the pre-mounted target/ESP when the JSON + uses pre_mounted_config, the root image stream + and its checksum, and a disk layout the image can + land on arch_install_system → one archinstall flow for partition/mount-or-use, - base install, early Omarchy packages, Limine setup, - useradd, runtime Omarchy packages, fstab + root image unpack (btrfs receive), per-machine + package delta, Limine setup, useradd, fstab; the + per-machine pacman keyring starts as a transient + systemd unit after the last pacstrap configure_hibernation → root-owned swap/resume drop-ins run_system_finalizer → arch-chroot root omarchy-apply-system, including Snapper finalize_limine_boot → final Limine config/UKI build after hardware drop-ins @@ -18,13 +23,16 @@ configure_ssh_access → authorized_keys for autoinstall; no-op otherwise configure_tailscale → tailnet join staged for first boot; no-op otherwise validate_boot → assert UKI / limine.conf / kernel cmdline are sane + create_factory_snapshot→ joins the keyring unit, then snapshots @ as @factory """ from __future__ import annotations import hashlib +import json import os import re +import shlex import shutil import subprocess import textwrap @@ -36,6 +44,7 @@ from .command import capture, capture_identifier, require_text from .context import InstallContext from .keyboard import configure_keyboard +from .phases import write_state from .ui import error, info @@ -117,43 +126,60 @@ def _omarchy_nvim_package() -> str: return _package_targets()["nvim"] -# Packages installed BEFORE useradd. The selected omarchy-settings package and -# omarchy-nvim populate /etc/skel so the user's home gets seeded correctly, and -# omarchy-settings also ships the limine/snapper configs. Target-side setup -# commands are installed later by the selected Omarchy runtime package and -# executed in chroot. -EARLY_BOOTSTRAP_BASE_PACKAGES = [ - "base-devel", - "git", - "limine", - "efibootmgr", - "omarchy-keyring", -] - -# Install LuaRocks before omarchy-nvim pulls in lua51-lpeg. Arch's lua-luarocks -# post_install script tries to rebuild manifests for existing rocks trees before -# the unversioned luarocks-admin command exists if both arrive in the wrong -# transaction order. Splitting this transaction avoids the harmless but noisy -# "luarocks-admin: command not found" line during ISO installs. -EARLY_LUAROCKS_PACKAGES = [ - "lua51", - "luarocks", -] - - -def _early_bootstrap_packages() -> list[str]: - return [*EARLY_BOOTSTRAP_BASE_PACKAGES, _omarchy_settings_package()] - +# The root image: a `btrfs send --compressed-data` stream of the invariant +# target system (build-root-image.sh), unpacked onto the target in place of +# pacstrapping it, shipped behind an outer whole-stream zstd layer (the +# per-extent compression inside the stream cannot reach the send framing or +# redundancy that spans extents; the outer pass is ~11% of the stream). +# build-iso.sh ships it as a plain file on the ISO, read straight off the +# boot medium, with sha256sum output for it (the compressed file) next to it. +# The subvolume name is what build-root-image.sh sends. +ROOT_IMAGE_STREAM = Path("/run/archiso/bootmnt/arch/x86_64/omarchy-root.btrfs.zst") +# Decompresses the outer layer in the receive pipe. --long=27 mirrors the +# compressing side's window: it is within the decoder's default 128 MiB +# acceptance limit, but saying it here keeps the pair visibly in step with +# STREAM_COMPRESS in build-root-image.sh. +ROOT_IMAGE_DECOMPRESS = ("zstd", "-dc", "--long=27") +ROOT_IMAGE_SUBVOLUME = "omarchy-root" +# The live ISO starts omarchy-root-image-verify.service at boot: `sha256sum -c` +# of the stream, running while the user is in the configurator. It is the only +# verifier. Both this phase and the free-space configurator gate collect its +# verdict through this helper, which logs the boot medium and its I/O scheduler, +# waits for the unit if it is still hashing, and starts it if it never ran. +ROOT_IMAGE_VERIFY_HELPER = "/usr/local/bin/omarchy-wait-root-image-verify" +ROOT_IMAGE_VERIFY_UNIT = "omarchy-root-image-verify.service" + + +BOOT_MEDIUM_MOUNT = Path("/run/archiso/bootmnt") + + +def _root_image_stream() -> Path: + if not ROOT_IMAGE_STREAM.is_file(): + # The archiso hook unmounts the boot medium after copying the airootfs + # to RAM (copytoram). The boot entries pin copytoram=n, so this only + # happens when someone edits the kernel command line. + if not BOOT_MEDIUM_MOUNT.is_dir(): + raise RuntimeError( + f"boot medium is not mounted at {BOOT_MEDIUM_MOUNT}: the live system was " + "copied to RAM (copytoram) and the medium released; boot with copytoram=n" + ) + raise RuntimeError(f"root image stream missing: {ROOT_IMAGE_STREAM}") + return ROOT_IMAGE_STREAM -def _early_user_seed_packages() -> list[str]: - return [_omarchy_nvim_package()] +# Packages the image must carry for the rest of the install to work: Limine +# setup reads the settings package's limine config, useradd copies the skel the +# settings and nvim packages populate, and the target-side setup commands come +# from the runtime package. Checked right after unpacking so a mismatched +# image fails here with a clear message instead of three phases later. +ROOT_IMAGE_REQUIRED_PACKAGES = ("limine", "omarchy-keyring") -def _early_packages() -> list[str]: +def _root_image_required_packages() -> list[str]: return [ - *_early_bootstrap_packages(), - *EARLY_LUAROCKS_PACKAGES, - *_early_user_seed_packages(), + *ROOT_IMAGE_REQUIRED_PACKAGES, + _omarchy_runtime_package(), + _omarchy_settings_package(), + _omarchy_nvim_package(), ] @@ -209,12 +235,126 @@ def _install_disk(ctx: InstallContext) -> str | None: # ───────────────────────────────────────────────────────────────────────────── def prepare_install_target(ctx: InstallContext) -> None: + """Everything that can fail before the disk is touched. The next phase + partitions, formats and encrypts as its first step, and a failure after + that leaves a wiped (or wiped and encrypted) disk with no system on it: + so the stream, its checksum and a layout the image can land on are all + checked here, where failing costs nothing.""" if ctx.is_protected: verify_protected_mounts(ctx) + # The protected layout exists already; check the real mounts. + _root_image_target_mounts(ctx.target) + else: + verify_root_image_layout(ctx.user_configuration.get("disk_config") or {}) + verify_root_image_stream(ctx) + + +def verify_root_image_layout(disk_config: dict) -> None: + """The root image replaces the target's @ subvolume, so the configurator + JSON must put the root on a btrfs @ subvolume, and the image path has no + LVM support (install_base_delta). Checked from the JSON so a layout the + image cannot land on fails before archinstall creates it.""" + if disk_config.get("lvm_config"): + raise RuntimeError("root image install does not support LVM layouts") + + for mod in disk_config.get("device_modifications") or []: + for part in mod.get("partitions") or []: + if part.get("fs_type") != "btrfs": + continue + for subvol in part.get("btrfs") or []: + if subvol.get("mountpoint") == "/" and subvol.get("name") in ("@", "/@"): + return + raise RuntimeError( + "root image install needs the target root on a btrfs @ subvolume; " + "disk_config mounts / from no such subvolume" + ) + + +def verify_root_image_stream(ctx: InstallContext) -> None: + """The stream is present and hashes to what the build recorded. A + truncated or corrupt copy (a badly flashed USB is the common case) would + also trip btrfs receive's per-command checksums, but only after the disk + is formatted. + + ROOT_IMAGE_VERIFY_HELPER is the single source of truth: it collects the + boot-time hasher's verdict, waiting for the unit if it is still running + and starting it if it never did, and logs the boot medium and its I/O + scheduler. The free-space configurator gate runs the same helper before it + partitions, so both disk-touching paths clear the same check; whoever gets + there first pays the wait. The hasher's read also leaves as much of the + stream as fits in the page cache for the unpack that follows.""" + _publish_verify_progress(ctx) + try: + result = subprocess.run( + [ROOT_IMAGE_VERIFY_HELPER], + capture_output=True, text=True, check=False, + ) + except OSError as exc: + raise RuntimeError(f"could not run {ROOT_IMAGE_VERIFY_HELPER}: {exc}") from exc + + for line in result.stdout.splitlines(): + if line.strip(): + info(f"› {line.strip()}") + + if result.returncode != 0: + raise RuntimeError( + result.stderr.strip() + or f"{ROOT_IMAGE_VERIFY_HELPER} failed with status {result.returncode}" + ) + + +def _publish_verify_progress(ctx: InstallContext) -> None: + """While the boot-time hasher is still reading the stream, mirror its read + position into phase_progress so the dashboard bar tracks the actual hash + instead of the phase's time-driven band. Best effort throughout: the + helper is the authority on the verdict, and any hiccup here (unit already + done, hasher between opens, /proc gone) just skips a sample.""" + try: + total = ROOT_IMAGE_STREAM.stat().st_size + except OSError: + return + while total and _verify_unit_property("ActiveState") == "activating": + pos = _hasher_read_pos() + if pos is not None: + _write_phase_progress(ctx, pos / total) + time.sleep(0.5) + + +def _verify_unit_property(prop: str) -> str: + res = subprocess.run( + ["systemctl", "show", ROOT_IMAGE_VERIFY_UNIT, "-p", prop, "--value"], + capture_output=True, text=True, check=False, + ) + return res.stdout.strip() + + +def _hasher_read_pos() -> int | None: + """Byte offset of the hasher's open fd on the stream: the unit's MainPID + is sha256sum while it runs, and fdinfo's pos is how far it has read.""" + pid = _verify_unit_property("MainPID") + if not pid.isdigit() or pid == "0": + return None + fd_dir = Path("/proc") / pid / "fd" + try: + for fd in fd_dir.iterdir(): + try: + if fd.resolve() != ROOT_IMAGE_STREAM: + continue + fdinfo = (fd_dir.parent / "fdinfo" / fd.name).read_text() + except OSError: + continue + for line in fdinfo.splitlines(): + if line.startswith("pos:"): + return int(line.split()[1]) + except OSError: + pass + return None def arch_install_system(ctx: InstallContext) -> None: - """Install the target system from the archinstall JSON. + """Install the target system: archinstall partitions and mounts per the + configurator JSON, the root image is unpacked onto the mounted layout, and + archinstall finishes with the per-machine package delta, users, and fstab. The phase sequence is the same for full-disk and protected installs. The JSON decides whether archinstall should create/mount a disk layout or use @@ -241,6 +381,11 @@ def arch_install_system(ctx: InstallContext) -> None: skip_wkd=True, ) + # Before anything writes into the target: the image replaces the + # (empty) root subvolume archinstall created, and everything written + # there first would go with it. + _install_root_image(ctx) + if not pre_mounted and arch.is_encrypted(config): installer.generate_key_files() @@ -250,23 +395,19 @@ def arch_install_system(ctx: InstallContext) -> None: _mount_offline_package_cache(ctx) _mask_mkinitcpio_pacman_hooks(ctx) try: - info("› installing base system (mkinitcpio deferred to final Limine UKI build)") + info("› installing per-machine packages (mkinitcpio deferred to final Limine UKI build)") # An empty kb_layout makes archinstall's set_keyboard_language skip # booting the target in a container just to run localectl; the # keymap is configured offline right after instead. kb_layout = config.locale_config.kb_layout if config.locale_config else "" - installer.minimal_installation( - optional_repositories=( - config.mirror_config.optional_repositories - if config.mirror_config else [] - ), - mkinitcpio=False, + arch.install_base_delta( + installer, + config, hostname=config.hostname, locale_config=( replace(config.locale_config, kb_layout="") if config.locale_config else None ), - pacman_config=config.pacman_config, ) if not configure_keyboard(installer.target, kb_layout): @@ -276,10 +417,9 @@ def arch_install_system(ctx: InstallContext) -> None: installer.set_mirrors(mirror_handler, config.mirror_config, on_target=True) if config.swap and config.swap.enabled: - installer.setup_swap(algo=config.swap.algorithm) + arch.setup_zram_swap(installer) _drop_archinstall_zram_conf(ctx) - _install_early_packages(installer) _configure_limine_boot(ctx, installer, config) info("› creating user (with /etc/skel populated)") @@ -287,12 +427,12 @@ def arch_install_system(ctx: InstallContext) -> None: installer.create_users(config.auth_config.users) if config.app_config: - info("› installing archinstall application selections") + # The image carries the PipeWire packages; this adds the audio + # firmware archinstall's hardware detection asks for and wires + # the per-user PipeWire units. + info("› applying archinstall application selections") arch.install_applications(installer, config) - info("› installing Omarchy runtime + omarchy-base.packages") - installer.add_additional_packages(_runtime_package_list(ctx)) - # Tailscale is bundled in the offline mirror but only installed # when an autoinstall drive staged an auth key; must happen here, # while the mirror is still bind-mounted, not in the phase that @@ -304,6 +444,11 @@ def arch_install_system(ctx: InstallContext) -> None: _unmask_mkinitcpio_pacman_hooks(ctx) _unmount_offline_package_cache(ctx) + # After the last pacstrap: each one runs its own pacman-key --init on + # the target's gnupg dir. Runs on while the phases below configure the + # target; create_factory_snapshot joins it. + _start_target_keyring_init(ctx) + # Standard arch finishers. if config.timezone: installer.set_timezone(config.timezone) @@ -318,6 +463,328 @@ def arch_install_system(ctx: InstallContext) -> None: installer.genfstab() +# ───────────────────────────────────────────────────────────────────────────── +# Root image: btrfs receive the build-time system into the target filesystem +# and make it the @ subvolume. +# +# archinstall (or the configurator, for protected installs) has created and +# mounted the subvolume layout by the time this runs: @ at the target, with +# @home, @log, @pkg and the ESP mounted inside it. `btrfs receive` can only +# create a new subvolume, never fill an existing one, so the image is received +# at the filesystem's top level, snapshotted writable, and swapped in for the +# empty @ while the layout is unmounted; then the layout is mounted again +# exactly as it was. The mount table is replayed from findmnt rather than +# asking archinstall to mount a second time, which would also unlock LUKS a +# second time; the mapper stays open throughout. +# ───────────────────────────────────────────────────────────────────────────── + +def _findmnt_mounts(root: Path) -> list[dict]: + """Every mount at or below root, parents before children, as dicts with + target/source/fstype/options.""" + res = subprocess.run( + ["findmnt", "-R", "-J", "-o", "TARGET,SOURCE,FSTYPE,OPTIONS", str(root)], + capture_output=True, text=True, check=True, + ) + flat: list[dict] = [] + + def walk(nodes): + for node in nodes: + flat.append({k: node.get(k) for k in ("target", "source", "fstype", "options")}) + walk(node.get("children") or []) + + walk(json.loads(res.stdout).get("filesystems") or []) + return flat + + +def _remount_option_string(options: str) -> str: + # subvolid refers to the subvolume replaced here; subvol= names it. + return ",".join(opt for opt in options.split(",") if not opt.startswith("subvolid=")) + + +def _root_image_target_mounts(target: Path) -> tuple[list[dict], str]: + """The mount table under target, checked for what the image swap needs: + target itself mounted, btrfs, on the @ subvolume. Returns the mounts and + the device backing the root.""" + mounts = _findmnt_mounts(target) + if not mounts or Path(mounts[0]["target"]) != target: + raise RuntimeError(f"{target} is not a mountpoint") + root_mount = mounts[0] + if root_mount["fstype"] != "btrfs": + raise RuntimeError(f"root image install needs a btrfs target root, got {root_mount['fstype']}") + root_options = (root_mount["options"] or "").split(",") + if not any(opt in ("subvol=/@", "subvol=@") for opt in root_options): + raise RuntimeError(f"root image install needs the target root on the @ subvolume, got {root_mount['options']}") + device = (root_mount["source"] or "").split("[")[0] + if not device: + raise RuntimeError(f"could not determine the btrfs device backing {target}") + return mounts, device + + +def _install_root_image(ctx: InstallContext) -> None: + target = ctx.target + stream = _root_image_stream() + mounts, device = _root_image_target_mounts(target) + + top = ctx.state_dir / "image-top" + top.mkdir(parents=True, exist_ok=True) + subprocess.run(["mount", "-o", "subvolid=5", device, str(top)], check=True) + try: + received = top / ROOT_IMAGE_SUBVOLUME + if received.exists(): + subprocess.run(["btrfs", "subvolume", "delete", str(received)], check=True, capture_output=True) + + info(f"› unpacking root image ({stream.stat().st_size >> 20} MiB stream from {stream})") + _receive_root_image(ctx, top, stream) + + staged = top / "@.image" + if staged.exists(): + subprocess.run(["btrfs", "subvolume", "delete", str(staged)], check=True, capture_output=True) + subprocess.run(["btrfs", "subvolume", "snapshot", str(received), str(staged)], check=True, capture_output=True) + subprocess.run(["btrfs", "subvolume", "delete", str(received)], check=True, capture_output=True) + + # The image's pacman.log ends up under the @log mount; carry it over so + # the installed system's log starts with the packages it was built from. + image_log = staged / "var" / "log" / "pacman.log" + log_subvol = top / "@log" + if image_log.is_file() and log_subvol.is_dir(): + shutil.copy2(image_log, log_subvol / "pacman.log") + + info("› making the image the root subvolume") + _umount_tree(target) + try: + subprocess.run(["btrfs", "subvolume", "delete", str(top / "@")], check=True, capture_output=True) + staged.rename(top / "@") + finally: + for mount in mounts: + mountpoint = Path(mount["target"]) + mountpoint.mkdir(parents=True, exist_ok=True) + source = (mount["source"] or "").split("[")[0] + subprocess.run( + ["mount", "-t", mount["fstype"], "-o", _remount_option_string(mount["options"] or ""), + source, str(mountpoint)], + check=True, + ) + finally: + subprocess.run(["umount", str(top)], check=False, capture_output=True) + + missing = [pkg for pkg in _root_image_required_packages() if not arch.target_has_package(target, pkg)] + if missing: + raise RuntimeError(f"root image lacks required packages: {', '.join(missing)}") + + # Per-machine identity the image deliberately ships without. + subprocess.run(["systemd-machine-id-setup", f"--root={target}"], check=True, capture_output=True) + + +# Transient systemd unit that initialises the target's pacman keyring. +TARGET_KEYRING_UNIT = "omarchy-target-keyring" + + +def _start_target_keyring_init(ctx: InstallContext) -> None: + """Initialise and populate the target's per-machine pacman keyring, as a + transient systemd unit that runs on while the install continues. + + The image ships no /etc/pacman.d/gnupg: its master key would be the same + on every install, and a shared signing key must never be distributed. On + a target pacstrapped directly, pacstrap -K initialised the keyring and + the keyring packages' scriptlets populated it; here those packages come + from the image, where their scriptlets ran with no keyring to populate. + The delta pacstrap's -K still ran --init (generating the master key), and + --init is idempotent, so run it again for installs that pacstrapped + nothing, then populate from the target's own keyring files. Chroot-free: + --gpgdir and --populate-from address the target directly, so no API + mounts are held. + + Started after the last pacstrap (each runs its own --init on this dir) + and joined by create_factory_snapshot: nothing in between reads the + keyring (the offline repo is SigLevel = Never) or writes it, and the + snapshot must not capture it half-written. A unit rather than a plain + child process: pacman-key leaves a gpg-agent and dirmngr behind, which + systemd kills with the rest of the unit's cgroup the moment pacman-key + exits (sockets under the target's gnupg dir would otherwise block the + unmount); the dashboard's process-group kill does not reach it, while + `systemctl stop` still does (stop_target_keyring_init); and its output + is in the journal whatever happens to the orchestrator. --wait --pipe + give a child to join with the unit's exit status and output. + """ + gpgdir = shlex.quote(str(ctx.target / "etc" / "pacman.d" / "gnupg")) + keyrings = shlex.quote(str(ctx.target / "usr" / "share" / "pacman" / "keyrings")) + script = ( + f"pacman-key --gpgdir {gpgdir} --init && " + f"pacman-key --gpgdir {gpgdir} --populate-from {keyrings} --populate archlinux omarchy" + ) + info("› initializing per-machine pacman keyring (background unit)") + # A failed unit from an earlier attempt would hold the name (--collect + # releases it, but not for a unit started without it). + subprocess.run(["systemctl", "reset-failed", TARGET_KEYRING_UNIT], check=False, capture_output=True) + ctx.state["target_keyring_proc"] = subprocess.Popen( + ["systemd-run", "--wait", "--pipe", "--collect", "--quiet", + f"--unit={TARGET_KEYRING_UNIT}", "sh", "-c", script], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + + +def _join_target_keyring_init(ctx: InstallContext, *, raise_on_error: bool = True) -> None: + """Wait for the keyring unit if one is running; no-op otherwise.""" + proc = ctx.state.pop("target_keyring_proc", None) + if proc is None: + return + out, _ = proc.communicate() + if raise_on_error and proc.returncode != 0: + raise RuntimeError( + f"per-machine pacman keyring init failed (exit {proc.returncode}):\n" + f"{(out or '').strip()}" + ) + + +def stop_target_keyring_init(ctx: InstallContext) -> None: + """Exit path: end the keyring unit if it is still running, so nothing + keeps writing into the target after the install has stopped. The + install's own error must win, so a keyring failure is not raised here.""" + if ctx.state.get("target_keyring_proc") is None: + return + subprocess.run(["systemctl", "stop", TARGET_KEYRING_UNIT], check=False, capture_output=True) + _join_target_keyring_init(ctx, raise_on_error=False) + + +def _umount_tree(root: Path, attempts: int = 20) -> None: + """umount -R with a few retries: the dashboard polls the target's pacman + db from another process, and a poll landing mid-unmount is EBUSY.""" + for attempt in range(1, attempts + 1): + res = subprocess.run(["umount", "-R", str(root)], capture_output=True, text=True) + if res.returncode == 0: + return + if attempt == attempts: + raise RuntimeError(f"could not unmount {root}: {res.stderr.strip()}") + time.sleep(0.25) + + +def _receive_root_image(ctx: InstallContext, top: Path, stream_path: Path) -> None: + """Pipe the stream through the outer-layer decompressor into btrfs + receive, publishing progress for the dashboard as a fraction of stream + bytes consumed (compressed bytes — the fraction of the medium read, which + is what the wait is made of). + + This loop keeps the read off the medium for itself rather than handing + zstd the file: a read error here is the dying-medium case the verify + machinery exists for, and it must surface as this process's OSError, not + as a decompressor exit code to reverse-engineer. + """ + total = stream_path.stat().st_size + errors = ctx.state_dir / "btrfs-receive.err" + with errors.open("w") as err: + unzstd = subprocess.Popen( + [*ROOT_IMAGE_DECOMPRESS], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=err, + ) + try: + proc = subprocess.Popen( + ["btrfs", "receive", "-q", str(top)], + stdin=unzstd.stdout, + stderr=err, + ) + except BaseException: + # A half-built pipeline has no receive to drain the decompressor, + # and _close_receive below never runs: close both of its pipes so + # it exits (EOF on stdin; EPIPE once its output has nowhere to + # go) and reap it, then let the original exception tell the + # story. btrfs is on every live ISO, so this is close to + # unreachable -- but a leaked child blocked on stdin is the kind + # of close-to that turns a loud failure into a wedged teardown. + # BaseException, not Exception: a KeyboardInterrupt aimed at this + # process alone must run the same cleanup on its way out (the + # block re-raises, so nothing is swallowed). + for pipe in (unzstd.stdin, unzstd.stdout): + if pipe is not None: + try: + pipe.close() + except OSError: + pass + unzstd.wait() + raise + assert unzstd.stdin is not None + assert unzstd.stdout is not None + # The receive holds the decoded pipe now. Dropping this copy of its read + # end is load-bearing: with it open, a receive that dies early would + # never turn zstd's writes into EPIPE, and the whole pipeline — this + # loop included — would block on full pipes instead of failing. + unzstd.stdout.close() + sent = 0 + last_report = 0.0 + try: + with stream_path.open("rb") as stream: + while chunk := stream.read(8 << 20): + unzstd.stdin.write(chunk) + sent += len(chunk) + now = time.monotonic() + if now - last_report >= 0.5: + _write_phase_progress(ctx, sent / total if total else 1.0) + last_report = now + except BrokenPipeError: + pass + finally: + # Whatever happened above — including the EIO off a dying medium that + # the whole verify machinery exists to catch — neither child must be + # left blocked on an open stdin. A blocked receive would hold the + # caller's staging mount busy, and that umount is check=False: the + # mount would leak onto the target filesystem with nothing said. + unzstd_code, code = _close_receive(unzstd, proc) + if unzstd_code != 0 or code != 0: + # Both children share the error file, so the full story is in the + # detail either way. When both fail, the order of death is not + # knowable from exit codes alone -- a corrupt outer layer kills zstd + # and starves the receive, while a dead receive EPIPEs zstd -- so + # the headline names both instead of guessing a culprit, and the + # detail (zstd's "premature end", the receive's own complaint) + # disambiguates. + if unzstd_code != 0 and code != 0: + stage = "root image decompression and btrfs receive both" + elif code != 0: + stage = "btrfs receive" + else: + stage = "root image decompression" + raise RuntimeError(f"{stage} failed: {errors.read_text(errors='replace').strip()}") + _write_phase_progress(ctx, 1.0) + + +def _close_receive(unzstd: subprocess.Popen, proc: subprocess.Popen) -> tuple[int, int]: + """Close the pipeline's intake and reap both children, returning + (decompressor, receive) exit statuses. + + Never raises: it runs on the way out of a failing read, where the original + exception is the one worth keeping. Closing flushes, so a decompressor + that has already died answers with a BrokenPipeError of its own; on EOF it + drains what it holds and exits, which ends the receive's stdin in turn. + The receive's wait is unbounded because a receive that has read EOF is + committing the subvolume, which on slow media is legitimately slow and + must not be killed. + """ + if unzstd.stdin is not None: + try: + unzstd.stdin.close() + except OSError: + pass + return unzstd.wait(), proc.wait() + + +def _write_phase_progress(ctx: InstallContext, fraction: float) -> None: + """Record how far the current phase has come (0..1) in the state file the + dashboard polls. Best effort: progress display must never fail an install.""" + state_path = ctx.state_dir / "state.json" + try: + state = json.loads(state_path.read_text()) + except (OSError, ValueError): + return + state["phase_progress"] = max(0.0, min(1.0, fraction)) + try: + write_state(state_path, state) + except OSError: + pass + + def _configure_limine_boot(ctx: InstallContext, installer, config) -> None: if not arch.bootloader_enabled(config): return @@ -625,20 +1092,6 @@ def _drop_archinstall_zram_conf(ctx: InstallContext) -> None: zram_conf.unlink(missing_ok=True) -def _install_early_packages(installer) -> None: - bootstrap_packages = _early_bootstrap_packages() - user_seed_packages = _early_user_seed_packages() - - info(f"› installing early Omarchy packages: {', '.join(bootstrap_packages)}") - installer.add_additional_packages(bootstrap_packages) - - info(f"› installing LuaRocks prerequisites: {', '.join(EARLY_LUAROCKS_PACKAGES)}") - installer.add_additional_packages(EARLY_LUAROCKS_PACKAGES) - - info(f"› installing user seed packages: {', '.join(user_seed_packages)}") - installer.add_additional_packages(user_seed_packages) - - def _mount_offline_package_cache(ctx: InstallContext) -> None: """Let pacstrap consume bundled packages without copying them first. @@ -723,28 +1176,6 @@ def _unmask_mkinitcpio_pacman_hooks( info(f"warning: failed to restore pacman hook mask for {name}: {exc}") -def _runtime_package_list(ctx: InstallContext) -> list[str]: - """Selected Omarchy runtime package + every package in the ISO-bundled - base package list that isn't already installed early.""" - base_pkgs_file = Path("/usr/share/omarchy-iso/omarchy-base.packages") - pkgs = [_omarchy_runtime_package()] - already_installed = set(_early_packages()) | { - _omarchy_runtime_package(), - _omarchy_settings_package(), - _omarchy_nvim_package(), - "omarchy", - "omarchy-settings", - "omarchy-nvim", - } - for raw in base_pkgs_file.read_text().splitlines(): - s = raw.strip() - if not s or s.startswith("#"): - continue - if s not in already_installed and s not in pkgs: - pkgs.append(s) - return pkgs - - # ───────────────────────────────────────────────────────────────────────────── # Install intent helpers: normalize the Omarchy-specific part of the # configurator JSON so full-disk and pre-mounted installs feed the same boot @@ -1783,6 +2214,9 @@ def _validate_pre_mounted_filesystems(ctx: InstallContext) -> None: # ───────────────────────────────────────────────────────────────────────────── def create_factory_snapshot(ctx: InstallContext) -> None: + # The keyring unit writes into @; the snapshot must not catch it midway. + _join_target_keyring_init(ctx) + fstype = _findmnt_value(ctx.target, "FSTYPE") if fstype != "btrfs": info(f"› target root is {fstype or 'unknown'}, not btrfs; skipping factory snapshot") @@ -1920,10 +2354,16 @@ def cleanup_protected_state(ctx: InstallContext) -> None: if not ctx.is_protected: return - subprocess.run(["umount", "-R", str(ctx.target)], check=False, capture_output=True) - if Path("/dev/mapper/omarchy_root").exists(): - subprocess.run( - ["cryptsetup", "close", "omarchy_root"], - check=False, - capture_output=True, - ) + # Swapoff, umount -R, and close of the mappers the mounts were backed by; + # shared with the dashboard's pre-reboot release. omarchy_root is named + # explicitly because a failure between luksOpen and mount leaves it open + # with nothing in the mount table for the release to see. On failure the + # script names the holders on stderr; surface that in the install log. + result = subprocess.run( + ["omarchy-release-install-target", str(ctx.target), "omarchy_root"], + check=False, + capture_output=True, + text=True, + ) + for line in (result.stderr or "").splitlines(): + info(f"release: {line}") diff --git a/configs/efiboot/loader/entries/01-archiso-x86_64-linux.conf b/configs/efiboot/loader/entries/01-archiso-x86_64-linux.conf index bf6d18c26..5219d101e 100644 --- a/configs/efiboot/loader/entries/01-archiso-x86_64-linux.conf +++ b/configs/efiboot/loader/entries/01-archiso-x86_64-linux.conf @@ -2,4 +2,4 @@ title Omarchy (x86_64, UEFI) sort-key 01 linux /%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2 initrd /%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img -options archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% quiet splash xe.enable_panel_replay=0 initramfs_async=0 +options archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% copytoram=n quiet splash xe.enable_panel_replay=0 initramfs_async=0 diff --git a/configs/grub/grub.cfg b/configs/grub/grub.cfg index a0f67ef68..1122ccbc0 100644 --- a/configs/grub/grub.cfg +++ b/configs/grub/grub.cfg @@ -54,13 +54,13 @@ timeout_style=hidden menuentry "Omarchy (%ARCH%, ${archiso_platform})" --class arch --class gnu-linux --class gnu --class os --id 'archlinux' { set gfxpayload=keep - linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux-t2 archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% quiet splash xe.enable_panel_replay=0 initramfs_async=0 + linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux-t2 archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% copytoram=n quiet splash xe.enable_panel_replay=0 initramfs_async=0 initrd /%INSTALL_DIR%/boot/%ARCH%/initramfs-linux-t2.img } menuentry "Omarchy with speakup screen reader (%ARCH%, ${archiso_platform})" --hotkey s --class arch --class gnu-linux --class gnu --class os --id 'archlinux-accessibility' { set gfxpayload=keep - linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux-t2 archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% accessibility=on xe.enable_panel_replay=0 initramfs_async=0 + linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux-t2 archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% copytoram=n accessibility=on xe.enable_panel_replay=0 initramfs_async=0 initrd /%INSTALL_DIR%/boot/%ARCH%/initramfs-linux-t2.img } diff --git a/configs/grub/loopback.cfg b/configs/grub/loopback.cfg index 395a29113..f1d24328f 100644 --- a/configs/grub/loopback.cfg +++ b/configs/grub/loopback.cfg @@ -30,13 +30,13 @@ timeout_style=hidden menuentry "Omarchy (%ARCH%, ${archiso_platform})" --class arch --class gnu-linux --class gnu --class os --id 'archlinux' { set gfxpayload=keep - linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux-t2 archisobasedir=%INSTALL_DIR% img_dev=UUID=${archiso_img_dev_uuid} img_loop="${iso_path}" quiet splash xe.enable_panel_replay=0 initramfs_async=0 + linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux-t2 archisobasedir=%INSTALL_DIR% img_dev=UUID=${archiso_img_dev_uuid} img_loop="${iso_path}" copytoram=n quiet splash xe.enable_panel_replay=0 initramfs_async=0 initrd /%INSTALL_DIR%/boot/%ARCH%/initramfs-linux-t2.img } menuentry "Omarchy with speakup screen reader (%ARCH%, ${archiso_platform})" --hotkey s --class arch --class gnu-linux --class gnu --class os --id 'archlinux-accessibility' { set gfxpayload=keep - linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux-t2 archisobasedir=%INSTALL_DIR% img_dev=UUID=${archiso_img_dev_uuid} img_loop="${iso_path}" accessibility=on xe.enable_panel_replay=0 initramfs_async=0 + linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux-t2 archisobasedir=%INSTALL_DIR% img_dev=UUID=${archiso_img_dev_uuid} img_loop="${iso_path}" copytoram=n accessibility=on xe.enable_panel_replay=0 initramfs_async=0 initrd /%INSTALL_DIR%/boot/%ARCH%/initramfs-linux-t2.img } diff --git a/configs/profiledef.sh b/configs/profiledef.sh index d7d483cf7..69567f9d7 100644 --- a/configs/profiledef.sh +++ b/configs/profiledef.sh @@ -15,6 +15,7 @@ airootfs_image_type="squashfs" # Package archives in the offline mirror are already zstd-compressed. Storing # them in an outer stream saves little space but makes pacman decompress the # outer layer while hashing and extracting every package during installation. +# (The root image stream is a plain file on the ISO, outside the squashfs.) # # Everything else in the live root is zstd rather than xz. Squashfs decompresses # on the page-fault path through a single stream (CONFIG_SQUASHFS_DECOMP_SINGLE), @@ -35,9 +36,12 @@ file_permissions=( ["/root/.automated_script.sh"]="0:0:755" ["/root/.gnupg"]="0:0:700" ["/root/configurator"]="0:0:755" + ["/root/customize_airootfs.sh"]="0:0:755" ["/usr/local/bin/choose-mirror"]="0:0:755" ["/usr/local/bin/omarchy-cidata-load"]="0:0:755" + ["/usr/local/bin/omarchy-wait-root-image-verify"]="0:0:755" ["/usr/local/bin/omarchy-iso-cleanup-disk"]="0:0:755" + ["/usr/local/bin/omarchy-release-install-target"]="0:0:755" ["/usr/local/bin/omarchy-install-dashboard"]="0:0:755" ["/usr/local/bin/omarchy-install-diagnose-media"]="0:0:755" ["/usr/local/bin/omarchy-iso-install"]="0:0:755" diff --git a/configs/syslinux/archiso_pxe-linux.cfg b/configs/syslinux/archiso_pxe-linux.cfg index 8cf909258..b0cc919f8 100644 --- a/configs/syslinux/archiso_pxe-linux.cfg +++ b/configs/syslinux/archiso_pxe-linux.cfg @@ -1,3 +1,19 @@ +# PXE entries pin copytoram=n like every other boot entry: the root image the +# installer streams must stay reachable under /run/archiso/bootmnt. The NBD +# and NFS hooks would otherwise force copytoram=y (they only skip the copy +# when the cmdline says exactly n), but both keep the server's image tree +# mounted, so the stream is there. +# +# There is deliberately no HTTP entry: archiso's HTTP hook copies only the +# airootfs (plus optional checksums) into a tmpfs and never mounts the image +# tree, so the root image cannot exist on that path and an install from it +# would always fail at the pre-flight gate. +# +# cms_verify is deliberately absent too: these ISOs are not codesigned +# (mkarchiso is never given a certificate), and with the flag set but no +# .cms.sig beside the airootfs the archiso hook aborts into an emergency +# shell — every PXE boot would die there. + LABEL arch64_nbd TEXT HELP Boot the Omarchy install medium using NBD. @@ -6,7 +22,7 @@ ENDTEXT MENU LABEL Omarchy install medium (x86_64, NBD) LINUX ::/%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2 INITRD ::/%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img -APPEND archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% archiso_nbd_srv=${pxeserver} cms_verify=y initramfs_async=0 +APPEND archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% archiso_nbd_srv=${pxeserver} copytoram=n initramfs_async=0 SYSAPPEND 3 LABEL arch64_nfs @@ -17,16 +33,5 @@ ENDTEXT MENU LABEL Omarchy install medium (x86_64, NFS) LINUX ::/%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2 INITRD ::/%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img -APPEND archisobasedir=%INSTALL_DIR% archiso_nfs_srv=${pxeserver}:/run/archiso/bootmnt cms_verify=y initramfs_async=0 -SYSAPPEND 3 - -LABEL arch64_http -TEXT HELP -Boot the Omarchy live medium using HTTP. -It allows you to install Omarchy or perform system maintenance. -ENDTEXT -MENU LABEL Omarchy install medium (x86_64, HTTP) -LINUX ::/%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2 -INITRD ::/%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img -APPEND archisobasedir=%INSTALL_DIR% archiso_http_srv=http://${pxeserver}/ cms_verify=y initramfs_async=0 +APPEND archisobasedir=%INSTALL_DIR% archiso_nfs_srv=${pxeserver}:/run/archiso/bootmnt copytoram=n initramfs_async=0 SYSAPPEND 3 diff --git a/configs/syslinux/archiso_sys-linux.cfg b/configs/syslinux/archiso_sys-linux.cfg index 805a5a732..309c7f344 100644 --- a/configs/syslinux/archiso_sys-linux.cfg +++ b/configs/syslinux/archiso_sys-linux.cfg @@ -6,7 +6,7 @@ ENDTEXT MENU LABEL Omarchy install medium (x86_64, BIOS) LINUX /%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2 INITRD /%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img -APPEND archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% quiet splash xe.enable_panel_replay=0 initramfs_async=0 +APPEND archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% copytoram=n quiet splash xe.enable_panel_replay=0 initramfs_async=0 # Accessibility boot option LABEL arch64speech @@ -17,4 +17,4 @@ ENDTEXT MENU LABEL Omarchy install medium (x86_64, BIOS) with ^speech LINUX /%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2 INITRD /%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img -APPEND archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% accessibility=on xe.enable_panel_replay=0 initramfs_async=0 +APPEND archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% copytoram=n accessibility=on xe.enable_panel_replay=0 initramfs_async=0 diff --git a/test/integration b/test/integration index 3954354cb..4c8408a80 100755 --- a/test/integration +++ b/test/integration @@ -10,6 +10,9 @@ # scenario Basename of a test/integration.d/-test.sh file; # all scenarios run when none are named # --reuse-base Skip the install if a saved base image exists +# --bios Boot the VMs on legacy BIOS (SeaBIOS) instead of UEFI; the +# install then exercises the Limine MBR path. Base images are +# cached separately per firmware. # --port PORT Host port forwarded to guest SSH (default 2322) # --memory MB VM memory (default 8192) # --timeout SECS Install timeout (default 2400) @@ -27,10 +30,13 @@ export OMARCHY_INTEGRATION_SSH_PORT=2322 export OMARCHY_INTEGRATION_MEMORY=8192 export OMARCHY_INTEGRATION_INSTALL_TIMEOUT=2400 export OMARCHY_INTEGRATION_NO_PREVIEW=false +export OMARCHY_INTEGRATION_FIRMWARE="${OMARCHY_INTEGRATION_FIRMWARE:-uefi}" while (($#)); do case "$1" in --reuse-base) REUSE_BASE=true ;; + --bios) OMARCHY_INTEGRATION_FIRMWARE=bios ;; + --uefi) OMARCHY_INTEGRATION_FIRMWARE=uefi ;; --no-preview) OMARCHY_INTEGRATION_NO_PREVIEW=true ;; --port) OMARCHY_INTEGRATION_SSH_PORT="$2"; shift ;; --memory) OMARCHY_INTEGRATION_MEMORY="$2"; shift ;; @@ -67,7 +73,7 @@ else done fi -omarchy-pkg-add qemu-full edk2-ovmf socat imagemagick tesseract tesseract-data-eng mtools +omarchy-pkg-add qemu-full edk2-ovmf socat imagemagick tesseract tesseract-data-eng mtools libisoburn export OMARCHY_INTEGRATION_ISO OMARCHY_INTEGRATION_ISO=$(realpath "$ISO") diff --git a/test/integration.d/base-test.sh b/test/integration.d/base-test.sh index 10d4d2e2b..4a24b4065 100644 --- a/test/integration.d/base-test.sh +++ b/test/integration.d/base-test.sh @@ -23,10 +23,18 @@ GUEST_HOSTNAME="omarchy-test" SCENARIO="${SCENARIO:-$(basename "${0%-test.sh}")}" +# Firmware the VMs boot: uefi (OVMF, the default) or bios (SeaBIOS, legacy). +# BIOS exercises the Limine MBR boot path that only real hardware covered; set +# it with OMARCHY_INTEGRATION_FIRMWARE=bios or the runner's --bios flag. The +# base image and its caches are kept per-firmware so the two never collide. +FIRMWARE="${OMARCHY_INTEGRATION_FIRMWARE:-uefi}" 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" +if [[ $FIRMWARE == bios ]]; then + BASE_DIR+="-bios" +fi 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" @@ -36,6 +44,12 @@ CIDATA_IMG="$BASE_DIR/cidata.img" HTTP_PORT=$((SSH_PORT + 1)) HTTP_PID="" +# Scenario extensions to the network stack, appended verbatim (leading comma +# included) to the user netdev and the NIC device: the PXE scenario adds the +# built-in TFTP server to the former and a boot order to the latter. +NETDEV_EXTRA="" +NIC_EXTRA="" + mkdir -p "$BASE_DIR" "$RUN_DIR" QMP_SOCK=$(mktemp -u "${TMPDIR:-/tmp}/omarchy-integration-qmp.XXXXXX.sock") @@ -69,7 +83,9 @@ finish() { } base_image_ready() { - [[ -f $BASE_DISK && -f $BASE_OVMF && -f $SSH_KEY ]] + [[ -f $BASE_DISK && -f $SSH_KEY ]] || return 1 + # UEFI keeps its NVRAM vars alongside the disk; BIOS has none. + [[ $FIRMWARE != uefi || -f $BASE_OVMF ]] } # ---------------------------------------------------------------- vm control @@ -160,19 +176,28 @@ start_vm() { local disk="$1" serial="$2" shift 2 + # UEFI needs the OVMF firmware pair; BIOS boots QEMU's built-in SeaBIOS with + # no pflash at all. + local firmware_args=() + if [[ $FIRMWARE == uefi ]]; then + firmware_args=( + -drive if=pflash,format=raw,readonly=on,file="$OVMF_CODE" + -drive if=pflash,format=raw,file="$ACTIVE_OVMF" + ) + fi + 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" \ + "${firmware_args[@]}" \ -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 \ + -netdev "user,id=net0,hostfwd=tcp:127.0.0.1:$SSH_PORT-:22$NETDEV_EXTRA" \ + -device "virtio-net-pci,netdev=net0$NIC_EXTRA" \ -qmp "unix:$QMP_SOCK,server,nowait" \ -serial "file:$serial" \ -pidfile "$PIDFILE" \ @@ -185,8 +210,10 @@ start_vm() { # 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" + if [[ $FIRMWARE == uefi ]]; then + cp "$BASE_OVMF" "$RUN_DIR/OVMF_VARS.4m.fd" + ACTIVE_OVMF="$RUN_DIR/OVMF_VARS.4m.fd" + fi start_vm "$RUN_DIR/run.qcow2" "$RUN_DIR/serial.log" } @@ -353,6 +380,89 @@ EOF press ctrl-alt-f1 } +# Root SSH into the live ISO itself (not the installed system). The live root +# runs sshd and autologs in on tty1 with an empty password, but sshd refuses +# empty passwords, so authorize our key the same console-login way as above, +# on tty3 (tty1 is the installer's). +ssh_live_root() { + 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 \ + "root@127.0.0.1" "$@" +} + +bootstrap_live_root_ssh() { + log "Authorizing root SSH on the live ISO via console login" + + mkdir -p "$BASE_DIR/www" + cat >"$BASE_DIR/www/bootstrap-live" <>/root/.ssh/authorized_keys +chmod 600 /root/.ssh/authorized_keys +EOF + + (cd "$BASE_DIR/www" && exec python3 -m http.server "$HTTP_PORT" --bind 127.0.0.1 >/dev/null 2>&1) & + HTTP_PID=$! + + if [[ $FIRMWARE == bios ]]; then + # ISOLINUX cancels its auto-boot countdown on any keystroke, so the + # ctrl-alt-f3 below would strand the VM at the boot menu. Commit the + # highlighted default (the install medium) with Enter first; a few presses + # catch the menu whenever it renders, and once booted they are harmless + # newlines (still well before the installer dashboard comes up). + local i + for i in 1 2 3; do press ret; sleep 2; done + fi + + 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-live-console-timeout" + echo "Timed out waiting for a live console login prompt" >&2 + return 1 + fi + sleep 4 + done + + type_text root + press ret + sleep 2 + # The password is empty; login still prompts for it. + ocr_screen | grep -qi "password" && { press ret; sleep 2; } + + type_text "curl -fsS http://10.0.2.2:$HTTP_PORT/bootstrap-live -o /tmp/bs && bash /tmp/bs" + press ret + + waited=0 + until ssh_live_root true 2>/dev/null; do + if ! vm_running; then + echo "VM exited while waiting for live root SSH" >&2 + return 1 + fi + if ((waited >= 180)); then + capture_console "failure-live-ssh-timeout" + echo "Timed out waiting for root SSH on the live ISO" >&2 + return 1 + fi + sleep 2 + ((waited += 2)) + done + capture_console "success-live-root-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 @@ -500,48 +610,38 @@ detect_packages() { 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 +# Wait out an unattended cidata install to its reboot into the installed +# system: SSH answering as the guest user is the success signal (cidata's +# authorized_keys enables sshd there, and the live environment has no such +# user, so there is no false positive from the installer phase). Presses the +# Reboot Now prompt if one appears. Screenshot names take the given prefix so +# each caller's artifacts stay apart; on a stopped install the live system's +# log and state are saved best-effort (root SSH may not be authorized yet). +wait_for_unattended_install() { + local prefix="$1" + local waited=0 text progress_name 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 + capture_console "success-$prefix-first-boot" + return 0 fi text=$(ocr_screen) if grep -qi "Reboot Now" <<<"$text"; then log "Install finished. Confirming the reboot prompt." - capture_console "success-install-reboot" + capture_console "success-$prefix-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 + capture_console "failure-$prefix-stopped" + ssh_live_root "cat /var/log/omarchy-install.log" >"$RUN_DIR/omarchy-install.log" 2>/dev/null || true + ssh_live_root "cat /run/omarchy-install/state.json" >"$RUN_DIR/state.json" 2>/dev/null || true + echo "Install failed — artifacts saved to $RUN_DIR" >&2 return 1 fi @@ -551,13 +651,13 @@ install_phase() { fi if ((waited >= INSTALL_TIMEOUT)); then - capture_console "failure-install-timeout" + capture_console "failure-$prefix-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" + printf -v progress_name 'success-%s-progress-%04ds' "$prefix" "$waited" capture_console "$progress_name" echo " ... installing (${waited}s)" fi @@ -565,6 +665,36 @@ install_phase() { sleep 10 ((waited += 10)) done +} + +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 + if [[ $FIRMWARE == uefi ]]; then + cp "$OVMF_VARS_TEMPLATE" "$BASE_OVMF" + ACTIVE_OVMF="$BASE_OVMF" + fi + + # Boot the ISO from an optical drive under both firmwares: OVMF and SeaBIOS + # both boot an ide-cd reliably, and the boot-medium type has no bearing on + # what the BIOS run exercises (Limine's MBR install and boot). SeaBIOS will + # not boot the isohybrid image off a fallback USB device here, so USB is not + # worth the flakiness. The copytoram-off / USB path is covered by unit tests. + 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 + + wait_for_unattended_install install || return 1 log "Installed system is up. Saving base image." stop_vm diff --git a/test/integration.d/corrupt-image-test.sh b/test/integration.d/corrupt-image-test.sh new file mode 100755 index 000000000..fc56a1969 --- /dev/null +++ b/test/integration.d/corrupt-image-test.sh @@ -0,0 +1,158 @@ +#!/bin/bash +# +# A corrupt install medium is refused before the disk is touched. The root +# image ships on the ISO with its sha256 beside it; omarchy-root-image-verify +# checks it at boot and the installer's pre-flight phase takes that verdict. +# Flip one byte inside the image stream on a copy of the ISO (a badly +# flashed stick's damage, for real), autoinstall from it, and assert: the +# unit fails, the install halts in "Preparing install target" telling the +# user to re-flash, nothing after that phase ran, and the target disk still +# has no partition table. Damaging the artifact rather than the recorded +# digest proves the digest is sensitive to the shipped bytes at that +# offset; the fixture separately proves the pristine stream matches its +# recorded digest host-side, so a build hashing the wrong or a stale file +# fails even in --reuse-base and standalone runs, where no install ever +# boots the untouched ISO. (That the verify truly reads the whole multi-GB +# image is the slow-medium scenario's business, where the read is what is +# being timed.) +# +# Boots the ISO itself, not the installed base image, so it needs no base. + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +CORRUPT_ISO="$BASE_DIR/corrupt.iso" +STREAM=arch/x86_64/omarchy-root.btrfs.zst +VERIFY_UNIT=omarchy-root-image-verify.service +STATE=/run/omarchy-install/state.json + +# ------------------------------------------------------------------ fixture + +# A copy of the ISO with one byte flipped inside the root image stream +# itself. ISO9660 files are contiguous extents with no per-file integrity +# data, so patching in place leaves everything else on the medium intact +# (cp --reflink makes the copy free on btrfs). +# +# The stream's extent comes from the ISO9660 directory records themselves: +# xorriso reports the file's start LBA and byte size, so the flip is placed +# by filesystem metadata, not by searching for content (the zstd frame +# magic opens every mirror package and countless squashfs blocks on this +# ISO, so a content search would need fingerprinting games). ISO9660 files +# are single contiguous extents, which is what makes lba*2048 + offset a +# byte address inside this file and no other. +corrupt_iso() { + local lba size start off orig flipped + + log "Copying the ISO and corrupting one byte inside the root image stream" + rm -f "$CORRUPT_ISO" + cp --reflink=auto "$ISO" "$CORRUPT_ISO" + + # "File data lba: xt , startlba , blocks , filesize , path" + # || true: under set -e a read off an empty pipe would kill the scenario + # before the guard below could say what went wrong. + read -r lba size < <(xorriso -indev "$ISO" -find "/$STREAM" -exec report_lba 2>/dev/null | + awk -F, '/File data lba/ { gsub(/ /, ""); print $2, $4 }') || true + [[ ${lba:-} =~ ^[0-9]+$ && ${size:-} =~ ^[0-9]+$ ]] || + { echo "xorriso could not report the extent of $STREAM" >&2; return 1; } + start=$((lba * 2048)) + + # Before damaging anything, prove the pristine artifact verifies: the + # digest recorded at build time must match the bytes that ship, computed + # here over the extent just located. Every booted install checks this + # implicitly, but --reuse-base skips the install and this scenario runs + # standalone, and in those modes nothing else would catch a build that + # hashed the wrong or a stale file. + local recorded computed + recorded=$(xorriso -osirrox on -indev "$ISO" -extract "/$STREAM.sha256" "$BASE_DIR/stream.sha256" 2>/dev/null && + awk '{print $1; exit}' "$BASE_DIR/stream.sha256") || true + [[ ${recorded:-} =~ ^[0-9a-f]{64}$ ]] || + { echo "could not read the recorded digest for $STREAM" >&2; return 1; } + computed=$(dd if="$ISO" bs=1M iflag=skip_bytes,count_bytes skip="$start" count="$size" status=none | sha256sum | cut -d' ' -f1) + [[ $computed == "$recorded" ]] || + { echo "pristine image does not match its recorded digest (recorded ${recorded:0:8}..., shipped ${computed:0:8}...)" >&2; return 1; } + log "Pristine stream matches its recorded digest (${recorded:0:8}...)" + + # Deep enough to model bit-rot in the payload, and provably inside the + # file's extent; +1 mod 256 so the byte always changes. + off=$((start + 1048576)) + ((1048576 < size)) || { echo "stream unexpectedly small: $size bytes" >&2; return 1; } + orig=$(dd if="$CORRUPT_ISO" bs=1 skip="$off" count=1 status=none | od -An -tu1 | tr -d ' ') + flipped=$(( (orig + 1) % 256 )) + printf "\\$(printf '%03o' "$flipped")" | + dd of="$CORRUPT_ISO" bs=1 seek="$off" conv=notrunc status=none + log "Stream extent at LBA $lba ($size bytes); byte at ISO offset $off flipped ($orig -> $flipped)" +} + +# -------------------------------------------------------------------- phases + +install_from_corrupt_medium() { + [[ -f $SSH_KEY ]] || ssh-keygen -t ed25519 -N "" -q -C "omarchy-integration" -f "$SSH_KEY" + detect_packages + build_cidata + + qemu-img create -f qcow2 "$RUN_DIR/disk.qcow2" 40G >/dev/null + if [[ $FIRMWARE == uefi ]]; then + cp "$OVMF_VARS_TEMPLATE" "$RUN_DIR/OVMF_VARS.4m.fd" + ACTIVE_OVMF="$RUN_DIR/OVMF_VARS.4m.fd" + fi + + log "Autoinstalling from the corrupt medium (headless)" + start_vm "$RUN_DIR/disk.qcow2" "$RUN_DIR/serial.log" \ + -drive "file=$CORRUPT_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 + + bootstrap_live_root_ssh + + log "Waiting for the installer to stop" + local waited=0 + until ssh_live_root 'grep -q "installer child exited" /var/log/omarchy-install.log' 2>/dev/null; do + if ! vm_running; then + echo "VM exited while waiting for the installer" >&2 + return 1 + fi + if ((waited >= 600)); then + capture_console "failure-installer-timeout" + echo "Timed out waiting for the installer to stop" >&2 + return 1 + fi + sleep 5 + ((waited += 5)) + done + sleep 2 + capture_console "success-installer-stopped" + ssh_live_root "cat /var/log/omarchy-install.log" >"$RUN_DIR/omarchy-install.log" 2>/dev/null || true + ssh_live_root "cat $STATE" >"$RUN_DIR/state.json" 2>/dev/null || true + ssh_live_root "journalctl -b -u $VERIFY_UNIT -o short-precise --no-pager" >"$RUN_DIR/verify-unit.journal" 2>/dev/null || true +} + +screen_shows() { + ocr_screen | grep -qi "$1" +} + +assert_refused() { + check "verify unit failed on the corrupt image" \ + ssh_live_root "systemctl show -p Result --value $VERIFY_UNIT | grep -qx exit-code" + check "install halted in the pre-flight phase" \ + ssh_live_root "jq -e '[.phases[] | select(.status == \"failed\") | .name] == [\"Preparing install target\"]' $STATE" + check "the error tells the user to re-flash the medium" \ + ssh_live_root "jq -r '.phases[] | select(.status == \"failed\") | .error' $STATE | grep -q 'install medium is corrupt: re-flash it'" + check "the error carries sha256sum's verdict" \ + ssh_live_root "jq -r '.phases[] | select(.status == \"failed\") | .error' $STATE | grep -q 'did NOT match'" + check "nothing ran after the pre-flight phase" \ + ssh_live_root "jq -e '[.phases[] | select(.status == \"ok\") | .name] == [\"Preparing live environment\"]' $STATE" + check "target disk has no partition table" \ + ssh_live_root "! lsblk -rno TYPE /dev/vda | grep -qx part && ! blkid /dev/vda" + check "dashboard shows the installation stopped" \ + screen_shows "installation stopped" + check "dashboard shows the re-flash advice" \ + screen_shows "re-flash" +} + +# ---------------------------------------------------------------------- main + +corrupt_iso +install_from_corrupt_medium +assert_refused +rm -f "$CORRUPT_ISO" +finish diff --git a/test/integration.d/factory-reset-test.sh b/test/integration.d/factory-reset-test.sh index ed6cc8789..ba42582fa 100755 --- a/test/integration.d/factory-reset-test.sh +++ b/test/integration.d/factory-reset-test.sh @@ -13,6 +13,14 @@ source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +# This scenario is about a shared UEFI ESP (Windows + a foreign Linux under +# EFI/Linux, efibootmgr entries) — concepts that do not exist on a legacy BIOS +# install. Skip it there rather than fail its UEFI-specific assertions. +if [[ $FIRMWARE != uefi ]]; then + log "factory-reset is UEFI-only (shared-ESP dual boot); skipping under $FIRMWARE" + exit 0 +fi + FOREIGN_ID="fedcfedcfedcfedcfedcfedcfedcfedc" base_image_ready || { echo "No base image; run this through ./test/integration" >&2; exit 1; } diff --git a/test/integration.d/firmware-boot-test.sh b/test/integration.d/firmware-boot-test.sh new file mode 100755 index 000000000..d98fa1b31 --- /dev/null +++ b/test/integration.d/firmware-boot-test.sh @@ -0,0 +1,56 @@ +#!/bin/bash +# +# The installed system booted, and it booted the way the firmware demands: +# under UEFI it comes up with an EFI runtime and a Limine EFI binary on the +# ESP; under legacy BIOS (--bios) it comes up with no EFI runtime and Limine's +# BIOS stage on the target disk's MBR path. The BIOS case is the one only real +# hardware covered before — the base install already proved the MBR boots (the +# install phase waits for SSH after the auto-reboot), and this pins down that +# the right bootloader was installed and the machine really is in BIOS mode. +# +# Runs against the base image, so it inherits whatever firmware that base was +# installed under: `./test/integration firmware-boot` (UEFI) or +# `./test/integration firmware-boot --bios`. + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +base_image_ready || { echo "No base image; run this through ./test/integration" >&2; exit 1; } + +verify_firmware_boot() { + log "Booting the installed base image ($FIRMWARE) to check its bootloader" + start_vm_from_base + wait_for_ssh "$BOOT_TIMEOUT" + + check "installed system is reachable (it booted, not the install medium)" \ + ssh_guest "test ! -e /run/archiso" + + ssh_sudo "find /boot -maxdepth 3 -print" >"$RUN_DIR/boot-tree.txt" 2>/dev/null || true + + # /boot is a root-only FAT mount, so its contents are checked with sudo. + if [[ $FIRMWARE == bios ]]; then + check "booted in legacy BIOS mode (no EFI runtime)" \ + ssh_guest "test ! -d /sys/firmware/efi" + check "Limine BIOS stage is installed under /boot" \ + ssh_sudo "test -f /boot/limine/limine-bios.sys" + check "Limine's boot code is in the disk's MBR" \ + ssh_sudo "dd if=/dev/vda bs=512 count=2048 2>/dev/null | grep -qa -i limine" + # Note: the install also drops EFI artifacts under /boot/EFI (a UEFI-machine + # fallback); they are inert under BIOS, so their presence is not asserted + # either way. What matters is that the machine booted via the BIOS/MBR path, + # which the checks above establish. + else + check "booted in UEFI mode (EFI runtime present)" \ + ssh_guest "test -d /sys/firmware/efi" + check "a Limine EFI binary is on the ESP" \ + ssh_sudo "test -e /boot/EFI/limine/limine_x64.efi -o -e /boot/EFI/BOOT/BOOTX64.EFI" + check "efibootmgr has a Limine entry" \ + ssh_sudo "efibootmgr | grep -qi limine" + fi + + capture_console "success-firmware-$FIRMWARE" +} + +# ---------------------------------------------------------------------- main + +verify_firmware_boot +finish diff --git a/test/integration.d/pxe-test.sh b/test/integration.d/pxe-test.sh new file mode 100755 index 000000000..7aed05377 --- /dev/null +++ b/test/integration.d/pxe-test.sh @@ -0,0 +1,127 @@ +#!/bin/bash +# +# The ISO's PXE path really boots and really installs: PXELINUX routed into +# the PXE menu (whichsys.c32) brings up a live environment whose medium is an +# NBD export, the pre-flight gate hashes the multi-GB root image across the +# network, and the unattended cidata install streams it to disk — the LAN +# install the pinned copytoram=n on the PXE entries exists to keep working. +# +# Mechanics: no boot medium is attached to the VM. QEMU's user-mode network +# stack plays the LAN — its built-in TFTP server hands iPXE lpxelinux.0 and +# the boot files straight out of the ISO's own /boot/syslinux and /arch/boot +# trees, and qemu-nbd serves the ISO itself as the export the initramfs dials +# back to (the guest reaches the host at 10.0.2.2, which is also what the +# archiso_pxe_common hook resolves ${pxeserver} to from the DHCP handshake). +# The one file the ISO does not carry is pxelinux.cfg/default — PXELINUX's +# config entry point, which every real deployment writes too; ours is a +# one-line INCLUDE of the ISO's own syslinux.cfg. +# +# Two fixed conditions, both inherent to the boot path under test: +# - BIOS only: the PXE config is syslinux and the ISO has no UEFI netboot +# path, so the scenario forces SeaBIOS regardless of the runner's flag. +# - Port 10809: archiso_nbd_srv carries an address only (${pxeserver}), so +# nbd-client always dials the well-known NBD port; qemu-nbd must own it +# on the host loopback for the duration of the run. +# +# Boots the ISO itself (over the wire), so it needs no base image. + +export OMARCHY_INTEGRATION_FIRMWARE=bios + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +# The TFTP root and NBD export come from the same rig bin/omarchy-iso-boot-pxe +# uses, so what this scenario proves is exactly what the hands-on tool boots. +source "$ROOT/bin/lib/netboot.sh" + +TFTP_ROOT="$RUN_DIR/tftp" + +pxe_cleanup() { + local status=$? + [[ -n $NBD_PID ]] && kill "$NBD_PID" 2>/dev/null || true + cleanup + return $status +} +trap pxe_cleanup EXIT + +nbd_dialed() { + [[ -n $(ss -Htn "sport = :$NBD_PORT") ]] +} + +# -------------------------------------------------------------------- phases + +netboot_into_installer() { + [[ -f $SSH_KEY ]] || ssh-keygen -t ed25519 -N "" -q -C "omarchy-integration" -f "$SSH_KEY" + detect_packages + build_cidata + + qemu-img create -f qcow2 "$RUN_DIR/disk.qcow2" 40G >/dev/null + + log "Netbooting with no attached install medium (headless)" + NETDEV_EXTRA=",tftp=$TFTP_ROOT,bootfile=/boot/syslinux/lpxelinux.0" + NIC_EXTRA=",bootindex=2" + start_vm "$RUN_DIR/disk.qcow2" "$RUN_DIR/serial.log" \ + -drive "file=$CIDATA_IMG,format=raw,if=none,id=cidata" \ + -device usb-storage,drive=cidata + + # Unlike the ISO's boot menu, the PXE menu has no auto-boot timeout, so + # commit the highlighted default — the NBD entry leads the list. + wait_for_screen "NBD" 300 + capture_console "success-pxe-01-menu" + press ret + + # The kernel and initramfs now cross the TFTP link, which is slow, and the + # boot shows no OCR-able text until much later. The first hard evidence the + # initramfs is up is it dialing the NBD export — gate on that from the host + # side before driving the console. + log "Waiting for the initramfs to dial the NBD export" + local waited=0 + until nbd_dialed; do + if ! vm_running; then + echo "VM exited before connecting to the NBD export" >&2 + return 1 + fi + if ((waited >= 900)); then + capture_console "failure-nbd-connect-timeout" + echo "Timed out waiting for the guest to dial the NBD export" >&2 + return 1 + fi + sleep 5 + ((waited += 5)) + done + log "NBD connection established" + + bootstrap_live_root_ssh +} + +assert_nbd_medium() { + # The NBD hook hands /dev/nbd0 to the mount handler, but the entry's + # archisosearchuuid makes the archiso hook re-resolve by UUID — and on an + # isohybrid image that lands on the partition (nbd0p1), the same way a USB + # stick boots from sdX1. Either is the NBD transport. + check "live medium is NBD-backed" \ + ssh_live_root "findmnt -no SOURCE /run/archiso/bootmnt | grep -qxE '/dev/nbd0(p[0-9]+)?'" + check "kernel cmdline pinned copytoram=n" \ + ssh_live_root "grep -q 'copytoram=n' /proc/cmdline" + check "root image stream is reachable on the netboot medium" \ + ssh_live_root "test -f /run/archiso/bootmnt/arch/x86_64/omarchy-root.btrfs.zst" +} + +assert_installed_system() { + check "installed system is reachable (not the live medium)" \ + ssh_guest "test ! -e /run/archiso" + check "booted in legacy BIOS mode" \ + ssh_guest "test ! -d /sys/firmware/efi" + + ssh_sudo "cat /var/log/omarchy-install.log" >"$RUN_DIR/omarchy-install.log" 2>/dev/null || true +} + +# ---------------------------------------------------------------------- main + +log "Assembling the TFTP root from the ISO's own netboot files" +assemble_tftp_root "$ISO" "$TFTP_ROOT" +log "Serving the ISO as NBD export 'archiso' on 127.0.0.1:$NBD_PORT" +serve_nbd "$ISO" +netboot_into_installer +assert_nbd_medium +wait_for_unattended_install pxe +assert_installed_system +finish diff --git a/test/integration.d/slow-medium-test.sh b/test/integration.d/slow-medium-test.sh new file mode 100755 index 000000000..576cbdd30 --- /dev/null +++ b/test/integration.d/slow-medium-test.sh @@ -0,0 +1,176 @@ +#!/bin/bash +# +# A medium too slow to read is named as such, with the disk untouched. The +# boot-time verify unit carries a size-based TimeoutStartSec so a stalling +# stick fails the pre-flight gate instead of hanging the install forever; +# this scenario forces that timeout and asserts the user reads "install +# medium is too slow" — not a generic failure. The regression it guards: a +# start timeout makes systemd STOP the unit, and on a dying medium the stop +# itself drags (SIGTERM cannot land on a read stuck in the driver), so the +# unit reports 'deactivating' long before 'failed'; the wait helper used to +# classify that window as "did not run". +# +# Mechanics: the ISO is booted unmodified. A systemd credential injected via +# SMBIOS type 11 adds a drop-in shrinking TimeoutStartSec — named 99-* so it +# sorts after the build-time 50-size-timeout.conf and wins the merge. The +# medium is throttled at boot so the hash cannot finish early, then choked +# over QMP once the installer is waiting at the gate: the hash is mid-read +# when the timeout's stop arrives, exactly like a dying stick. +# +# Boots the ISO itself, not the installed base image, so it needs no base. + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +VERIFY_UNIT=omarchy-root-image-verify.service +STATE=/run/omarchy-install/state.json + +# Slow enough that hashing the multi-GB image cannot beat the timeout, fast +# enough that the live system boots promptly: the image needs 90+ s at this +# rate while the unit times out at 180 s only because the choke below stops +# the hash long before either. +BOOT_THROTTLE_BPS=$((40 * 1024 * 1024)) + +# From unit start (early boot): long enough that SSH is up and the choke has +# landed first, short enough to keep the scenario brisk. +TIMEOUT_DROPIN=$'[Service]\nTimeoutStartSec=180\n' + +# ------------------------------------------------------------------ fixture + +# QMP is line-delimited JSON: the whole command must be a single line, and +# qmp() swallows errors, so read the response — a failed choke would let the +# hash win the race against the timeout and pass the gate legitimately. +throttle_medium() { + local bps="$1" resp + resp=$(qmp "\"block_set_io_throttle\", \"arguments\": {\"device\": \"cdrom0\", \"bps\": $bps, \"bps_rd\": 0, \"bps_wr\": 0, \"iops\": 0, \"iops_rd\": 0, \"iops_wr\": 0}") + if [[ $resp == *'"error"'* || $resp != *'"return"'* ]]; then + echo "block_set_io_throttle failed: $resp" >&2 + return 1 + fi +} + +# -------------------------------------------------------------------- phases + +install_from_slow_medium() { + local cred + cred=$(base64 -w0 <<<"$TIMEOUT_DROPIN") + + [[ -f $SSH_KEY ]] || ssh-keygen -t ed25519 -N "" -q -C "omarchy-integration" -f "$SSH_KEY" + detect_packages + build_cidata + + qemu-img create -f qcow2 "$RUN_DIR/disk.qcow2" 40G >/dev/null + if [[ $FIRMWARE == uefi ]]; then + cp "$OVMF_VARS_TEMPLATE" "$RUN_DIR/OVMF_VARS.4m.fd" + ACTIVE_OVMF="$RUN_DIR/OVMF_VARS.4m.fd" + fi + + log "Autoinstalling from a throttled medium (headless)" + start_vm "$RUN_DIR/disk.qcow2" "$RUN_DIR/serial.log" \ + -drive "file=$ISO,media=cdrom,if=none,format=raw,id=cdrom0,throttling.bps-total=$BOOT_THROTTLE_BPS" \ + -device ide-cd,drive=cdrom0,bootindex=2 \ + -drive "file=$CIDATA_IMG,format=raw,if=none,id=cidata" \ + -device usb-storage,drive=cidata \ + -smbios "type=11,value=io.systemd.credential.binary:systemd.unit-dropin.${VERIFY_UNIT}~99-test-timeout=$cred" + + bootstrap_live_root_ssh + + check "shrunk timeout is in effect" \ + ssh_live_root "systemctl show -p TimeoutStartUSec --value $VERIFY_UNIT | grep -qx 3min" + + # The choke must land while the installer is already waiting at the gate: + # the field failure is a user parked on the pre-flight phase when the + # timeout hits, with the helper actively polling the unit through systemd's + # slow stop of it. Choking earlier stalls the installer's own climb to the + # gate, and it then only samples the unit after the stop has settled — + # which hides exactly the window this scenario exists to pin down. + log "Waiting for the installer to reach the verify gate" + local gated=0 + until ssh_live_root "jq -e '.current_phase == \"Preparing install target\"' $STATE" >/dev/null 2>&1; do + if ! vm_running; then + echo "VM exited before the installer reached the verify gate" >&2 + return 1 + fi + if ((gated >= 300)); then + capture_console "failure-gate-timeout" + echo "Timed out waiting for the installer to reach the verify gate" >&2 + return 1 + fi + sleep 2 + ((gated += 2)) + done + + # If the hash already finished, the gate passes legitimately and nothing + # below tests the timeout; the throttle above is sized to prevent this. + check "verify unit is still hashing when the medium dies" \ + ssh_live_root "systemctl show -p ActiveState --value $VERIFY_UNIT | grep -qx activating" + + log "Choking the medium to 64 KB/s" + # Slow enough that the hash cannot finish and the stop of a mid-read + # process takes visible seconds, fast enough that no single request + # outlives the guest kernel's 30 s command timeout — 1 KB/s turns reads + # into I/O errors and the unit fails as corrupt instead of slow. + throttle_medium 65536 + + log "Waiting for the installer to stop" + local waited=0 + until ssh_live_root 'grep -q "installer child exited" /var/log/omarchy-install.log' 2>/dev/null; do + if ! vm_running; then + echo "VM exited while waiting for the installer" >&2 + return 1 + fi + if ((waited >= 600)); then + capture_console "failure-installer-timeout" + echo "Timed out waiting for the installer to stop" >&2 + return 1 + fi + sleep 5 + ((waited += 5)) + done + + # Lift the choke so the stuck read drains and the unit can settle into its + # terminal state, and so the assertions below are not themselves throttled. + throttle_medium 0 + local settled=0 + until ssh_live_root "systemctl show -p ActiveState --value $VERIFY_UNIT | grep -qx failed" 2>/dev/null; do + if ((settled >= 120)); then + break + fi + sleep 2 + ((settled += 2)) + done + + sleep 2 + capture_console "success-installer-stopped" + ssh_live_root "cat /var/log/omarchy-install.log" >"$RUN_DIR/omarchy-install.log" 2>/dev/null || true + ssh_live_root "cat $STATE" >"$RUN_DIR/state.json" 2>/dev/null || true + ssh_live_root "journalctl -b -u $VERIFY_UNIT -o short-precise --no-pager" >"$RUN_DIR/verify-unit.journal" 2>/dev/null || true +} + +screen_shows() { + ocr_screen | grep -qi "$1" +} + +assert_named_slow() { + check "verify unit ended in a start timeout" \ + ssh_live_root "systemctl show -p Result --value $VERIFY_UNIT | grep -qx timeout" + check "install halted in the pre-flight phase" \ + ssh_live_root "jq -e '[.phases[] | select(.status == \"failed\") | .name] == [\"Preparing install target\"]' $STATE" + check "the error names the slow medium" \ + ssh_live_root "jq -r '.phases[] | select(.status == \"failed\") | .error' $STATE | grep -q 'install medium is too slow: try another USB stick or port'" + check "nothing ran after the pre-flight phase" \ + ssh_live_root "jq -e '[.phases[] | select(.status == \"ok\") | .name] == [\"Preparing live environment\"]' $STATE" + check "target disk has no partition table" \ + ssh_live_root "! lsblk -rno TYPE /dev/vda | grep -qx part && ! blkid /dev/vda" + # The stopped-banner renders red on dark, which OCR misreads; the log + # carries the dashboard's own record of the stop. + check "dashboard reported the installer stopped" \ + grep -q "installer child exited with status 1" "$RUN_DIR/omarchy-install.log" + check "dashboard shows the slow-medium advice" \ + screen_shows "too slow" +} + +# ---------------------------------------------------------------------- main + +install_from_slow_medium +assert_named_slow +finish diff --git a/test/unit/boot-cmdline-test.sh b/test/unit/boot-cmdline-test.sh new file mode 100644 index 000000000..096330a80 --- /dev/null +++ b/test/unit/boot-cmdline-test.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# +# Every boot entry that loads the live system must pin copytoram=n. The +# archiso hook defaults to copytoram=auto, which copies the airootfs to RAM +# and unmounts the boot medium whenever the image is under 4 GiB and the +# machine has RAM to spare -- the root image the installer streams from that +# medium then vanishes (seen on a ThinkPad X200s booting from USB; the QEMU +# tests never hit it because they boot the ISO as an optical drive). +# +# loopback.cfg counts: mkarchiso ships it at /boot/grub/loopback.cfg, which is +# how Ventoy and a hand-written GRUB entry boot the ISO as a file on a disk. +# That path loop-mounts the image, so archisodevice is /dev/loopN rather than +# /dev/sr*, and the auto rule fires there too. It is matched on archisobasedir +# rather than archisosearchuuid because it finds the medium by img_dev/img_loop. +# +# The PXE entries count too: the NBD and NFS hooks force copytoram=y unless +# the cmdline says exactly n, and both keep the server's image tree mounted, +# so pinned they can still stream the root image. HTTP cannot -- its hook only +# downloads the airootfs into a tmpfs -- so no entry may use archiso_http_srv. +# +# No entry may carry cms_verify either: these ISOs are not codesigned +# (mkarchiso is never given a certificate), and the archiso hook aborts into +# an emergency shell when the flag is set with no .cms.sig on the medium. The +# releng profile the build seeds from ships it on the PXE entries, so a +# config resync would quietly reintroduce it. + +set -euo pipefail + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd) +cd "$ROOT" + +entries=$(grep -rhE '^\s*(APPEND|linux|options)\s.*archisobasedir=' \ + configs/syslinux/archiso_sys-linux.cfg configs/syslinux/archiso_pxe-linux.cfg \ + configs/grub/grub.cfg configs/grub/loopback.cfg configs/efiboot/loader/entries) + +count=$(printf '%s\n' "$entries" | wc -l) +[ "$count" -ge 9 ] || { echo "expected at least 9 boot entries, found $count"; exit 1; } + +if grep -rq archiso_http_srv configs/syslinux configs/grub configs/efiboot; then + echo "HTTP PXE entry found: that path copies only the airootfs to RAM, so the root image is never on the medium and an install can never succeed" + exit 1 +fi + +if grep -rhE '^[^#]*cms_verify' configs/syslinux configs/grub configs/efiboot | grep -q .; then + echo "cms_verify found: the ISO is not codesigned, so any boot entry carrying it aborts into an emergency shell" + exit 1 +fi + +fail=0 +while IFS= read -r line; do + if ! grep -qE '(^|[[:space:]])copytoram=n([[:space:]]|$)' <<<"$line"; then + echo "boot entry without copytoram=n: $line" + fail=1 + fi +done <<<"$entries" + +[ "$fail" -eq 0 ] && echo "ok: $count boot entries pin copytoram=n" +exit "$fail" diff --git a/test/unit/dashboard-release-gate-test.sh b/test/unit/dashboard-release-gate-test.sh new file mode 100755 index 000000000..700bc63c0 --- /dev/null +++ b/test/unit/dashboard-release-gate-test.sh @@ -0,0 +1,157 @@ +#!/bin/bash +# +# The dashboard asks omarchy-release-install-target to let go of the target +# before it offers the install medium's removal, and that release's verdict +# decides two things the user acts on: whether the finish screen says the +# stick may come out, and whether the button does the medium-free +# `reboot -ff` or the graceful reboot that still pages off it. A release that +# failed while the screen said otherwise is how a machine gets its stick +# pulled mid-read, so the verdict is tested here rather than trusted. +# +# The dashboard's own driver is a screenful of top-level code, so the file is +# sourced up to it: everything above is definitions, which is all this needs. + +set -euo pipefail + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd) +DASHBOARD="$ROOT/configs/airootfs/usr/local/bin/omarchy-install-dashboard" + +fails=0 +check() { # desc, expected, actual + local desc=$1 want=$2 got=$3 + if [[ $got != "$want" ]]; then + echo "FAIL: $desc (want '$want', got '$got')"; fails=1; return + fi + echo "ok: $desc" +} + +check_has() { # desc, needle, haystack + local desc=$1 needle=$2 hay=$3 + if [[ $hay != *"$needle"* ]]; then + echo "FAIL: $desc (missing '$needle' in: $hay)"; fails=1; return + fi + echo "ok: $desc" +} + +check_absent() { # desc, needle, haystack + local desc=$1 needle=$2 hay=$3 + if [[ $hay == *"$needle"* ]]; then + echo "FAIL: $desc (unexpected '$needle' in: $hay)"; fails=1; return + fi + echo "ok: $desc" +} + +BOX=$(mktemp -d) +trap 'rm -rf "$BOX"' EXIT +mkdir -p "$BOX/bin" "$BOX/omarchy" +printf 'OMARCHY\n' >"$BOX/omarchy/logo.txt" +printf '{"target": "%s/mnt", "started_at": 0, "finished_at": 62}\n' "$BOX" >"$BOX/state.json" + +# The release helper the dashboard shells out to, standing in for the real +# sweep: it echoes the diagnosis a failing sweep writes to stderr and takes +# its exit status from a fixture, which is the whole contract between them. +cat >"$BOX/bin/omarchy-release-install-target" <<'EOF' +#!/bin/bash +echo "release-helper $*" >>"$BOX/calls" +echo "could not unmount $1; holders: init(1)" >&2 +exit "$(cat "$BOX/release_rc")" +EOF +cat >"$BOX/bin/systemctl" <<'EOF' +#!/bin/bash +echo "systemctl $*" >>"$BOX/calls" +EOF +cat >"$BOX/bin/reboot" <<'EOF' +#!/bin/bash +echo "reboot $*" >>"$BOX/calls" +EOF +cat >"$BOX/bin/ttfx" <<'EOF' +#!/bin/bash +exit 0 +EOF +# Just enough jq that the test does not hinge on the host having the real +# one: the target query answers from the state file, anything else answers +# empty (install_duration then falls through to its log parsing). +cat >"$BOX/bin/jq" <<'EOF' +#!/bin/bash +prog= +for arg in "$@"; do + case $arg in -*) ;; *) prog=$arg; break ;; esac +done +if [[ $prog == *.target* ]]; then + target=$(sed -n 's/.*"target": *"\([^"]*\)".*/\1/p' "${@: -1}") + echo "${target:-/mnt}" +fi +EOF +chmod +x "$BOX"/bin/* + +# The dashboard's definitions, without the driver that would run an install. +awk '/^\[\[ -e \$TTY_PATH \]\] \|\| exit 2$/ { exit } { print }' "$DASHBOARD" >"$BOX/dashboard-defs.sh" + +# Run one scenario: source the definitions, set the release verdict the way +# the given helper exit status would, call $1, and leave the tty output, the +# log and the recorded calls behind for the parent to assert on. +scenario() { # function-to-call, release_rc -> sets TTY_OUT, LOG_OUT, CALLS + local fn=$1 release_rc=$2 reader + echo "$release_rc" >"$BOX/release_rc" + : >"$BOX/calls" + : >"$BOX/install.log" + # The tty is a fifo, not a file: the dashboard reopens it with > for every + # write, which a real console shrugs off and a regular file answers by + # truncating away everything drawn before. An fd held open on the write end + # keeps the reader alive across those reopens. + : >"$BOX/tty.out" + rm -f "$BOX/tty.fifo" + mkfifo "$BOX/tty.fifo" + cat <"$BOX/tty.fifo" >>"$BOX/tty.out" & + reader=$! + ( + export BOX + PATH="$BOX/bin:$PATH" + exec 9>"$BOX/tty.fifo" + OMARCHY_DASHBOARD_TTY="$BOX/tty.fifo" OMARCHY_PATH="$BOX/omarchy" \ + source "$BOX/dashboard-defs.sh" "$BOX/install.log" "$BOX/state.json" -- true + release_target + echo "$TARGET_RELEASED" >"$BOX/released" + [[ $fn == release_target ]] || "$fn" + ) + wait "$reader" + TTY_OUT=$(cat "$BOX/tty.out") + LOG_OUT=$(cat "$BOX/install.log") + CALLS=$(cat "$BOX/calls") + RELEASED=$(cat "$BOX/released") +} + +# ── The verdict itself ─────────────────────────────────────────────────────── +scenario release_target 0 +check "a clean sweep leaves the target released" "yes" "$RELEASED" +check_has "the helper is given the target from the state file" "release-helper $BOX/mnt" "$CALLS" +check_absent "a clean sweep logs no failure" "could not release" "$LOG_OUT" + +scenario release_target 1 +check "a failed sweep withdraws the release" "no" "$RELEASED" +check_has "the failure is logged" "release: could not release $BOX/mnt" "$LOG_OUT" +check_has "the helper's holder diagnosis is forwarded to the log" "holders: init(1)" "$LOG_OUT" + +# ── What the finish screen promises ────────────────────────────────────────── +scenario render_finish 0 +check_has "a released target invites the medium out" "You can now remove the install medium" "$TTY_OUT" + +scenario render_finish 1 +check_has "a held target asks for the medium to stay" \ + "Leave the install medium in until the reboot completes" "$TTY_OUT" +check_absent "and never promises the opposite in the same breath" \ + "You can now remove" "$TTY_OUT" + +# ── What the button does ───────────────────────────────────────────────────── +scenario reboot_now 0 +check_has "a released target reboots without stop jobs" "systemctl reboot -ff" "$CALLS" + +scenario reboot_now 1 +check_has "a held target reboots gracefully" "systemctl reboot" "$CALLS" +check_absent "a held target keeps its stop jobs" "-ff" "$CALLS" + +if (( fails )); then + echo "dashboard release gate: FAILED" + exit 1 +fi +echo "ok: the dashboard's removal offer and reboot follow the release" diff --git a/test/unit/free-space-gate-test.sh b/test/unit/free-space-gate-test.sh new file mode 100755 index 000000000..45955c643 --- /dev/null +++ b/test/unit/free-space-gate-test.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# +# The free-space install formats inside the configurator, so the configurator +# is the only thing that can refuse a bad install medium before parted, wipefs, +# luksFormat and mkfs have already run. The full-disk path has an integration +# scenario for its gate (corrupt-image-test.sh, which autoinstalls from cidata +# and so never reaches the configurator at all); this path has none, and +# deleting its gate outright leaves ./test/all green. Assert the ordering +# statically instead: whatever else moves, omarchy-wait-root-image-verify has +# to run before run_partition_execute. + +set -euo pipefail + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd) +configurator="$ROOT/configs/airootfs/root/configurator" + +# Comment lines are dropped first: the branch below is explained in prose that +# names both the helper and the function, so matching them would find the +# explanation whether or not the code still does it. +line_of() { # what, pattern -> the one line number matching it + local what=$1 pattern=$2 hits + hits=$(grep -vnE '^\s*#' "$configurator" | grep -E ":$pattern" | cut -d: -f1) + if [[ $(grep -c . <<<"$hits") -ne 1 ]]; then + echo "expected exactly one $what in ${configurator#"$ROOT/"}, found: ${hits:-none}" >&2 + exit 1 + fi + printf '%s\n' "$hits" +} + +branch=$(line_of "free_space branch" 'if \[\[ \$install_target == "free_space" \]\]; then$') +gate=$(line_of "call to the verify gate" '.*omarchy-wait-root-image-verify') +format=$(line_of "call to run_partition_execute" '\s+run_partition_execute ') + +((branch < gate)) || + { echo "the verify gate is outside the free_space branch (branch $branch, gate $gate)"; exit 1; } +((gate < format)) || + { echo "run_partition_execute runs before the verify gate (gate $gate, format $format): a corrupt install medium would be found only after the disk was partitioned"; exit 1; } + +echo "ok: the free-space path verifies the install medium before it formats" diff --git a/test/unit/release-install-target-test.sh b/test/unit/release-install-target-test.sh new file mode 100755 index 000000000..d22fd7ac2 --- /dev/null +++ b/test/unit/release-install-target-test.sh @@ -0,0 +1,272 @@ +#!/bin/bash +# +# omarchy-release-install-target is the one source of truth for letting go of +# the install target, and its exit status is load-bearing: the dashboard's +# removal offer and its immediate `reboot -ff` both key on it, so a sweep that +# quietly gives up must not report success. This drives the script with +# stubbed swapoff/findmnt/umount/dmsetup/cryptsetup on PATH, so every branch — +# the swap hunt, the crypt gate, the failure exits — is checked without a disk. + +set -euo pipefail + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd) +SCRIPT="$ROOT/configs/airootfs/usr/local/bin/omarchy-release-install-target" + +fails=0 +check() { # desc, expected_rc, actual_rc, [needle in output], [output] + local desc=$1 want=$2 got=$3 needle=${4:-} out=${5:-} + if [[ $got != "$want" ]]; then + echo "FAIL: $desc (rc want=$want got=$got)"; fails=1; return + fi + if [[ -n $needle && $out != *"$needle"* ]]; then + echo "FAIL: $desc (missing '$needle' in: $out)"; fails=1; return + fi + echo "ok: $desc" +} + +check_absent() { # desc, needle, haystack + local desc=$1 needle=$2 hay=$3 + if [[ $hay == *"$needle"* ]]; then + echo "FAIL: $desc (unexpected '$needle' in: $hay)"; fails=1; return + fi + echo "ok: $desc" +} + +call_order() { # desc, earlier-call-pattern, later-call-pattern + # First match of each pattern in $CALLS, by line. A missing call is its own + # failure, not a script abort: the bare grep pipelines here would otherwise + # kill the whole test under set -euo pipefail exactly when they had + # something to say. + local desc=$1 first second + first=$(grep -n "$2" <<<"$CALLS" | head -1 | cut -d: -f1 || true) + second=$(grep -n "$3" <<<"$CALLS" | head -1 | cut -d: -f1 || true) + if [[ -z $first || -z $second ]]; then + echo "FAIL: $desc (missing call: '$2' -> ${first:-none}, '$3' -> ${second:-none})" + fails=1; return + fi + if (( first >= second )); then + echo "FAIL: $desc ('$2' at line $first, '$3' at line $second)"; fails=1; return + fi + echo "ok: $desc" +} + +# A sandbox whose stub tools record every call to $BOX/calls and answer from +# fixture files, so a case is set up by writing those files. The stubs model +# the kernel's view rather than a script's: a successful `cryptsetup close` +# drops the mapper from the table dmsetup then reports on, which is what makes +# the close's success or failure observable the way the script observes it. +BOXES=() +trap '(( ${#BOXES[@]} == 0 )) || rm -rf "${BOXES[@]}"' EXIT + +new_box() { # -> sets BOX to a fresh sandbox with default (nothing to do) state + BOX=$(mktemp -d) + BOXES+=("$BOX") + export BOX + mkdir -p "$BOX/bin" + : >"$BOX/calls" + : >"$BOX/swaps" # /proc/swaps body, header already stripped + : >"$BOX/sources" # findmnt -o SOURCE -R + : >"$BOX/mapper_mounts" # findmnt -o TARGET -S /dev/mapper/ + : >"$BOX/uuids" # " " per line; absent = no such mapper + echo 1 >"$BOX/mounted_rc" + echo 0 >"$BOX/umount_rc" + echo 0 >"$BOX/close_rc" + + cat >"$BOX/bin/sync" <<'EOF' +#!/bin/bash +echo "sync" >>"$BOX/calls" +EOF + cat >"$BOX/bin/tail" <<'EOF' +#!/bin/bash +# Only /proc/swaps is faked; anything else goes to the real tail. +if [[ ${*: -1} == /proc/swaps ]]; then cat "$BOX/swaps"; exit 0; fi +exec /usr/bin/tail "$@" +EOF + cat >"$BOX/bin/swapoff" <<'EOF' +#!/bin/bash +echo "swapoff $*" >>"$BOX/calls" +EOF + cat >"$BOX/bin/findmnt" <<'EOF' +#!/bin/bash +echo "findmnt $*" >>"$BOX/calls" +case "$*" in + *"-o SOURCE"*) cat "$BOX/sources" ;; + *"-o TARGET"*) cat "$BOX/mapper_mounts" ;; + *) echo "stub findmnt tree" ;; # the failure diagnosis +esac +EOF + cat >"$BOX/bin/mountpoint" <<'EOF' +#!/bin/bash +exit "$(cat "$BOX/mounted_rc")" +EOF + cat >"$BOX/bin/umount" <<'EOF' +#!/bin/bash +echo "umount $*" >>"$BOX/calls" +exit "$(cat "$BOX/umount_rc")" +EOF + cat >"$BOX/bin/dmsetup" <<'EOF' +#!/bin/bash +echo "dmsetup $*" >>"$BOX/calls" +# Broken mode: dmsetup cannot answer at all — the message shape a real one +# prints when the control node is unreachable (reproduced non-root), which +# the script must keep apart from a mapper that is provably gone. +if [[ -e $BOX/dmsetup_broken ]] || + { [[ -e $BOX/dmsetup_broken_after ]] && + (( $(grep -c '^dmsetup' "$BOX/calls") > $(cat "$BOX/dmsetup_broken_after") )); }; then + echo "/dev/mapper/control: open failed: Permission denied" >&2 + echo "Command failed." >&2 + exit 1 +fi +mapper="${*: -1}" +uuid=$(awk -v m="$mapper" '$1 == m { print $2 }' "$BOX/uuids") +[[ -n $uuid ]] || { echo "Device does not exist." >&2; exit 1; } +# `info -c -o uuid` is the gate's query; a bare `info` is the failure +# report. The flag is matched positionally: a glob over $* would also hit a +# mapper name containing '-c'. +if [[ ${2:-} == -c ]]; then echo "$uuid"; else echo "Name: $mapper"; fi +EOF + cat >"$BOX/bin/cryptsetup" <<'EOF' +#!/bin/bash +echo "cryptsetup $*" >>"$BOX/calls" +rc=$(cat "$BOX/close_rc") +# close_removes: the transient shape — cryptsetup reports a failure while +# the mapping is nevertheless gone from the kernel's table. +if (( rc == 0 )) || [[ -e $BOX/close_removes ]]; then + awk -v m="${*: -1}" '$1 != m' "$BOX/uuids" >"$BOX/uuids.new" + mv "$BOX/uuids.new" "$BOX/uuids" +fi +exit "$rc" +EOF + cat >"$BOX/bin/fuser" <<'EOF' +#!/bin/bash +echo " USER PID ACCESS COMMAND" +EOF + chmod +x "$BOX"/bin/* + TARGET="$BOX/mnt" + mkdir -p "$TARGET" +} + +run_release() { # extra mapper args -> sets RC, OUT (stderr), CALLS + set +e + OUT=$(PATH="$BOX/bin:$PATH" bash "$SCRIPT" "$TARGET" "$@" 2>&1 >/dev/null) + RC=$? + set -e + CALLS=$(cat "$BOX/calls") +} + +# ── An idempotent no-op: nothing mounted, no swap, no mapper ───────────────── +new_box +run_release +check "an untouched target releases silently" 0 "$RC" +check_absent "no unmount is attempted with nothing mounted" "umount" "$CALLS" +check_absent "no mapper is closed with nothing open" "cryptsetup" "$CALLS" + +# ── The ordinary release: swap off, tree unmounted, crypt mapper closed ────── +new_box +echo 0 >"$BOX/mounted_rc" +printf '%s file 4194300 0 -2\n/dev/zram0 partition 8388604 0 100\n' "$TARGET/swap/swapfile" >"$BOX/swaps" +echo "/dev/mapper/omarchy_root[/@]" >"$BOX/sources" +echo "omarchy_root CRYPT-LUKS2-2b0e-omarchy_root" >"$BOX/uuids" +run_release +check "a mounted encrypted target is fully released" 0 "$RC" +check "the target's swapfile is deactivated" 0 0 "swapoff $TARGET/swap/swapfile" "$CALLS" +check_absent "swap outside the target is left alone" "/dev/zram0" "$CALLS" +check "the tree is unmounted" 0 0 "umount -R $TARGET" "$CALLS" +check "the mapper off the mount table is closed" 0 0 "cryptsetup close omarchy_root" "$CALLS" +# Swap is an invisible holder: with it still on, the unmount reports busy. +call_order "swapoff comes before the unmount" '^swapoff' '^umount' + +# ── A tree that stays mounted fails the release ────────────────────────────── +new_box +echo 0 >"$BOX/mounted_rc" +echo 1 >"$BOX/umount_rc" +echo "/dev/mapper/omarchy_root[/@]" >"$BOX/sources" +echo "omarchy_root CRYPT-LUKS2-2b0e-omarchy_root" >"$BOX/uuids" +run_release +check "a failed unmount fails the release" 1 "$RC" "could not unmount $TARGET" "$OUT" +check "the holders are named on the way out" 0 0 "ACCESS COMMAND" "$OUT" +check_absent "a target still mounted is not swept for mappers" "cryptsetup" "$CALLS" + +# ── A mapper that stays open fails the release too ─────────────────────────── +new_box +echo 0 >"$BOX/mounted_rc" +echo 1 >"$BOX/close_rc" +echo "/dev/mapper/omarchy_root[/@]" >"$BOX/sources" +echo "omarchy_root CRYPT-LUKS2-2b0e-omarchy_root" >"$BOX/uuids" +run_release +check "a mapper that stays open fails the release" 1 "$RC" "could not close mapper omarchy_root" "$OUT" +check "the unmount still happened" 0 0 "umount -R $TARGET" "$CALLS" + +# ── The crypt gate: cryptsetup would happily remove what is not ours ───────── +new_box +echo 0 >"$BOX/mounted_rc" +echo "/dev/mapper/vg0-data[/]" >"$BOX/sources" +echo "vg0-data LVM-9f2cIkjHqf0e" >"$BOX/uuids" +run_release +check "a non-crypt mapper is left alone" 0 "$RC" +check_absent "cryptsetup is never called on an LVM mapping" "cryptsetup" "$CALLS" + +new_box +echo 0 >"$BOX/mounted_rc" +echo "/dev/mapper/omarchy_root[/@]" >"$BOX/sources" # no uuids entry: gone already +run_release +check "a mapper that is not there falls out silently" 0 "$RC" +check_absent "no close is attempted on a missing mapper" "cryptsetup" "$CALLS" + +# ── A dmsetup that cannot answer must not pass for "already gone" ──────────── +new_box +echo 0 >"$BOX/mounted_rc" +echo "/dev/mapper/omarchy_root[/@]" >"$BOX/sources" +echo "omarchy_root CRYPT-LUKS2-2b0e-omarchy_root" >"$BOX/uuids" +touch "$BOX/dmsetup_broken" +run_release +check "an unanswerable mapper query fails the release" 1 "$RC" \ + "could not query mapper omarchy_root" "$OUT" +check_absent "no close is attempted on an unproven mapper" "cryptsetup" "$CALLS" + +# ── A close that errors while the mapper is provably gone is forgiven ──────── +# The transient shape round 5 could not reproduce on hardware: cryptsetup +# reports a failure, yet device-mapper's table no longer holds the mapping. +# The post-close probe must read that as closed — failing it would tell a +# healthy machine to leave the medium in and take the graceful reboot. +new_box +echo 0 >"$BOX/mounted_rc" +echo 1 >"$BOX/close_rc" +touch "$BOX/close_removes" +echo "/dev/mapper/omarchy_root[/@]" >"$BOX/sources" +echo "omarchy_root CRYPT-LUKS2-2b0e-omarchy_root" >"$BOX/uuids" +run_release +check "a failed close with the mapper gone still releases" 0 "$RC" +check_absent "and reports nothing to hold the medium for" "could not close" "$OUT" + +# ── dmsetup dying between the close and its probe is still a failed close ──── +new_box +echo 0 >"$BOX/mounted_rc" +echo 1 >"$BOX/close_rc" +echo "/dev/mapper/omarchy_root[/@]" >"$BOX/sources" +echo "omarchy_root CRYPT-LUKS2-2b0e-omarchy_root" >"$BOX/uuids" +echo 1 >"$BOX/dmsetup_broken_after" # the gate's query answers; every later call breaks +run_release +check "an unanswerable post-close probe fails the release" 1 "$RC" \ + "could not close mapper omarchy_root" "$OUT" + +# ── Mappers named as arguments: the open-but-never-mounted window ──────────── +new_box +echo "omarchy_root CRYPT-LUKS2-2b0e-omarchy_root" >"$BOX/uuids" +run_release omarchy_root +check "a named mapper is closed with nothing mounted" 0 "$RC" "cryptsetup close omarchy_root" "$CALLS" + +# ── A mapper mounted outside the target tree ───────────────────────────────── +new_box +echo "omarchy_root CRYPT-LUKS2-2b0e-omarchy_root" >"$BOX/uuids" +echo "/run/omarchy-install/image-top" >"$BOX/mapper_mounts" +run_release omarchy_root +check "a stage mount outside the target is unmounted" 0 "$RC" \ + "umount -R /run/omarchy-install/image-top" "$CALLS" +call_order "the stage mount goes before the close" '^umount -R /run' '^cryptsetup close' + +if (( fails )); then + echo "omarchy-release-install-target: FAILED" + exit 1 +fi +echo "ok: omarchy-release-install-target sweeps and reports" diff --git a/test/unit/test_command_capture.py b/test/unit/test_command_capture.py index 5dc9bdf41..d62b0a422 100644 --- a/test/unit/test_command_capture.py +++ b/test/unit/test_command_capture.py @@ -111,7 +111,9 @@ def test_a_mangled_btrfs_device_is_never_handed_to_mount(self): # under it; a replaced byte must stop the phase before that, not turn # into an opaque mount failure. self.fake_findmnt(source=b"/dev/sda\xe02[/@]") - ctx = types.SimpleNamespace(target=Path("/mnt"), state_dir=Path("/run/omarchy")) + ctx = types.SimpleNamespace( + target=Path("/mnt"), state_dir=Path("/run/omarchy"), state={} + ) with mock.patch.object(phases_impl, "info"), self.assertRaises(RuntimeError) as raised: phases_impl.create_factory_snapshot(ctx) diff --git a/test/unit/test_provisioning_state.py b/test/unit/test_provisioning_state.py index 7386f0012..e5377677d 100644 --- a/test/unit/test_provisioning_state.py +++ b/test/unit/test_provisioning_state.py @@ -168,6 +168,7 @@ def make_ctx(target, **overrides): user_credentials={"users": []}, state_dir=target / "state", authorized_keys_path=None, + state={}, ) defaults.update(overrides) return types.SimpleNamespace(**defaults) diff --git a/test/unit/test_root_image.py b/test/unit/test_root_image.py new file mode 100644 index 000000000..72262d686 --- /dev/null +++ b/test/unit/test_root_image.py @@ -0,0 +1,673 @@ +"""Unit tests for the root image install path in the orchestrator. + +Covers the pre-flight checks prepare_install_target runs before the disk is +touched (stream present and verified by the boot-time unit, a disk layout +the image can land on) and the destructive subvolume dance in _install_root_image, +asserted on the subprocess calls against a temp target. +""" + +import errno +import json +import os +import shlex +import shutil +import subprocess +import sys +import tempfile +import types +import unittest +from pathlib import Path +from subprocess import CompletedProcess +from unittest import mock + +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") +) + +from orchestrator import phases_impl # noqa: E402 + + +def btrfs_root_layout(name="@", mountpoint="/"): + return { + "config_type": "default_layout", + "device_modifications": [ + { + "device": "/dev/vda", + "wipe": True, + "partitions": [ + {"fs_type": "fat32", "mountpoint": "/boot", "btrfs": []}, + { + "fs_type": "btrfs", + "mountpoint": None, + "btrfs": [ + {"name": name, "mountpoint": mountpoint}, + {"name": "@home", "mountpoint": "/home"}, + ], + }, + ], + } + ], + } + + +class VerifyRootImageLayoutTest(unittest.TestCase): + def test_btrfs_root_subvolume_passes(self): + phases_impl.verify_root_image_layout(btrfs_root_layout()) + phases_impl.verify_root_image_layout(btrfs_root_layout(name="/@")) + + def test_lvm_rejected(self): + layout = btrfs_root_layout() + layout["lvm_config"] = {"config_type": "default", "vol_groups": []} + with self.assertRaisesRegex(RuntimeError, "LVM"): + phases_impl.verify_root_image_layout(layout) + + def test_root_not_on_at_subvolume_rejected(self): + with self.assertRaisesRegex(RuntimeError, "@ subvolume"): + phases_impl.verify_root_image_layout(btrfs_root_layout(name="root")) + + def test_ext4_root_rejected(self): + layout = { + "device_modifications": [ + {"partitions": [{"fs_type": "ext4", "mountpoint": "/", "btrfs": []}]} + ] + } + with self.assertRaisesRegex(RuntimeError, "@ subvolume"): + phases_impl.verify_root_image_layout(layout) + + def test_empty_config_rejected(self): + with self.assertRaises(RuntimeError): + phases_impl.verify_root_image_layout({}) + + +class VerifyRootImageStreamTest(unittest.TestCase): + """verify_root_image_stream delegates to the shared shell helper + (omarchy-wait-root-image-verify): it passes when the helper exits 0 and + fails the install with the helper's message when it does not. The wait, + the systemd handoff and the corrupt-medium wording live in the helper and + are covered by test/unit/wait-root-image-verify-test.sh.""" + + HELPER = phases_impl.ROOT_IMAGE_VERIFY_HELPER + + def setUp(self): + self.ctx = types.SimpleNamespace(state_dir=Path("/nonexistent")) + self.infos = [] + patch = mock.patch.object(phases_impl, "info", side_effect=self.infos.append) + patch.start() + self.addCleanup(patch.stop) + + def helper_returns(self, returncode=0, stdout="", stderr=""): + self.captured = {} + + def fake_run(cmd, **kwargs): + self.captured["cmd"] = cmd + return CompletedProcess(cmd, returncode, stdout=stdout, stderr=stderr) + + return mock.patch.object(phases_impl.subprocess, "run", side_effect=fake_run) + + def test_passes_when_helper_exits_zero(self): + with self.helper_returns(0, stdout="boot medium: /dev/sda1 on /dev/sda, scheduler: none mq-deadline kyber [bfq]\n"): + phases_impl.verify_root_image_stream(self.ctx) + self.assertEqual(self.captured["cmd"], [self.HELPER]) + # The boot medium / scheduler line the helper prints reaches the log. + self.assertTrue(any("bfq" in m for m in self.infos)) + + def test_corrupt_medium_raises_with_the_helpers_message(self): + stderr = ("install medium is corrupt: re-flash it\n" + "sha256 mismatch on the root image\n" + "omarchy-root.btrfs.zst: FAILED") + with self.helper_returns(1, stdout="boot medium: /dev/sda1 ...\n", stderr=stderr): + with self.assertRaises(RuntimeError) as raised: + phases_impl.verify_root_image_stream(self.ctx) + # The dashboard headlines the first line, so it must lead and be short. + first = str(raised.exception).splitlines()[0] + self.assertEqual(first, "install medium is corrupt: re-flash it") + self.assertLess(len(first), 50) + + def test_nonzero_without_stderr_still_fails(self): + with self.helper_returns(3, stdout="", stderr=""): + with self.assertRaisesRegex(RuntimeError, "status 3"): + phases_impl.verify_root_image_stream(self.ctx) + + def test_missing_helper_fails_the_install(self): + with mock.patch.object(phases_impl.subprocess, "run", + side_effect=FileNotFoundError("no helper")): + with self.assertRaisesRegex(RuntimeError, "could not run"): + phases_impl.verify_root_image_stream(self.ctx) + + +class PublishVerifyProgressTest(unittest.TestCase): + """_publish_verify_progress mirrors the hasher's fdinfo read position into + phase_progress while the unit is activating. Our own unbuffered fd on a + temp stream stands in for sha256sum's, with MainPID pointed at this + process.""" + + def test_mirrors_hasher_read_position(self): + with tempfile.TemporaryDirectory() as tmp: + stream = (Path(tmp) / "omarchy-root.btrfs.zst").resolve() + stream.write_bytes(b"\0" * 4096) + states = iter(["activating", "active"]) + + def unit_property(prop): + return next(states) if prop == "ActiveState" else str(os.getpid()) + + fractions = [] + fd = os.open(stream, os.O_RDONLY) + try: + os.read(fd, 1024) + with mock.patch.object(phases_impl, "ROOT_IMAGE_STREAM", stream), \ + mock.patch.object(phases_impl, "_verify_unit_property", + side_effect=unit_property), \ + mock.patch.object(phases_impl, "_write_phase_progress", + side_effect=lambda ctx, f: fractions.append(f)), \ + mock.patch.object(phases_impl.time, "sleep"): + phases_impl._publish_verify_progress(ctx=None) + finally: + os.close(fd) + self.assertEqual(fractions, [0.25]) + + def test_missing_stream_is_a_no_op(self): + with mock.patch.object(phases_impl, "ROOT_IMAGE_STREAM", + Path("/nonexistent/omarchy-root.btrfs.zst")), \ + mock.patch.object(phases_impl, "_verify_unit_property") as show: + phases_impl._publish_verify_progress(ctx=None) + show.assert_not_called() + + +class PrepareInstallTargetTest(unittest.TestCase): + """prepare_install_target wires the checks together per install mode.""" + + def setUp(self): + self.calls = [] + for name in ("verify_protected_mounts", "verify_root_image_stream", + "verify_root_image_layout", "_root_image_target_mounts"): + patch = mock.patch.object( + phases_impl, name, side_effect=lambda *a, _n=name, **k: self.calls.append(_n) + ) + patch.start() + self.addCleanup(patch.stop) + + def test_full_disk_checks_json_layout_then_stream(self): + ctx = types.SimpleNamespace( + is_protected=False, target=Path("/mnt"), + user_configuration={"disk_config": btrfs_root_layout()}, + ) + phases_impl.prepare_install_target(ctx) + self.assertEqual(self.calls, ["verify_root_image_layout", "verify_root_image_stream"]) + + def test_protected_checks_real_mounts_then_stream(self): + ctx = types.SimpleNamespace( + is_protected=True, target=Path("/mnt"), + user_configuration={"disk_config": {"config_type": "pre_mounted_config"}}, + ) + phases_impl.prepare_install_target(ctx) + self.assertEqual( + self.calls, + ["verify_protected_mounts", "_root_image_target_mounts", "verify_root_image_stream"], + ) + + +class InstallRootImageTest(unittest.TestCase): + """The subvolume swap: receive at the top level, snapshot writable, drop + the empty @, rename the snapshot in, replay the mounts.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.target = Path(self.tmp.name) / "mnt" + self.target.mkdir() + self.state_dir = Path(self.tmp.name) / "state" + self.state_dir.mkdir() + self.top = self.state_dir / "image-top" + self.stream = Path(self.tmp.name) / "omarchy-root.btrfs.zst" + self.stream.write_bytes(b"stream") + self.ctx = types.SimpleNamespace(target=self.target, state_dir=self.state_dir) + + self.mounts = [ + {"target": str(self.target), "source": "/dev/mapper/omarchy_root[/@]", + "fstype": "btrfs", "options": "rw,noatime,compress=zstd:3,subvolid=256,subvol=/@"}, + {"target": str(self.target / "boot"), "source": "/dev/vda1", + "fstype": "vfat", "options": "rw,relatime"}, + {"target": str(self.target / "home"), "source": "/dev/mapper/omarchy_root[/@home]", + "fstype": "btrfs", "options": "rw,noatime,compress=zstd:3,subvolid=257,subvol=/@home"}, + {"target": str(self.target / "var/log"), "source": "/dev/mapper/omarchy_root[/@log]", + "fstype": "btrfs", "options": "rw,noatime,compress=zstd:3,subvolid=258,subvol=/@log"}, + ] + self.calls = [] + self.received_packages = {"limine", "omarchy-keyring", "omarchy", "omarchy-settings", "omarchy-nvim"} + + def fake_run(cmd, **kwargs): + self.calls.append(cmd) + if cmd[0] == "mount" and cmd[1:3] == ["-o", "subvolid=5"]: + # The top level as archinstall left it: an empty @ and the + # other subvolumes of the layout. + top = Path(cmd[-1]) + for name in ("@", "@home", "@log"): + (top / name).mkdir(parents=True, exist_ok=True) + elif cmd[:3] == ["btrfs", "subvolume", "snapshot"]: + shutil.copytree(cmd[3], cmd[4]) + elif cmd[:3] == ["btrfs", "subvolume", "delete"]: + shutil.rmtree(cmd[3]) + return CompletedProcess(cmd, 0, stdout="", stderr="") + + def fake_receive(ctx, top, stream_path): + self.assertEqual(stream_path, self.stream) + self.calls.append(["btrfs", "receive", str(top)]) + received = top / phases_impl.ROOT_IMAGE_SUBVOLUME + (received / "var/log").mkdir(parents=True) + (received / "var/log/pacman.log").write_text("[image] installed base\n") + (received / "etc").mkdir() + + patches = [ + mock.patch.object(phases_impl.subprocess, "run", side_effect=fake_run), + mock.patch.object(phases_impl, "_findmnt_mounts", return_value=self.mounts), + mock.patch.object(phases_impl, "_receive_root_image", side_effect=fake_receive), + mock.patch.object(phases_impl, "_umount_tree", + side_effect=lambda root: self.calls.append(["umount", "-R", str(root)])), + mock.patch.object(phases_impl, "_root_image_required_packages", + return_value=["limine", "omarchy-keyring", "omarchy"]), + mock.patch.object(phases_impl.arch, "target_has_package", create=True, + side_effect=lambda target, pkg: pkg in self.received_packages), + mock.patch.object(phases_impl, "ROOT_IMAGE_STREAM", self.stream), + mock.patch.object(phases_impl, "info"), + ] + for patch in patches: + patch.start() + self.addCleanup(patch.stop) + + def btrfs_calls(self): + return [cmd for cmd in self.calls if cmd[0] == "btrfs"] + + def test_swaps_received_image_in_for_the_empty_root(self): + phases_impl._install_root_image(self.ctx) + + top = str(self.top) + received = f"{top}/{phases_impl.ROOT_IMAGE_SUBVOLUME}" + self.assertEqual(self.calls[0], ["mount", "-o", "subvolid=5", "/dev/mapper/omarchy_root", top]) + self.assertEqual( + self.btrfs_calls(), + [ + ["btrfs", "receive", top], + ["btrfs", "subvolume", "snapshot", received, f"{top}/@.image"], + ["btrfs", "subvolume", "delete", received], + ["btrfs", "subvolume", "delete", f"{top}/@"], + ], + ) + # The old @ is deleted only once the layout is unmounted, and the + # snapshot takes its name. + unmount = self.calls.index(["umount", "-R", str(self.target)]) + delete_root = self.calls.index(["btrfs", "subvolume", "delete", f"{top}/@"]) + self.assertLess(unmount, delete_root) + self.assertTrue((self.top / "@" / "etc").is_dir()) + self.assertFalse((self.top / "@.image").exists()) + self.assertFalse((self.top / phases_impl.ROOT_IMAGE_SUBVOLUME).exists()) + + def test_replays_mounts_without_subvolid(self): + phases_impl._install_root_image(self.ctx) + + delete_root = self.calls.index(["btrfs", "subvolume", "delete", f"{self.top}/@"]) + remounts = [cmd for cmd in self.calls[delete_root:] if cmd[0] == "mount"] + self.assertEqual( + remounts, + [ + ["mount", "-t", "btrfs", "-o", "rw,noatime,compress=zstd:3,subvol=/@", + "/dev/mapper/omarchy_root", str(self.target)], + ["mount", "-t", "vfat", "-o", "rw,relatime", "/dev/vda1", str(self.target / "boot")], + ["mount", "-t", "btrfs", "-o", "rw,noatime,compress=zstd:3,subvol=/@home", + "/dev/mapper/omarchy_root", str(self.target / "home")], + ["mount", "-t", "btrfs", "-o", "rw,noatime,compress=zstd:3,subvol=/@log", + "/dev/mapper/omarchy_root", str(self.target / "var/log")], + ], + ) + # The top level is released last, then per-machine identity is set. + self.assertEqual(self.calls[-2], ["umount", str(self.top)]) + self.assertEqual(self.calls[-1], ["systemd-machine-id-setup", f"--root={self.target}"]) + + def test_carries_image_pacman_log_into_log_subvolume(self): + phases_impl._install_root_image(self.ctx) + self.assertEqual((self.top / "@log/pacman.log").read_text(), "[image] installed base\n") + + def test_stale_subvolumes_from_a_previous_attempt_are_removed_first(self): + original_run = phases_impl.subprocess.run.side_effect + + def run_with_leftovers(cmd, **kwargs): + result = original_run(cmd, **kwargs) + if cmd[0] == "mount" and cmd[1:3] == ["-o", "subvolid=5"]: + top = Path(cmd[-1]) + (top / phases_impl.ROOT_IMAGE_SUBVOLUME).mkdir() + (top / "@.image").mkdir() + return result + + phases_impl.subprocess.run.side_effect = run_with_leftovers + phases_impl._install_root_image(self.ctx) + + top = str(self.top) + received = f"{top}/{phases_impl.ROOT_IMAGE_SUBVOLUME}" + self.assertEqual( + self.btrfs_calls()[:4], + [ + ["btrfs", "subvolume", "delete", received], + ["btrfs", "receive", top], + ["btrfs", "subvolume", "delete", f"{top}/@.image"], + ["btrfs", "subvolume", "snapshot", received, f"{top}/@.image"], + ], + ) + + def test_missing_required_package_fails_after_the_swap(self): + self.received_packages.discard("omarchy") + with self.assertRaisesRegex(RuntimeError, "lacks required packages: omarchy"): + phases_impl._install_root_image(self.ctx) + # The layout is back in place and the top level released either way. + self.assertIn(["umount", str(self.top)], self.calls) + self.assertTrue((self.top / "@" / "etc").is_dir()) + + def test_receive_failure_releases_top_level_and_keeps_layout_mounted(self): + phases_impl._receive_root_image.side_effect = RuntimeError("btrfs receive failed") + with self.assertRaisesRegex(RuntimeError, "btrfs receive failed"): + phases_impl._install_root_image(self.ctx) + self.assertEqual(self.calls[-1], ["umount", str(self.top)]) + self.assertNotIn(["umount", "-R", str(self.target)], self.calls) + self.assertTrue((self.top / "@").is_dir()) + + +class FakeUnitProc: + """What Popen(systemd-run --wait --pipe ...) hands back.""" + + def __init__(self, returncode=0, output=""): + self.returncode = returncode + self.output = output + self.joined = 0 + + def communicate(self): + self.joined += 1 + return self.output, None + + +class TargetKeyringUnitTest(unittest.TestCase): + """The per-machine keyring the image deliberately ships without, run as + a transient unit and joined before the factory snapshot.""" + + def setUp(self): + self.target = Path("/mnt") + self.ctx = types.SimpleNamespace(target=self.target, state={}) + self.runs = [] + self.popens = [] + + def fake_run(cmd, **kwargs): + self.runs.append(cmd) + return CompletedProcess(cmd, 0, stdout="", stderr="") + + def fake_popen(cmd, **kwargs): + self.popens.append((cmd, kwargs)) + return FakeUnitProc() + + for patch in ( + mock.patch.object(phases_impl.subprocess, "run", side_effect=fake_run), + mock.patch.object(phases_impl.subprocess, "Popen", side_effect=fake_popen), + mock.patch.object(phases_impl, "info"), + ): + patch.start() + self.addCleanup(patch.stop) + + def test_start_runs_init_then_populate_in_a_waited_unit(self): + phases_impl._start_target_keyring_init(self.ctx) + + self.assertEqual(self.runs, [["systemctl", "reset-failed", "omarchy-target-keyring"]]) + (cmd, kwargs), = self.popens + self.assertEqual( + cmd[:6], + ["systemd-run", "--wait", "--pipe", "--collect", "--quiet", "--unit=omarchy-target-keyring"], + ) + self.assertEqual(cmd[6:8], ["sh", "-c"]) + self.assertEqual( + cmd[8], + "pacman-key --gpgdir /mnt/etc/pacman.d/gnupg --init && " + "pacman-key --gpgdir /mnt/etc/pacman.d/gnupg " + "--populate-from /mnt/usr/share/pacman/keyrings --populate archlinux omarchy", + ) + self.assertNotIn("arch-chroot", cmd[8]) + self.assertIs(kwargs["stderr"], phases_impl.subprocess.STDOUT) + self.assertIsInstance(self.ctx.state["target_keyring_proc"], FakeUnitProc) + + def test_join_waits_once_and_clears_state(self): + proc = FakeUnitProc() + self.ctx.state["target_keyring_proc"] = proc + phases_impl._join_target_keyring_init(self.ctx) + phases_impl._join_target_keyring_init(self.ctx) # no-op: nothing pending + self.assertEqual(proc.joined, 1) + self.assertNotIn("target_keyring_proc", self.ctx.state) + + def test_join_raises_with_the_unit_output(self): + self.ctx.state["target_keyring_proc"] = FakeUnitProc(returncode=1, output="gpg: boom\n") + with self.assertRaisesRegex(RuntimeError, r"(?s)keyring init failed \(exit 1\).*gpg: boom"): + phases_impl._join_target_keyring_init(self.ctx) + self.assertNotIn("target_keyring_proc", self.ctx.state) + + def test_stop_ends_the_unit_and_never_raises(self): + proc = FakeUnitProc(returncode=1, output="killed") + self.ctx.state["target_keyring_proc"] = proc + phases_impl.stop_target_keyring_init(self.ctx) + self.assertEqual(self.runs, [["systemctl", "stop", "omarchy-target-keyring"]]) + self.assertEqual(proc.joined, 1) + self.assertNotIn("target_keyring_proc", self.ctx.state) + + def test_stop_without_a_unit_touches_nothing(self): + phases_impl.stop_target_keyring_init(self.ctx) + self.assertEqual(self.runs, []) + + def test_factory_snapshot_joins_the_unit_before_anything_else(self): + # Even when the snapshot itself is skipped, the join runs: a failed + # keyring must fail the install. + self.ctx.state["target_keyring_proc"] = FakeUnitProc(returncode=1, output="gpg: boom") + with mock.patch.object(phases_impl, "_findmnt_value", return_value="ext4"): + with self.assertRaisesRegex(RuntimeError, "keyring init failed"): + phases_impl.create_factory_snapshot(self.ctx) + self.assertEqual(self.runs, []) + + +class ReceiveRootImageTest(unittest.TestCase): + """The stream-through-zstd-into-btrfs-receive pump, against real children + on real pipes. + + The decompressor is the real zstd on real fixture streams; btrfs receive + is stood in for by a shell that drains stdin, so its pipe blocks the way + the real one does and the child is only reaped when its stdin actually + reaches EOF (which now takes the decompressor exiting first). + """ + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + root = Path(self.tmp.name) + self.state_dir = root / "state" + self.state_dir.mkdir() + (self.state_dir / "state.json").write_text("{}") + self.top = root / "image-top" + self.received = root / "received" + self.ctx = types.SimpleNamespace(target=root / "mnt", state_dir=self.state_dir) + self.procs = [] + + def stand_in(self, script): + """Patch Popen so btrfs receive is this shell script instead, keeping + the caller's pipes and stderr file. The zstd child spawns for real. + + Fixtures must be built before this arms: subprocess.run resolves + Popen through the patched module attribute too. + """ + + spawn = subprocess.Popen # before the patch below replaces it + + def popen(cmd, **kwargs): + if cmd[0] == "zstd": + return spawn(cmd, **kwargs) + self.assertEqual(cmd[:2], ["btrfs", "receive"]) + proc = spawn(["sh", "-c", script], **kwargs) + self.procs.append(proc) + return proc + + patch = mock.patch.object(phases_impl.subprocess, "Popen", side_effect=popen) + patch.start() + self.addCleanup(patch.stop) + + def drains_stdin(self): + self.stand_in(f"cat > {shlex.quote(str(self.received))}") + + def compressed(self, data): + return subprocess.run( + ["zstd", "-q", "-c", "--long=27"], input=data, + stdout=subprocess.PIPE, check=True, + ).stdout + + def stream_file(self, data, raw=False): + """The shipped artifact: DATA behind the outer zstd layer (or, with + raw=True, a file that never was a zstd stream).""" + path = Path(self.tmp.name) / "omarchy-root.btrfs.zst" + path.write_bytes(data if raw else self.compressed(data)) + return path + + def test_streams_the_file_through_and_reports_done(self): + stream = self.stream_file(b"x" * (9 << 20)) + self.drains_stdin() + phases_impl._receive_root_image(self.ctx, self.top, stream) + + self.assertEqual(self.received.read_bytes(), b"x" * (9 << 20)) + self.assertEqual(json.loads((self.state_dir / "state.json").read_text())["phase_progress"], 1.0) + + def test_a_failed_receive_raises_with_its_error_output(self): + stream = self.stream_file(b"stream") + self.stand_in("cat > /dev/null; echo 'ERROR: chunk failed' >&2; exit 1") + with self.assertRaisesRegex(RuntimeError, "btrfs receive failed.*chunk failed"): + phases_impl._receive_root_image(self.ctx, self.top, stream) + + def test_a_receive_that_dies_at_once_fails_instead_of_blocking(self): + # With the parent still holding a read end of the decoded pipe, a + # dead receive would never EPIPE the decompressor and the pump would + # block on full pipes forever. The payload outsizes every buffer in + # the pipeline so that hang is reachable. The EPIPE takes the + # decompressor down too, so the headline names both. + stream = self.stream_file(b"x" * (9 << 20)) + self.stand_in("exit 1") + with self.assertRaisesRegex(RuntimeError, "decompression and btrfs receive both failed"): + phases_impl._receive_root_image(self.ctx, self.top, stream) + + def test_a_corrupt_layer_with_a_failing_receive_names_both(self): + # The corrupt-medium shape the headline used to get backwards: zstd + # dies on the junk, the starved receive exits nonzero after it, and + # the message blamed the receive alone. Neither death order is + # provable from exit codes, so both are named and the shared detail + # file carries zstd's own verdict. + stream = self.stream_file(b"junk that is no zstd frame", raw=True) + self.stand_in("cat > /dev/null; exit 1") + with self.assertRaisesRegex(RuntimeError, "decompression and btrfs receive both failed"): + phases_impl._receive_root_image(self.ctx, self.top, stream) + + def test_a_receive_that_cannot_spawn_reaps_the_decompressor(self): + # The half-built pipeline: on the live ISO btrfs-progs is guaranteed, + # so a receive that cannot spawn is close to unreachable -- but a + # decompressor left alive and blocked on a stdin nobody will ever + # feed turns that loud failure into a wedged teardown. + stream = self.stream_file(b"x" * (9 << 20)) + spawn = subprocess.Popen + zstd_procs = [] + + def popen(cmd, **kwargs): + if cmd[0] == "zstd": + proc = spawn(cmd, **kwargs) + zstd_procs.append(proc) + return proc + raise FileNotFoundError("btrfs") + + patch = mock.patch.object(phases_impl.subprocess, "Popen", side_effect=popen) + patch.start() + self.addCleanup(patch.stop) + with self.assertRaises(FileNotFoundError): + phases_impl._receive_root_image(self.ctx, self.top, stream) + + (zstd,) = zstd_procs + self.assertIsNotNone(zstd.poll()) + + def test_an_interrupt_during_spawn_reaps_the_decompressor_too(self): + # A KeyboardInterrupt aimed at this process alone (not the process + # group, which reaches zstd directly) lands wherever the interpreter + # happens to be -- including inside the second Popen. It is a + # BaseException, so an `except Exception` would skip the cleanup and + # leak the blocked decompressor while the orchestrator unwinds. + stream = self.stream_file(b"x" * (9 << 20)) + spawn = subprocess.Popen + zstd_procs = [] + + def popen(cmd, **kwargs): + if cmd[0] == "zstd": + proc = spawn(cmd, **kwargs) + zstd_procs.append(proc) + return proc + raise KeyboardInterrupt + + patch = mock.patch.object(phases_impl.subprocess, "Popen", side_effect=popen) + patch.start() + self.addCleanup(patch.stop) + with self.assertRaises(KeyboardInterrupt): + phases_impl._receive_root_image(self.ctx, self.top, stream) + + (zstd,) = zstd_procs + self.assertIsNotNone(zstd.poll()) + + def test_a_corrupt_outer_layer_fails_as_decompression(self): + # The boot-time hash normally rejects this first; the pipe is the + # backstop when the medium rots between hash and receive. + stream = self.stream_file(b"not a zstd frame at all", raw=True) + self.drains_stdin() + with self.assertRaisesRegex(RuntimeError, "decompression failed"): + phases_impl._receive_root_image(self.ctx, self.top, stream) + self.assertEqual(self.procs[0].wait(timeout=10), 0) + + def test_a_read_error_off_the_medium_still_closes_and_reaps_the_receive(self): + # EIO out of a dying stick is the failure the verify machinery exists + # for. If stdin were left open, the zstd child would sit blocked on it + # — and btrfs receive behind it — holding the staging mount busy for + # the caller's silent umount. Random payload so the compressed stream + # outgrows the 8 MiB first chunk and the EIO lands mid-stream. + payload = os.urandom(12 << 20) + stream = DyingStream(self.compressed(payload)) + self.drains_stdin() + with self.assertRaises(OSError): + phases_impl._receive_root_image(self.ctx, self.top, stream) + + proc = self.procs[0] + self.assertEqual(proc.wait(timeout=10), 0) + received = self.received.read_bytes() + # What made it through is a flushed prefix of the payload: the + # truncated tail died in the decompressor, not in the receive. + self.assertTrue(payload.startswith(received)) + self.assertLess(len(received), len(payload)) + + +class DyingStream: + """A stream path whose reads give one chunk and then fail like a medium + that has gone away mid-install.""" + + def __init__(self, data): + self.data = data + + def stat(self): + return types.SimpleNamespace(st_size=len(self.data)) + + def open(self, mode): + return self + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def read(self, size): + if self.data: + chunk, self.data = self.data[:size], b"" + return chunk + raise OSError(errno.EIO, "Input/output error") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/unit/udev-rules-test.sh b/test/unit/udev-rules-test.sh new file mode 100644 index 000000000..fb5f9dccd --- /dev/null +++ b/test/unit/udev-rules-test.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# +# The udev rules shipped in the live ISO must parse, and the boot-medium rule +# must keep putting USB disks and optical drives on BFQ: that is what makes +# the root-image verify unit's IOSchedulingClass=idle mean anything while the +# live system pages its airootfs in from the same stick. + +set -euo pipefail + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd) +rules_dir="$ROOT/configs/airootfs/etc/udev/rules.d" + +if ! command -v udevadm >/dev/null || ! udevadm verify --help >/dev/null 2>&1; then + echo "skip: udevadm verify unavailable" + exit 0 +fi + +udevadm verify --no-style "$rules_dir"/*.rules + +bfq_rule="$rules_dir/71-omarchy-iso-boot-medium-bfq.rules" +grep -qE '^ACTION=="add\|change".*ENV\{ID_BUS\}=="usb".*ATTR\{queue/scheduler\}="bfq"' "$bfq_rule" || + { echo "USB disks are no longer put on bfq in $bfq_rule"; exit 1; } +grep -qE '^ACTION=="add\|change".*KERNEL=="sr\[0-9\]\*.*ATTR\{queue/scheduler\}="bfq"' "$bfq_rule" || + { echo "optical drives are no longer put on bfq in $bfq_rule"; exit 1; } +grep -qE 'ENV\{DEVTYPE\}=="disk"' "$bfq_rule" || + { echo "bfq rule must be scoped to whole disks (partitions have no queue/)"; exit 1; } + +echo "ok: udev rules parse; boot media go on bfq" diff --git a/test/unit/wait-root-image-verify-test.sh b/test/unit/wait-root-image-verify-test.sh new file mode 100755 index 000000000..9eddf41e9 --- /dev/null +++ b/test/unit/wait-root-image-verify-test.sh @@ -0,0 +1,157 @@ +#!/bin/bash +# +# omarchy-wait-root-image-verify is the single gate both disk-touching paths +# clear before formatting: the orchestrator (full-disk) and the configurator +# (free-space). It collects the boot-time hasher's verdict, waiting if it is +# still running and starting it if it never did. This drives it with a stubbed +# systemctl/findmnt/lsblk/journalctl and a fake boot medium on PATH, so the +# wait/verdict logic is checked without an ISO. + +set -euo pipefail + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd) +HELPER="$ROOT/configs/airootfs/usr/local/bin/omarchy-wait-root-image-verify" + +fails=0 +check() { # desc, expected_rc, actual_rc, [needle in output], [output] + local desc=$1 want=$2 got=$3 needle=${4:-} out=${5:-} + if [[ $got != "$want" ]]; then + echo "FAIL: $desc (rc want=$want got=$got)"; fails=1; return + fi + if [[ -n $needle && $out != *"$needle"* ]]; then + echo "FAIL: $desc (missing '$needle' in: $out)"; fails=1; return + fi + echo "ok: $desc" +} + +# A sandbox with a fake boot medium and stub tools on PATH. ACTIVE_SEQ is the +# newline-separated ActiveState values `systemctl show ... ActiveState` returns +# on successive calls (last one repeats); LOADSTATE, START_RC and RESULT (the +# unit's Result property, exit-code unless overridden) tune the rest. +run_helper() { # loadstate, active_seq, start_rc, [result] -> sets RC and OUT + local loadstate=$1 active_seq=$2 start_rc=$3 result=${4:-exit-code} + local box; box=$(mktemp -d) + mkdir -p "$box/bin" "$box/medium/arch/x86_64" "$box/sys/block/sdz/queue" + : >"$box/medium/arch/x86_64/omarchy-root.btrfs.zst" + : >"$box/medium/arch/x86_64/omarchy-root.btrfs.zst.sha256" + + # WITH_HASHER_PROC=1: a fake hasher (pid 4242) holds the stream at pos 512 + # of 1024 bytes, and the helper draws progress into $box/progress. The shim + # rewrites /proc to the sandbox, so hash_pct reads this fixture. + local mainpid=0 + if [[ -n ${WITH_HASHER_PROC:-} ]]; then + mainpid=4242 + head -c 1024 /dev/zero >"$box/medium/arch/x86_64/omarchy-root.btrfs.zst" + mkdir -p "$box/proc/4242/fd" "$box/proc/4242/fdinfo" + ln -s "$box/medium/arch/x86_64/omarchy-root.btrfs.zst" "$box/proc/4242/fd/7" + printf 'pos:\t512\nflags:\t0100000\n' >"$box/proc/4242/fdinfo/7" + fi + echo "none mq-deadline kyber [bfq]" >"$box/sys/block/sdz/queue/scheduler" + printf '%s\n' "$active_seq" >"$box/active_seq" + + cat >"$box/bin/findmnt" <"$box/bin/lsblk" <"$box/bin/journalctl" <<'EOF' +#!/bin/bash +echo "omarchy-root.btrfs.zst: FAILED" +EOF + # systemctl show -p LoadState|ActiveState --value ; systemctl start ... + cat >"$box/bin/systemctl" < 1)); then printf '%s\n' "\${seq[@]:1}" >"\$box/active_seq"; fi + ;; + esac + exit 0 +fi +if [[ \$1 == start ]]; then exit \$start_rc; fi +exit 0 +EOF + chmod +x "$box"/bin/* + + # Point the helper's hardcoded /run/archiso/bootmnt and /sys at the sandbox + # by running it through a tiny shim that rewrites those roots. + local shim="$box/run-helper" + sed -e "s#/run/archiso/bootmnt#$box/medium#g" \ + -e "s#/sys/block#$box/sys/block#g" \ + -e "s#/proc/#$box/proc/#g" \ + "$HELPER" >"$shim" + chmod +x "$shim" + + local progress_env=() + [[ -n ${WITH_HASHER_PROC:-} ]] && progress_env=(OMARCHY_VERIFY_PROGRESS="$box/progress") + + set +e + OUT=$(PATH="$box/bin:$PATH" env "${progress_env[@]}" bash "$shim" 2>&1) + RC=$? + set -e + PROGRESS_OUT=$(cat "$box/progress" 2>/dev/null || true) + rm -rf "$box" +} + +# Already verified before we look: pass, and the scheduler line is emitted. +run_helper loaded "active" 0 +check "active unit passes" 0 "$RC" "scheduler: none mq-deadline kyber [bfq]" "$OUT" + +# Still hashing, then completes: waits, then passes. +run_helper loaded $'activating\nactivating\nactive' 0 +check "waits for a running unit then passes" 0 "$RC" "waiting for" "$OUT" + +# With OMARCHY_VERIFY_PROGRESS set and a hasher pid to read, the wait draws a +# live percent (fixture: pos 512 of 1024 -> 50%) and finishes the line at 100% +# on success. Progress goes to the given path, never into stdout/stderr. +WITH_HASHER_PROC=1 run_helper loaded $'activating\nactivating\nactive' 0 +check "progress line drawn while hashing" 0 "$RC" "" "$OUT" +[[ $PROGRESS_OUT == *" 50%"* && $PROGRESS_OUT == *"100%"* ]] && + echo "ok: progress shows 50% then 100%" || + { echo "FAIL: progress output wrong: $(printf '%q' "$PROGRESS_OUT")"; fails=1; } +[[ $OUT == *"50%"* ]] && { echo "FAIL: percent leaked into stdout/stderr"; fails=1; } || true + +# A failed hash ends the progress line without the cosmetic 100%. (The first +# ActiveState answer is consumed by the inactive check before the wait loop.) +WITH_HASHER_PROC=1 run_helper loaded $'activating\nactivating\nfailed' 0 +[[ $PROGRESS_OUT == *" 50%"* && $PROGRESS_OUT != *"100%"* ]] && + echo "ok: failed hash ends progress without 100%" || + { echo "FAIL: failed-hash progress wrong: $(printf '%q' "$PROGRESS_OUT")"; fails=1; } + +# Hash failed: corrupt medium, re-flash message leads. +run_helper loaded "failed" 0 +check "failed unit is a corrupt medium" 1 "$RC" "install medium is corrupt: re-flash it" "$OUT" + +# Hash hit the size-based TimeoutStartSec: the medium stalls reads. Distinct +# advice -- re-flashing the same stick would not help. +run_helper loaded "failed" 0 timeout +check "timed-out unit is a slow medium" 1 "$RC" "install medium is too slow" "$OUT" + +# systemd stops a timed-out unit before it settles into failed, so the wait +# passes through deactivating. Waiting that out is what keeps the slow-medium +# advice: classifying it as terminal answered "did not run" instead. +run_helper loaded $'activating\ndeactivating\ndeactivating\nfailed' 0 timeout +check "a unit still stopping is waited out, not called unrunnable" 1 "$RC" \ + "install medium is too slow" "$OUT" + +# Never started and start leaves it inactive: cannot verify. +run_helper loaded "inactive" 0 +check "unit that will not run fails" 1 "$RC" "did not run" "$OUT" + +# Unit not on this system. +run_helper not-found "inactive" 0 +check "missing unit fails" 1 "$RC" "not on this live system" "$OUT" + +[[ $fails -eq 0 ]] && echo "ok: omarchy-wait-root-image-verify gate behaves" +exit "$fails"