diff --git a/bin/omarchy-iso-test b/bin/omarchy-iso-test index 3607901..3e670a8 100755 --- a/bin/omarchy-iso-test +++ b/bin/omarchy-iso-test @@ -14,6 +14,8 @@ # Usage: omarchy-iso-test [release/omarchy.iso] [options] # # --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 # --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/acceptance into the guest before running @@ -36,6 +38,7 @@ MEMORY=8192 INSTALL_TIMEOUT=2400 BOOT_TIMEOUT=600 ENCRYPT=false +PROVISION=false REUSE_BASE=false INSTALL_ONLY=false KEEP_RUNNING=false @@ -50,6 +53,7 @@ GUEST_HOSTNAME="omarchy-test" while (($#)); do case "$1" in --encrypt) ENCRYPT=true ;; + --provision) PROVISION=true ;; --reuse-base) REUSE_BASE=true ;; --install-only) INSTALL_ONLY=true ;; --keep-running) KEEP_RUNNING=true ;; @@ -86,6 +90,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" 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" @@ -96,7 +101,9 @@ HTTP_PID="" mkdir -p "$BASE_DIR" "$RUN_DIR" -QMP_SOCK="$RUN_DIR/qmp.sock" +# Unix socket paths are capped at ~108 bytes, which a run directory nested in +# test-runs//runs/ can exceed. Keep the socket in /tmp. +QMP_SOCK=$(mktemp -u "${TMPDIR:-/tmp}/omarchy-iso-test-qmp.XXXXXX.sock") PIDFILE="$RUN_DIR/qemu.pid" log() { @@ -286,6 +293,28 @@ type_text() { :) press shift-semicolon ;; @) press shift-2 ;; \|) press shift-backslash ;; + ";") press semicolon ;; + "'") press apostrophe ;; + '"') press shift-apostrophe ;; + "=") press equal ;; + "+") press shift-equal ;; + "%") press shift-5 ;; + "&") press shift-7 ;; + "*") press shift-8 ;; + "(") press shift-9 ;; + ")") press shift-0 ;; + "<") press shift-comma ;; + ">") press shift-dot ;; + "!") press shift-1 ;; + "#") press shift-3 ;; + '$') press shift-4 ;; + "?") press shift-slash ;; + "~") press shift-grave_accent ;; + "\\") press backslash ;; + "[") press bracket_left ;; + "]") press bracket_right ;; + "{") press shift-bracket_left ;; + "}") press shift-bracket_right ;; *) echo "type_text: unsupported character: $ch" >&2; return 1 ;; esac sleep 0.05 @@ -297,6 +326,7 @@ type_text() { ssh_guest() { ssh -i "$SSH_KEY" -p "$SSH_PORT" \ -o BatchMode=yes \ + -o IdentitiesOnly=yes \ -o StrictHostKeyChecking=no \ -o UserKnownHostsFile=/dev/null \ -o ConnectTimeout=5 \ @@ -576,10 +606,43 @@ audio-panel|meta_l-ctrl-a|omarchy-keyboard-panel|omarchy.audio' drive_configurator() { log "Driving the installer wizard" + # A greeter (animated logo + tagline) opens the installer; Return begins. + wait_for_screen "Opinionated" 300 + capture_console "success-installer-00-greeter" + press ret + + # Keyboard is the first screen. Ctrl+C there is the hidden entry into deferred provisioning + # mode; a normal install just selects the layout. wait_for_screen "keyboard layout" 300 capture_console "success-installer-01-keyboard-layout" + + if $PROVISION; then + press ctrl-c # arm "prepare for another owner" + wait_for_screen "another owner" 30 + capture_console "success-installer-02-prepare-confirm" + press ret # "Yes, prepare for another owner" is the affirmative default + + # Jumps straight to disk selection, then the overwrite confirm. + wait_for_screen "install disk" 60 + capture_console "success-installer-03-prepare-install-disk" + press ret + + wait_for_screen "recovery possible" 60 + capture_console "success-installer-04-prepare-disk-warning-encrypted" + if ! $ENCRYPT; then + press ctrl-c + wait_for_screen "without encryption" 30 + capture_console "success-installer-05-prepare-disk-warning-unencrypted" + fi + press ret + sleep 2 + capture_console "success-installer-13-install-started" + return 0 + fi + press ret # English (US) is preselected + # Normal flow: user step comes before disk selection. wait_for_screen "Username" 120 type_text "$GUEST_USER" capture_console "success-installer-02-username" @@ -622,17 +685,93 @@ drive_configurator() { capture_console "success-installer-10-install-disk" press ret # the single virtio disk + wait_for_screen "installation mode\|recovery possible" 60 + if ocr_screen | grep -qi "installation mode"; then + capture_console "success-installer-11-install-mode" + press ret # "Full disk install" is preselected + fi + wait_for_screen "recovery possible" 60 - capture_console "success-installer-11-disk-warning-encrypted" + capture_console "success-installer-12-disk-warning-encrypted" if ! $ENCRYPT; then press ctrl-c # toggles the confirm to the unencrypted flow wait_for_screen "without encryption" 30 - capture_console "success-installer-12-disk-warning-unencrypted" + capture_console "success-installer-13-disk-warning-unencrypted" fi press ret sleep 2 - capture_console "success-installer-13-install-started" + capture_console "success-installer-14-install-started" +} + +# The deferred-provisioning first boot runs the user form on tty1 (omarchy-provision-owner) before +# SDDM. Encrypted deferred-provisioning installs auto-unlock from the staged keyfile, so there +# is no LUKS prompt to answer here either way. +drive_provision_owner() { + log "Driving the deferred-provisioning first-boot setup" + + # Deferred provisioning defers the keyboard step to first boot; it comes before the user form. + # Pick English (UK) (one row above the preselected English (US)) so the + # verifier can prove the deferred step applied a non-default layout — UK + # leaves every letter identical to US, so the typed password (sent as raw + # keycodes) still matches the literal one the harness pipes to sudo. + # A greeter (animated logo + tagline) opens first boot; Return begins. + wait_for_screen "Opinionated" 600 + capture_console "success-provision-00-greeter" + press ret + + wait_for_screen "keyboard layout" 120 + press up + capture_console "success-provision-01-keyboard-layout-uk" + press ret + + wait_for_screen "Username" 120 + capture_console "success-provision-02-username" + type_text "$GUEST_USER" + press ret + + wait_for_screen "Password" 60 + type_text "$GUEST_PASSWORD" + capture_console "success-provision-03-password" + press ret + + wait_for_screen "Conf *irm" 60 + type_text "$GUEST_PASSWORD" + press ret + + wait_for_screen "Full name" 60 + type_text "Omarchy Test" + press ret + + wait_for_screen "Email address" 60 + # The keyboard step above applied English (UK), where '@' is Shift+' (not + # Shift+2 as on US); send that keycode so the typed email is correct under + # the new layout — itself further proof the deferred keymap took effect. + type_text "test" + press shift-apostrophe + type_text "omarchy.org" + capture_console "success-provision-04-email" + press ret + + # Hostname is deferred to first boot too. + wait_for_screen "Hostname" 60 + type_text "$GUEST_HOSTNAME" + capture_console "success-provision-05-hostname" + press ret + + # Timezone is deferred to first boot too; accept the geo-guessed default. + # Generous timeout: the tzupdate geo-guess over first-boot networking can lag. + wait_for_screen "Timezone" 180 + capture_console "success-provision-06-timezone" + press ret + + wait_for_screen "look right" 60 + capture_console "success-provision-07-review" + press ret + + # Finalization (offline Node unpack, LUKS re-key on encrypted installs) + # runs before SDDM appears. + capture_console "success-provision-08-finalizing" } wait_for_install() { @@ -649,6 +788,15 @@ wait_for_install() { return 0 fi + # Deferred-provisioning installs skip the celebration and reboot on their own + # (no Reboot Now prompt); the first-boot greeter tagline appearing means the + # install finished and rebooted into setup. + if $PROVISION && grep -qi "Opinionated" <<<"$text"; then + log "Install finished and auto-rebooted into first-boot setup." + capture_console "success-installer-15-autoreboot" + return 0 + fi + if grep -qi "installation stopped" <<<"$text"; then capture_console "failure-installer-installation-stopped" echo "Install failed — screenshot saved to $RUN_DIR" >&2 @@ -751,11 +899,11 @@ EOF press ret sleep 3 - type_text "curl -fsS http://10.0.2.2:$HTTP_PORT/bootstrap | bash" + type_text "curl -fsS http://10.0.2.2:$HTTP_PORT/bootstrap -o /tmp/bs && bash /tmp/bs" capture_console "success-first-boot-05-bootstrap-command" press ret - wait_for_ssh 180 "failure-first-boot-ssh-timeout" + wait_for_ssh 360 "failure-first-boot-ssh-timeout" capture_console "success-first-boot-06-bootstrap-complete" kill "$HTTP_PID" 2>/dev/null || true @@ -835,11 +983,63 @@ install_phase() { drive_configurator wait_for_install - if $ENCRYPT; then + if $PROVISION; then + drive_provision_owner + elif $ENCRYPT; then unlock_luks fi bootstrap_ssh + # 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 + # the base mid-finalization produces a flaky image, so wait for the pending + # flag to clear before powering off. + if $PROVISION; then + log "Waiting for provisioning first-boot setup to finish" + local waited=0 rc + while true; do + # Capture rc via `|| rc=$?` so a non-zero result doesn't trip `set -e` + # (this command is intentionally allowed to "fail" — that's the signal). + rc=0 + ssh_guest "test -f /var/lib/omarchy/provisioning/pending" 2>/dev/null || rc=$? + # rc 0 = flag still present; rc 255 = SSH not up yet (finalization may + # still be running) — keep waiting. Only a clean "file absent" (rc 1) + # means provisioning finished, so we don't snapshot mid-finalization on a + # transient SSH blip. + (( rc == 0 || rc == 255 )) || break + if ((waited >= 900)); then + capture_console "failure-provision-finalization-timeout" + echo "Timed out waiting for provisioning first-boot setup to complete" >&2 + return 1 + fi + sleep 10 + ((waited += 10)) + done + + # The owner's hostname and timezone are set at first boot now, not baked + # into the install — confirm they actually landed on the running system. + # Hostname is deterministic (we typed it), so a mismatch is a hard failure; + # timezone is geo-guessed, so just report what landed. + local got_host="" got_tz="" + got_host=$(ssh_guest "cat /etc/hostname" 2>/dev/null | tr -d '\r\n') || got_host="" + got_tz=$(ssh_guest "timedatectl show -p Timezone --value" 2>/dev/null | tr -d '\r\n') || got_tz="" + if [[ $got_host != "$GUEST_HOSTNAME" ]]; then + capture_console "failure-provision-hostname-not-applied" + echo "First-boot hostname is '${got_host:-}', expected '$GUEST_HOSTNAME'" >&2 + return 1 + fi + log "hostname applied at first boot: $got_host" + if [[ -n $got_tz && $got_tz != "UTC" ]]; then + log "timezone applied at first boot: $got_tz" + else + echo "WARN: timezone is '${got_tz:-}' (expected the owner's choice, not UTC)" >&2 + fi + + # Deferred first-boot setup hands straight off to SDDM once the oneshot + # service exits — no celebration screen, no Start Omarchy button to confirm. + capture_console "success-provision-09-handoff" + fi + ssh_guest "echo $GUEST_PASSWORD | sudo -S cat /var/log/omarchy-install-timing.json 2>/dev/null" \ >"$RUN_DIR/omarchy-install-timing.json" 2>/dev/null || true ssh_guest "echo $GUEST_PASSWORD | sudo -S cat /var/log/pacman.log 2>/dev/null" \ diff --git a/configs/airootfs/root/.automated_script.sh b/configs/airootfs/root/.automated_script.sh index 5a57508..16904f8 100644 --- a/configs/airootfs/root/.automated_script.sh +++ b/configs/airootfs/root/.automated_script.sh @@ -101,6 +101,16 @@ else ./configurator fi +# Deferred-provisioning installs skip the celebration/reboot prompt and reboot on +# their own — the owner completes setup at first boot. Signalled by the config's +# defer_provisioning flag (interactive) or the defer-provisioning marker (cidata). +# Parse the flag with jq (the same JSON semantics the orchestrator uses) rather +# than a line regex, so a reformatted config can't read as a direct install. +if [[ -f /root/defer-provisioning ]] || + [[ "$(jq -r '.omarchy_install.defer_provisioning // false' /root/user_configuration.json 2>/dev/null)" == "true" ]]; then + export OMARCHY_UI_DEFER_PROVISIONING=yes +fi + # The foreground dashboard is now the sole visible install UI owner. It starts # the actual installer as a non-interactive child, logs child output, waits for # completion, then renders the final installed-time/reboot prompt itself. @@ -117,4 +127,5 @@ rm -f /run/omarchy-install/state.json --email-file /root/user_email_address.txt \ --encrypt-file /root/user_encrypt_installation.txt \ --authorized-keys-file /root/authorized_keys \ - --tailscale-authkey-file /root/tailscale_authkey + --tailscale-authkey-file /root/tailscale_authkey \ + --defer-provisioning-file /root/defer-provisioning diff --git a/configs/airootfs/root/configurator b/configs/airootfs/root/configurator index 5b67e24..3a1ca9e 100644 --- a/configs/airootfs/root/configurator +++ b/configs/airootfs/root/configurator @@ -38,12 +38,111 @@ measure_terminal() { } measure_terminal +# The live VT resizes late during boot: tty1 starts as the 80-column boot +# console and only grows once the kernel framebuffer/KMS takes over (noticeably +# late in VMs). The logo is wider than 80 columns, so a first draw against that +# transient narrow console wraps the logo ("smushed") and can't center it +# (padding clamps to 0, so it hugs the left). A terminal emulator (or sudo's +# pty) has the same problem the other way: it reports a stale 24x80 for a few +# hundred ms after start before its real winsize is set. Either way, hold the +# first draw until the width settles at (or above) the logo width. A real VT +# that never widens (e.g. nomodeset) still proceeds after the cap. +wait_for_stable_terminal() { + local width last="" stable=0 waited=0 + while (( waited < 5000 )); do + width=$(stty size 2>/dev/null = LOGO_WIDTH )); then + (( ++stable >= 3 )) && break + else + stable=0 + fi + last=$width + sleep 0.2 + (( waited += 200 )) + done +} + clear_logo() { measure_terminal printf "\033[H\033[2J" gum style --foreground 2 --padding "1 0 0 $PADDING_LEFT" "$(<"$LOGO_PATH")" } +# Vertically-centered welcome shown once before the keyboard step — the same +# frame the deferred-provisioning first boot opens on, so install and first boot +# bookend the setup identically. Logo, the Omarchy tagline, and a single button, +# centered like the boot logo. No animation. +greeter() { + measure_terminal + + local rows logo_h content_h top cols logo_row tagline_row hint_row + local tagline hint tpad hpad anim + cols=$TERM_WIDTH + rows=$(stty size 2>/dev/null /dev/null 2>&1; then + printf '\033[%d;1H\0337' "$((logo_row + logo_h))" + # Run tte directly (not inside a `while` subshell) so $anim is tte's own + # PID: killing a wrapping subshell would orphan tte, which then keeps + # painting the logo over the keyboard step. --cycles is high enough that it + # never ends on its own before Return. + tte -i "$LOGO_PATH" \ + --canvas-width "$cols" \ + --anchor-text c \ + --frame-rate 60 \ + --reuse-canvas \ + colorshift \ + --gradient-stops 2 10 6 10 \ + --gradient-frames 3 \ + --cycles 1000 \ + --final-gradient-stops 2 \ + /dev/tty 2>/dev/null & + anim=$! + fi + + IFS= read -r _ /dev/null; wait "$anim" 2>/dev/null || true; } + # tte leaves the tty in raw/no-echo mode when killed; restore it or the gum + # prompts in the keyboard step that follows silently die. + stty sane /dev/null || true + # Reset the screen so the leftover animation frame doesn't linger under the + # keyboard step that follows. + printf '\033[0m\033[H\033[2J\033[?25h' +} + abort() { gum style "${1:-Aborted installation}" echo @@ -73,11 +172,22 @@ notice() { # STEP 1: KEYBOARD LAYOUT keyboard_form() { - step "Let's setup your machine..." + # The Ctrl+C hint sits directly under the header (no blank line between), + # matching the disk-overwrite screen's unencrypted offer. Ctrl+C on the first + # screen is the hidden entry into deferred provisioning; $1 is "true" only for that first + # invocation, so the review-loop re-edit can't flip a user install into it. + local allow_defer_provisioning="${1:-false}" + clear_logo + echo + say "Let's setup your machine..." + if $allow_defer_provisioning; then + say --foreground 8 "Press Ctrl+C to prepare this machine for another owner." + fi + echo + keyboards=$'Azerbaijani|azerty Belarusian|by Belgian|be-latin1 -Bosnian|ba Bulgarian|bg-cp1251 Croatian|croat Czech|cz @@ -103,7 +213,6 @@ Irish|ie Italian|it Japanese|jp106 Kazakh|kazakh -Khmer (Cambodia)|khmer Kyrgyz|kyrgyz Lao|la-latin1 Latvian|lv @@ -124,7 +233,19 @@ Swedish|sv-latin1 Tajik|tj_alt-UTF8 Turkish|trq Ukrainian|ua' - choice=$(printf '%s\n' "$keyboards" | cut -d'|' -f1 | gum choose --height 10 --selected "English (US)" --header "Select keyboard layout") || abort + choice=$(printf '%s\n' "$keyboards" | cut -d'|' -f1 | gum choose --height 10 --selected "English (US)" --header "Select keyboard layout") + local status=$? + + if $allow_defer_provisioning && (( status == 130 )); then + if confirm_prepare_for_another_owner; then + defer_provisioning=true + return 0 + fi + keyboard_form true # declined: back to the keyboard picker + return + fi + (( status == 0 )) || abort + keyboard=$(printf '%s\n' "$keyboards" | awk -F'|' -v c="$choice" '$1==c{print $2; exit}') # Only attempt to load keyboard layout if we're on a real console @@ -134,9 +255,22 @@ Ukrainian|ua' fi } -keyboard_form +# Ctrl+C on the first screen offers to prepare the machine for another owner. +# Confirm it before committing, so an accidental Ctrl+C doesn't silently switch +# the whole install. +confirm_prepare_for_another_owner() { + clear_logo + echo + say "This prepares the machine for another owner." + say --foreground 8 "The system installs now, but setup is delayed until first boot." + echo + gum confirm --affirmative "Yes, prepare for another owner" --negative "No, keep setting up" \ + "Prepare this machine for another owner?" +} -# STEP 2: USER +# The user step. Deferred-provisioning installs skip it entirely — the machine's first owner +# picks their keyboard and creates their user in the first-boot setup, so the +# operator sets nothing user-specific. user_form() { step "Let's setup your user account..." @@ -197,11 +331,12 @@ user_form() { fi } -user_form +user_step() { + user_form -while true; do - # Add manual padding since gum table -p doesn't respect padding - echo -e "Field,Value + while true; do + # Add manual padding since gum table -p doesn't respect padding + echo -e "Field,Value Username,$username Password,$(printf "%${#password}s" | tr ' ' '*') Full name,${full_name:-[Skipped]} @@ -209,16 +344,17 @@ Email address,${email_address:-[Skipped]} Hostname,$hostname Timezone,$timezone Keyboard,$keyboard" | - gum table -s "," -p | sed "s/^/${PADDING_LEFT_SPACES}/" + gum table -s "," -p | sed "s/^/${PADDING_LEFT_SPACES}/" - echo - if gum confirm --negative "No, change it" "Does this look right?"; then - break - else - keyboard_form - user_form - fi -done + echo + if gum confirm --negative "No, change it" "Does this look right?"; then + break + else + keyboard_form + user_form + fi + done +} # STEP 3: DISK @@ -347,11 +483,22 @@ wait_for_device() { # Save the configurator answers the installer consumes: git identity and # encryption choice as plain files, account details as archinstall credentials. +# Deferred-provisioning installs defer the user to first boot: no credentials, and on encrypted +# targets the orchestrator generates a throwaway LUKS passphrase itself. write_user_files() { echo "$full_name" >user_full_name.txt echo "$email_address" >user_email_address.txt printf '%s\n' "$encrypt_installation" >user_encrypt_installation.txt + if $defer_provisioning; then + cat <<-_EOF_ >user_credentials.json +{ + "users": [] +} +_EOF_ + return + fi + local password_escaped password_hash_escaped username_escaped password_escaped=$(echo -n "$password" | jq -Rsa) password_hash_escaped=$(echo -n "$password_hash" | jq -Rsa) @@ -413,10 +560,10 @@ not_enough_space() { return 1 } -run_partition_install() { - local dry=false - [[ "${1:-}" == "dry" ]] && dry=true - +# Decide half of the free-space install: checks, free-space analysis, and the +# encryption confirm. Sets the globals run_partition_execute needs. Returns 1 +# to fall back to the install-mode picker. +run_partition_decide() { step "Checking for BitLocker on $disk" local bl_part bl_part=$(detect_bitlocker || true) @@ -448,7 +595,8 @@ run_partition_install() { # An unlabeled disk has no partition table for parted to scan, so we # synthesize a whole-disk free region and mklabel gpt before mkpart. - local FREE_SPACE_INFO needs_mklabel=false pt_type disk_size_b + local FREE_SPACE_INFO pt_type disk_size_b + needs_mklabel=false pt_type=$(lsblk -dno PTTYPE "$disk" 2>/dev/null) if [[ -z "$pt_type" ]]; then disk_size_b=$(lsblk -bdno SIZE "$disk" 2>/dev/null) @@ -491,7 +639,6 @@ run_partition_install() { local EFI_SIZE_B=$((2 * gib)) local MIN_INSTALL_B=$((32 * gib)) # Minimum total Omarchy allocation - local EFI_START_B EFI_END_B ROOT_START_B ROOT_END_B EFI_START_B=$(( (FREE_START_B + ALIGN - 1) / ALIGN * ALIGN )) EFI_END_B=$((EFI_START_B + EFI_SIZE_B)) ROOT_START_B=$(( (EFI_END_B + 1 + ALIGN - 1) / ALIGN * ALIGN )) @@ -554,7 +701,6 @@ run_partition_install() { esac done - local esp_mount_in_target if [[ "$encrypt_installation" == "true" ]]; then esp_mount_in_target=/boot else @@ -563,12 +709,11 @@ run_partition_install() { kernel_choice=$(detect_kernel) - local last_part efi_part_num root_part_num + local last_part last_part=$(lsblk -n -o PARTN "$disk" 2>/dev/null | grep -E '^[0-9]+$' | sort -n | tail -1) efi_part_num=$(( ${last_part:-0} + 1 )) root_part_num=$(( efi_part_num + 1 )) - local efi_dev root_partition_device root_mapper efi_dev=$(partition_path "$disk" "$efi_part_num") root_partition_device=$(partition_path "$disk" "$root_part_num") if [[ "$encrypt_installation" == "true" ]]; then @@ -576,6 +721,14 @@ run_partition_install() { else root_mapper="$root_partition_device" fi +} + +# Execute half of the free-space install: partition, format, mount, and write +# the pre-mounted (protected) install configuration. Runs after the user step +# so LUKS can be formatted with the user's password. +run_partition_execute() { + local dry=false + [[ "${1:-}" == "dry" ]] && dry=true if $dry; then $needs_mklabel && say "[dry] would initialize GPT on $disk" @@ -634,10 +787,6 @@ run_partition_install() { mount "$efi_dev" /mnt"$esp_mount_in_target" fi - clear - - write_user_files - local luks_uuid_json="null" luks_uuid if [[ "$encrypt_installation" == "true" ]]; then if $dry; then @@ -716,10 +865,6 @@ run_partition_install() { "version": "3.0.9" } _EOF_ - - if $dry; then - print_dry_run_files - fi } disk_form() { @@ -771,8 +916,7 @@ open_partition_tool() { } # If the disk has no partition table or no unallocated free space, the only -# viable path is a full-disk install. Skip the install-mode picker and go -# straight to confirmation. +# viable paths write the whole disk, so the free-space mode is not offered. requires_full_disk_install() { local pt_type free_space_b pt_type=$(lsblk -dno PTTYPE "$disk" 2>/dev/null) @@ -799,7 +943,7 @@ install_mode_form() { # The free-space/protected path registers an EFI boot entry and is UEFI-only. # Full-disk installs still support BIOS through the orchestrator's BIOS branch. - if [[ -d /sys/firmware/efi ]]; then + if [[ -d /sys/firmware/efi ]] && ! $full_disk_only; then choices+=("Free space install (alongside existing data)") fi choices+=("Choose a different disk") @@ -846,30 +990,43 @@ confirm_disk_overwrite() { done } +# Decide what and how to install: sets install_target (full_disk|free_space), +# install_target and encrypt_installation. Nothing is written to disk yet — +# the user step runs between this and the execution below. (deferred provisioning never +# reaches here; it's armed by Ctrl+C on the first screen and goes straight to +# disk selection.) select_installation() { local full_disk_only while true; do if requires_full_disk_install; then full_disk_only=true - install_mode="Full disk install" else full_disk_only=false + fi + + # With no free-space option to offer, a full-disk install is the only + # mode, so skip the picker and go straight to the overwrite confirm. + if $full_disk_only; then + install_mode="Full disk install" + else install_mode_form fi case "$install_mode" in "Full disk install") if confirm_disk_overwrite; then + install_target="full_disk" return 0 fi - if $full_disk_only; then - disk_form - fi + # When the picker was skipped, declining the overwrite means the user + # wants a different disk. + $full_disk_only && disk_form ;; - "Free space install (alongside existing data)") - if run_partition_install "$@"; then - exit 0 + "Free space install"*) + if run_partition_decide; then + install_target="free_space" + return 0 fi ;; "Choose a different disk") @@ -879,13 +1036,64 @@ select_installation() { done } -disk_form -select_installation "$@" +# STEP 1: KEYBOARD (Ctrl+C here arms deferred provisioning and jumps to disk selection) + +defer_provisioning=false +install_target="full_disk" + +# Let the live VT reach its settled width before the first screen so the logo +# isn't measured against the transient 80-column boot console. +wait_for_stable_terminal + +greeter + +keyboard_form true + +if $defer_provisioning; then + # Deferred provisioning: no user setup, and the keyboard is deferred to the machine's first + # boot. A defined default keymap so it boots before then; the owner picks + # their real layout in the OOBE, which re-keys the console and, on encrypted + # installs, rebuilds the initramfs so the LUKS prompt matches. + keyboard="us" + username="" + password="" + password_hash="" + full_name="" + email_address="" + # Neutral defaults for the install; the owner overwrites both in the + # first-boot OOBE (omarchy-provision-owner). + hostname="omarchy" + timezone="UTC" + + # Jump straight to disk selection + overwrite confirmation (encrypted by + # default, Ctrl+C toggles unencrypted). deferred provisioning is always a full-disk install. + disk_form + until confirm_disk_overwrite; do + disk_form + done + install_target="full_disk" +else + # Normal install: keyboard (done above) → user → disk → install mode. The + # keyboard chosen above is already loaded, so the LUKS passphrase entered in + # the user step is typed under the right layout. + user_step + disk_form + select_installation +fi clear write_user_files +if [[ $install_target == "free_space" ]]; then + run_partition_execute "$@" + + if [[ ${1:-} == "dry" ]]; then + print_dry_run_files + fi + exit 0 +fi + # Setup partition layout disk_size=$(lsblk -bdno SIZE "$disk") mib=$((1024 * 1024)) @@ -902,14 +1110,21 @@ main_partition_size=$((disk_size_in_mib - main_partition_start - gpt_backup_rese kernel_choice=$(detect_kernel) if [[ $encrypt_installation == true ]]; then + # Deferred-provisioning installs carry no password: the orchestrator generates a throwaway + # LUKS passphrase and stages it for the first-boot re-key. + if $defer_provisioning; then + encryption_password_line="" + else + encryption_password_line=", + \"encryption_password\": $(echo -n "$password" | jq -Rsa)" + fi disk_encryption_config=$(cat <<-_EOF_ , "disk_encryption": { "encryption_type": "luks", "lvm_volumes": [], "iter_time": 2000, - "partitions": [ "8c2c2b92-1070-455d-b76a-56263bab24aa" ], - "encryption_password": $(echo -n "$password" | jq -Rsa) + "partitions": [ "8c2c2b92-1070-455d-b76a-56263bab24aa" ]$encryption_password_line } _EOF_ ) @@ -927,6 +1142,7 @@ cat <<-_EOF_ >user_configuration.json "custom_commands": [], "omarchy_install": { "mode": "full_disk", + "defer_provisioning": $defer_provisioning, "target_mount": "/mnt", "boot": { "esp_mount": "/boot", diff --git a/configs/airootfs/usr/local/bin/omarchy-cidata-load b/configs/airootfs/usr/local/bin/omarchy-cidata-load index 67bcd28..6578849 100755 --- a/configs/airootfs/usr/local/bin/omarchy-cidata-load +++ b/configs/airootfs/usr/local/bin/omarchy-cidata-load @@ -11,6 +11,9 @@ # Anything less than the pair means this isn't an autoinstall drive, and the # caller falls back to the wizard. # +# A `defer-provisioning` marker file stands in for user_credentials.json: deferred-provisioning +# installs defer user creation to first boot, so imaging rigs ship no credentials. +# # The optional argument prefixes every path the script touches; tests use it to # run against a sandbox instead of the live system. @@ -35,10 +38,20 @@ done mkdir -p "$mnt" mount -o ro "$device" "$mnt" || exit 1 +# Clear any inputs a previous load in this live session left in /root before +# copying the current drive: a stale `defer-provisioning` marker or credentials file would +# otherwise force a fresh drive back into deferred provisioning or hand it an old +# LUKS passphrase. The interactive wizard writes these same names, but a +# cidata load always precedes it, so nothing of the user's is discarded here. +optional_inputs=(user_credentials.json defer-provisioning user_full_name.txt user_email_address.txt user_encrypt_installation.txt authorized_keys tailscale_authkey) +for file in user_configuration.json "${optional_inputs[@]}"; do + rm -f "$dest/$file" +done + loaded=1 -if [[ -f $mnt/user_configuration.json && -f $mnt/user_credentials.json ]]; then - files=("$mnt"/user_configuration.json "$mnt"/user_credentials.json) - for file in user_full_name.txt user_email_address.txt user_encrypt_installation.txt authorized_keys tailscale_authkey; do +if [[ -f $mnt/user_configuration.json ]] && [[ -f $mnt/user_credentials.json || -f $mnt/defer-provisioning ]]; then + files=("$mnt"/user_configuration.json) + for file in "${optional_inputs[@]}"; do [[ -f $mnt/$file ]] && files+=("$mnt/$file") done if cp "${files[@]}" "$dest"/; then diff --git a/configs/airootfs/usr/local/bin/omarchy-install-dashboard b/configs/airootfs/usr/local/bin/omarchy-install-dashboard index 9dc2672..bf6da9d 100755 --- a/configs/airootfs/usr/local/bin/omarchy-install-dashboard +++ b/configs/airootfs/usr/local/bin/omarchy-install-dashboard @@ -321,11 +321,13 @@ install_progress() { "Installing Arch + Omarchy") lo=65 hi=700 tau=150 pkg_signal=1 ;; "Configuring hibernation") lo=700 hi=720 tau=10 ;; "Configuring system") lo=720 hi=850 tau=120 ;; - "Finalizing Limine boot") lo=850 hi=950 tau=90 ;; + "Staging provisioning") lo=850 hi=855 tau=5 ;; + "Finalizing Limine boot") lo=855 hi=950 tau=90 ;; "Finalizing user") lo=950 hi=978 tau=30 ;; "Configuring login") lo=978 hi=988 tau=5 ;; "Configuring DNS resolver") lo=988 hi=994 tau=5 ;; - "Validating boot setup") lo=994 hi=1000 tau=5 ;; + "Validating boot setup") lo=994 hi=998 tau=5 ;; + "Creating factory snapshot") lo=998 hi=1000 tau=5 ;; *) # Unknown names used to inherit a 1->99 sweep. Derive an even band from # the published index instead; with no index, a bounded slice, so a @@ -734,6 +736,17 @@ if (( status != 0 )); then exit "$failure_status" fi +# Deferred-provisioning installs finish silently and reboot on their own — the +# owner completes setup (and sees the send-off) at first boot, so the "Installed +# Omarchy in ..." celebration and the Reboot Now prompt belong to a direct +# install only. +if [[ ${OMARCHY_UI_DEFER_PROVISIONING:-} == "yes" ]]; then + printf '%s' "$CLEAR" >"$TTY_PATH" 2>/dev/null || true + [[ ${OMARCHY_UI_AUTO_REBOOT:-} == "no" ]] && exit 0 + reboot 2>/dev/null || systemctl reboot 2>/dev/null || true + exit 0 +fi + set +e render_finish finish_render_status=$? diff --git a/configs/airootfs/usr/local/bin/omarchy-iso-install b/configs/airootfs/usr/local/bin/omarchy-iso-install index baceddf..59aca95 100755 --- a/configs/airootfs/usr/local/bin/omarchy-iso-install +++ b/configs/airootfs/usr/local/bin/omarchy-iso-install @@ -18,6 +18,7 @@ while [[ $# -gt 0 ]]; do --encrypt-file) export OMARCHY_INSTALL_ENCRYPT_FILE="$2"; shift 2 ;; --authorized-keys-file) export OMARCHY_INSTALL_AUTHORIZED_KEYS_FILE="$2"; shift 2 ;; --tailscale-authkey-file) export OMARCHY_INSTALL_TAILSCALE_AUTHKEY_FILE="$2"; shift 2 ;; + --defer-provisioning-file) export OMARCHY_INSTALL_DEFER_PROVISIONING_FILE="$2"; shift 2 ;; *) echo "omarchy-iso-install: unknown arg $1" >&2; exit 2 ;; esac done diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py index 72958e1..9b5c50f 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py @@ -6,6 +6,7 @@ from __future__ import annotations import json import os +import secrets from dataclasses import dataclass, field from pathlib import Path from typing import Any @@ -25,6 +26,7 @@ class InstallContext: user_credentials: dict arch_config_path: Path omarchy_install: dict[str, Any] + defer_provisioning: bool = False target: Path = Path("/mnt") omarchy_path: Path = Path("/usr/share/omarchy") @@ -48,10 +50,54 @@ class InstallContext: user_configuration = json.loads(config_path.read_text()) omarchy_install = user_configuration.get("omarchy_install") or _default_omarchy_install(user_configuration) + # Deferred provisioning: the whole system installs but user creation is deferred to + # first boot. Selected by the configurator (omarchy_install.defer_provisioning) or by + # an `defer-provisioning` marker file on an autoinstall drive, which also replaces the + # user_credentials.json requirement. + defer_provisioning_marker = _optional_path(os.environ.get("OMARCHY_INSTALL_DEFER_PROVISIONING_FILE")) + defer_provisioning = bool(omarchy_install.get("defer_provisioning")) or defer_provisioning_marker is not None + omarchy_install["defer_provisioning"] = defer_provisioning + + if creds_path.exists(): + user_credentials = json.loads(creds_path.read_text()) + elif defer_provisioning: + user_credentials = {"users": []} + else: + raise RuntimeError(f"credentials file missing: {creds_path}") + + if defer_provisioning: + # Deferred provisioning promises no account until first boot. A supplied + # credentials file (a rig handing over its LUKS passphrase) must + # not smuggle in users or a root password. + encryption_password = user_credentials.get("encryption_password") + user_credentials = {"users": []} + if encryption_password: + user_credentials["encryption_password"] = encryption_password + arch_configuration = dict(user_configuration) arch_configuration.pop("omarchy_install", None) + + if defer_provisioning: + # The userless invariant covers the archinstall config too: a + # combined/legacy config could carry account authentication that + # archinstall would still act on (users, a root password). Strip + # every such field so no account is created before first boot. + _strip_account_fields(arch_configuration) + state_dir = Path(os.environ.get("OMARCHY_INSTALL_STATE_DIR", "/run/omarchy-install")) state_dir.mkdir(parents=True, exist_ok=True) + + if defer_provisioning: + # Encrypted deferred-provisioning installs have no user password to protect LUKS + # with. Generate a throwaway passphrase (staged for first-boot + # re-key by the stage_provisioning_state phase) and hand it to archinstall + # through both places it may look: the disk_encryption block and + # the credentials file. + _inject_provisioning_encryption_password(arch_configuration, user_credentials) + creds_path = state_dir / "provisioning-user_credentials.json" + creds_path.write_text(json.dumps(user_credentials, indent=2) + "\n") + creds_path.chmod(0o600) + arch_config_path = state_dir / "archinstall-user_configuration.json" arch_config_path.write_text(json.dumps(arch_configuration, indent=2) + "\n") @@ -64,9 +110,10 @@ class InstallContext: authorized_keys_path=_optional_path(os.environ.get("OMARCHY_INSTALL_AUTHORIZED_KEYS_FILE")), tailscale_authkey_path=_optional_path(os.environ.get("OMARCHY_INSTALL_TAILSCALE_AUTHKEY_FILE")), user_configuration=user_configuration, - user_credentials=json.loads(creds_path.read_text()), + user_credentials=user_credentials, arch_config_path=arch_config_path, omarchy_install=omarchy_install, + defer_provisioning=defer_provisioning, state_dir=state_dir, ) disk_config = user_configuration.get("disk_config", {}) @@ -77,7 +124,12 @@ class InstallContext: @property def username(self) -> str: - return self.user_credentials["users"][0]["username"] + users = self.user_credentials.get("users") or [] + if users: + return users[0]["username"] + if self.defer_provisioning: + return "" + raise RuntimeError("user_credentials.json contains no users") @property def mode(self) -> str: @@ -91,6 +143,38 @@ class InstallContext: return self.mode == "protected" +def _strip_account_fields(arch_configuration: dict) -> None: + """Remove every field archinstall reads to create users or set a root + password, so it cannot ship a hidden provisioning account. + Encryption material lives elsewhere (disk_config.disk_encryption) and is + untouched.""" + for key in ("!users", "!root-password", "root_enc_password", "users"): + arch_configuration.pop(key, None) + + auth = arch_configuration.get("auth_config") + if isinstance(auth, dict): + for key in ("users", "root_enc_password"): + auth.pop(key, None) + + +def _inject_provisioning_encryption_password(arch_configuration: dict, user_credentials: dict) -> None: + """Ensure an encrypted deferred-provisioning install has a LUKS passphrase archinstall can + use. Reuse one the input already carries (an autoinstall rig may supply + its own); otherwise generate a throwaway. Mutates the disk_encryption + block in place — arch_configuration shares it with user_configuration, so + the stage_provisioning_state phase can read the effective passphrase back.""" + disk_encryption = (arch_configuration.get("disk_config") or {}).get("disk_encryption") + if disk_encryption is None: + return + + password = disk_encryption.get("encryption_password") or user_credentials.get("encryption_password") + if not password: + password = secrets.token_urlsafe(24) + + disk_encryption["encryption_password"] = password + user_credentials["encryption_password"] = password + + def _default_omarchy_install(user_configuration: dict) -> dict[str, Any]: disk_config = user_configuration.get("disk_config", {}) mode = "protected" if disk_config.get("config_type") == "pre_mounted_config" else "full_disk" diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py index a73f7db..9b6ebf5 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py @@ -34,6 +34,7 @@ def build_phases(ctx: InstallContext): arch_install_system, configure_hibernation, run_system_finalizer, + stage_provisioning_state, finalize_limine_boot, run_chroot_finalizer, configure_dns_resolver, @@ -41,6 +42,7 @@ def build_phases(ctx: InstallContext): configure_ssh_access, configure_tailscale, validate_boot, + create_factory_snapshot, ) return [ @@ -49,6 +51,9 @@ def build_phases(ctx: InstallContext): ("Installing Arch + Omarchy", arch_install_system), ("Configuring hibernation", configure_hibernation), ("Configuring system", run_system_finalizer), + # Before finalize_limine_boot: the deferred-provisioning cryptkey drop-in and keyfile + # must be in place for the final UKI build. + ("Staging provisioning", stage_provisioning_state), ("Finalizing Limine boot", finalize_limine_boot), ("Finalizing user", run_chroot_finalizer), ("Configuring login", configure_login), @@ -56,6 +61,7 @@ def build_phases(ctx: InstallContext): ("Configuring Tailscale", configure_tailscale), ("Configuring DNS resolver", configure_dns_resolver), ("Validating boot setup", validate_boot), + ("Creating factory snapshot", create_factory_snapshot), ] @@ -66,7 +72,8 @@ def main() -> int: error(f"Configuration error: {e}") return 2 - info(f"Installing Omarchy for {ctx.username} → {ctx.target}") + who = ctx.username or "deferred provisioning (user created at first boot)" + info(f"Installing Omarchy for {who} → {ctx.target}") from .phases_impl import ( boost_cpu_governor, 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 76a28e2..ffdebe0 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py @@ -13,7 +13,7 @@ Phase ordering (full-disk and protected/pre-mounted): configure_hibernation → root-owned swap/resume drop-ins run_system_finalizer → arch-chroot root omarchy-setup-system, including Snapper finalize_limine_boot → final Limine config/UKI build after hardware drop-ins - run_chroot_finalizer → arch-chroot -u user omarchy-finalize-user + run_chroot_finalizer → arch-chroot -u user omarchy-provision-user configure_login → sddm state + encrypted-install autologin configure_ssh_access → authorized_keys for autoinstall; no-op otherwise configure_tailscale → tailnet join staged for first boot; no-op otherwise @@ -1006,7 +1006,7 @@ def _debug_run(ctx: InstallContext, cmd: list[str]) -> None: # 2. bind-mount the offline mirror + /opt/packages into /mnt for target pacman # and bundled language runtimes # 3. arch-chroot as root → omarchy-setup-system --first-install -# 4. arch-chroot as user → omarchy-finalize-user --first-install +# 4. arch-chroot as user → omarchy-provision-user --first-install # ───────────────────────────────────────────────────────────────────────────── def _prepare_target_setup(ctx: InstallContext) -> None: @@ -1136,16 +1136,142 @@ def _run_target_setup_command(ctx: InstallContext, cmd: list[str], *, user: str def run_system_finalizer(ctx: InstallContext) -> None: + if ctx.defer_provisioning: + cmd = ["/usr/bin/omarchy-setup-system", "--defer-provisioning", "--first-install"] + else: + cmd = ["/usr/bin/omarchy-setup-system", "--install-user", ctx.username, "--first-install"] + _mask_mkinitcpio_pacman_hooks(ctx, ctx.target, TARGET_DEFERRED_BOOT_HOOKS) try: - _run_target_setup_command( - ctx, - ["/usr/bin/omarchy-setup-system", "--install-user", ctx.username, "--first-install"], - ) + _run_target_setup_command(ctx, cmd) finally: _unmask_mkinitcpio_pacman_hooks(ctx, ctx.target, TARGET_DEFERRED_BOOT_HOOKS) +# ───────────────────────────────────────────────────────────────────────────── +# stage_provisioning_state: produce the on-disk "provisioning state" the runtime's first-boot +# setup (omarchy-provision-owner) and factory reset (omarchy-system-factory-reset) consume. +# +# Every install stashes the bundled Node tarball in /var/lib/omarchy/provisioning/ +# so a later factory reset can finalize the new owner's user offline. deferred-provisioning +# installs additionally arm the first-boot setup service and, on encrypted +# targets, stage the throwaway LUKS passphrase: the keyfile embedded in the +# initramfs auto-unlocks boot during the provisioning window, and first-boot setup +# re-keys the volume to the owner's password and removes it. +# +# Runs before finalize_limine_boot so the cryptkey cmdline drop-in and the +# keyfile land in the final UKI build. +# ───────────────────────────────────────────────────────────────────────────── + +PROVISION_STATE_DIR = "var/lib/omarchy/provisioning" +PROVISION_KEYFILE = "etc/omarchy/provisioning.key" +NODE_PACKAGES_DIR = Path("/opt/packages") + + +def stage_provisioning_state(ctx: InstallContext) -> None: + # World-readable: first-boot finalization reads the Node tarball as the + # new user. The only secret inside (luks-key) is itself 0600 root. + provisioning_dir = ctx.target / PROVISION_STATE_DIR + provisioning_dir.mkdir(parents=True, exist_ok=True) + provisioning_dir.chmod(0o755) + + _stage_node_tarball(ctx, provisioning_dir) + + if not ctx.defer_provisioning: + return + + service_src = ctx.target / "usr/share/omarchy/install/provisioning/omarchy-provision-owner.service" + setup_bin = ctx.target / "usr/bin/omarchy-provision-owner" + if not service_src.exists() or not setup_bin.exists(): + raise RuntimeError( + "deferred-provisioning install requested, but the installed Omarchy runtime does not ship " + "first-boot setup (omarchy-provision-owner + install/provisioning/omarchy-provision-owner.service). " + "Update the runtime package this ISO bundles before installing in deferred provisioning." + ) + + info("› arming first-boot setup") + (provisioning_dir / "pending").touch() + + unit_dst = ctx.target / "etc/systemd/system/omarchy-provision-owner.service" + unit_dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(service_src, unit_dst) + wants_dir = ctx.target / "etc/systemd/system/multi-user.target.wants" + wants_dir.mkdir(parents=True, exist_ok=True) + link = wants_dir / "omarchy-provision-owner.service" + link.unlink(missing_ok=True) + link.symlink_to("/etc/systemd/system/omarchy-provision-owner.service") + + if _provision_install_encrypted(ctx): + _stage_provisioning_luks_unlock(ctx, provisioning_dir) + + +def _stage_node_tarball(ctx: InstallContext, provisioning_dir) -> None: + tarballs = sorted(NODE_PACKAGES_DIR.glob("node-v*-linux-x64.tar.gz")) + if not tarballs: + # Hard error on every install, not just deferred-provisioning installs: the stash is what lets a + # later factory reset finalize the next owner offline, and an ISO + # build always bundles the tarball — its absence means a broken build. + raise RuntimeError( + f"no bundled Node tarball in {NODE_PACKAGES_DIR} — first-boot setup " + "and factory reset could not finalize a user offline" + ) + + packages_dir = provisioning_dir / "packages" + packages_dir.mkdir(parents=True, exist_ok=True) + target_tarball = packages_dir / tarballs[0].name + if not target_tarball.exists(): + info("› stashing Node tarball for offline first-boot setup") + shutil.copy2(tarballs[0], target_tarball) + + +def _provision_install_encrypted(ctx: InstallContext) -> bool: + if _storage_intent(ctx).get("luks_uuid"): + return True + disk_encryption = (ctx.user_configuration.get("disk_config") or {}).get("disk_encryption") + if disk_encryption and disk_encryption.get("encryption_type", "luks") != "no_encryption": + return True + return ctx.encrypt + + +def _provision_encryption_password(ctx: InstallContext) -> str | None: + disk_encryption = (ctx.user_configuration.get("disk_config") or {}).get("disk_encryption") or {} + return disk_encryption.get("encryption_password") or ctx.user_credentials.get("encryption_password") + + +def _stage_provisioning_luks_unlock(ctx: InstallContext, provisioning_dir) -> None: + password = _provision_encryption_password(ctx) + if not password: + # Full-disk deferred-provisioning installs get a generated passphrase injected by + # InstallContext; only a pre-mounted (rig-partitioned) LUKS target can + # land here, and it must hand over the passphrase it formatted with. + raise RuntimeError( + "deferred-provisioning install on a pre-encrypted target requires the LUKS passphrase " + "in user_credentials.json (encryption_password) so first boot can re-key" + ) + + info("› staging LUKS auto-unlock for the provisioning window") + + # Byte-for-byte the slot passphrase: no trailing newline anywhere. + luks_key = provisioning_dir / "luks-key" + luks_key.write_text(password) + luks_key.chmod(0o600) + + keyfile = ctx.target / PROVISION_KEYFILE + keyfile.parent.mkdir(parents=True, exist_ok=True) + keyfile.write_text(password) + keyfile.chmod(0o600) + + cmdline_dropin = ctx.target / "etc/limine-entry-tool.d/99-omarchy-provisioning-unlock.conf" + cmdline_dropin.parent.mkdir(parents=True, exist_ok=True) + cmdline_dropin.write_text( + 'KERNEL_CMDLINE[default]+=" cryptkey=rootfs:/etc/omarchy/provisioning.key"\n' + ) + + files_dropin = ctx.target / "etc/mkinitcpio.conf.d/99-omarchy-provisioning-key.conf" + files_dropin.parent.mkdir(parents=True, exist_ok=True) + files_dropin.write_text("FILES+=(/etc/omarchy/provisioning.key)\n") + + def finalize_limine_boot(ctx: InstallContext) -> None: """Finalize Limine after target system setup has written all dynamic boot drop-ins (hibernation, hardware quirks, protected-mode ESP settings). @@ -1239,9 +1365,13 @@ def _limine_kernel_cmdline(config_text: str) -> str: def run_chroot_finalizer(ctx: InstallContext) -> None: + if ctx.defer_provisioning: + info("› deferred-provisioning install: user finalization deferred to first boot") + return + _run_target_setup_command( ctx, - ["/usr/bin/omarchy-finalize-user", "--force", "--first-install"], + ["/usr/bin/omarchy-provision-user", "--force", "--first-install"], user=ctx.username, ) @@ -1285,29 +1415,32 @@ def configure_login(ctx: InstallContext) -> None: ) autologin_conf = sddm_dir / "autologin.conf" - if ctx.encrypt: + if ctx.encrypt and not ctx.defer_provisioning: autologin_conf.write_text( "[Autologin]\n" f"User={ctx.username}\n" "Session=omarchy.desktop\n" ) else: + # deferred-provisioning installs have no user yet; omarchy-provision-owner writes autologin + # and SDDM state at first boot once the owner exists. autologin_conf.unlink(missing_ok=True) - state_dir = ctx.target / "var" / "lib" / "sddm" - state_dir.mkdir(parents=True, exist_ok=True) - (state_dir / "state.conf").write_text( - f"[Last]\nSession=omarchy.desktop\nUser={ctx.username}\n" - ) + if not ctx.defer_provisioning: + state_dir = ctx.target / "var" / "lib" / "sddm" + state_dir.mkdir(parents=True, exist_ok=True) + (state_dir / "state.conf").write_text( + f"[Last]\nSession=omarchy.desktop\nUser={ctx.username}\n" + ) + subprocess.run( + ["arch-chroot", str(ctx.target), "chown", "sddm:sddm", + "/var/lib/sddm", "/var/lib/sddm/state.conf"], + check=False, capture_output=True, + ) autologin = ctx.target / "etc" / "systemd" / "system" / "getty@tty1.service.d" / "autologin.conf" autologin.unlink(missing_ok=True) - subprocess.run( - ["arch-chroot", str(ctx.target), "chown", "sddm:sddm", - "/var/lib/sddm", "/var/lib/sddm/state.conf"], - check=False, capture_output=True, - ) subprocess.run( ["arch-chroot", str(ctx.target), "systemctl", "enable", "sddm.service"], check=False, capture_output=True, @@ -1326,24 +1459,36 @@ def configure_ssh_access(ctx: InstallContext) -> None: return keys = _authorized_keys(ctx.authorized_keys_path) - info(f"› installing {len(keys)} SSH key(s) for {ctx.username}") - ssh_dir = ctx.target / "home" / ctx.username / ".ssh" - ssh_dir.mkdir(parents=True, exist_ok=True) - ssh_dir.chmod(0o700) + if ctx.defer_provisioning: + # No user to authorize yet. Stage the keys in provisioning state for + # omarchy-provision-owner to install once first boot creates the owner, and + # still open the door (sshd + ufw) below. + info(f"› staging {len(keys)} SSH key(s) for the first-boot user") + provisioning_dir = ctx.target / PROVISION_STATE_DIR + provisioning_dir.mkdir(parents=True, exist_ok=True) + staged = provisioning_dir / "authorized_keys" + staged.write_text("".join(f"{key}\n" for key in keys)) + staged.chmod(0o600) + else: + info(f"› installing {len(keys)} SSH key(s) for {ctx.username}") - authorized_keys = ssh_dir / "authorized_keys" - authorized_keys.write_text("".join(f"{key}\n" for key in keys)) - authorized_keys.chmod(0o600) + ssh_dir = ctx.target / "home" / ctx.username / ".ssh" + ssh_dir.mkdir(parents=True, exist_ok=True) + ssh_dir.chmod(0o700) - # Ask the target for the uid rather than assuming the first user is 1000. - # Uncaptured so a failure shows up in the install log, and checked because - # a root-owned authorized_keys is one sshd refuses to read. - subprocess.run( - ["arch-chroot", str(ctx.target), "chown", "-R", - f"{ctx.username}:{ctx.username}", f"/home/{ctx.username}/.ssh"], - check=True, - ) + authorized_keys = ssh_dir / "authorized_keys" + authorized_keys.write_text("".join(f"{key}\n" for key in keys)) + authorized_keys.chmod(0o600) + + # Ask the target for the uid rather than assuming the first user is 1000. + # Uncaptured so a failure shows up in the install log, and checked because + # a root-owned authorized_keys is one sshd refuses to read. + subprocess.run( + ["arch-chroot", str(ctx.target), "chown", "-R", + f"{ctx.username}:{ctx.username}", f"/home/{ctx.username}/.ssh"], + check=True, + ) info("› enabling sshd") subprocess.run( @@ -1550,6 +1695,34 @@ def validate_boot(ctx: InstallContext) -> None: if ctx.is_protected: _validate_pre_mounted_filesystems(ctx) + if ctx.defer_provisioning: + _validate_provisioning_state(ctx) + + +def _validate_provisioning_state(ctx: InstallContext) -> None: + """An deferred-provisioning install that boots without a working first-boot setup is a + user-less brick; insist the armed state is complete before reboot.""" + provisioning_dir = ctx.target / PROVISION_STATE_DIR + if not (provisioning_dir / "pending").exists(): + raise RuntimeError(f"deferred-provisioning install but {provisioning_dir / 'pending'} is missing") + + link = ctx.target / "etc/systemd/system/multi-user.target.wants/omarchy-provision-owner.service" + if not link.is_symlink(): + raise RuntimeError("deferred-provisioning install but omarchy-provision-owner.service is not enabled") + + if not list((provisioning_dir / "packages").glob("node-v*.tar.gz")): + raise RuntimeError(f"deferred-provisioning install but no Node tarball staged in {provisioning_dir / 'packages'}") + + if _provision_install_encrypted(ctx): + for required in (provisioning_dir / "luks-key", ctx.target / PROVISION_KEYFILE): + if not required.exists(): + raise RuntimeError(f"encrypted deferred-provisioning install but {required} is missing") + limine_conf_text = ( + ctx.target / _boot_intent(ctx)["esp_mount"].lstrip("/") / "limine.conf" + ).read_text() + if "cryptkey=rootfs:" not in limine_conf_text: + raise RuntimeError("encrypted deferred-provisioning install but limine.conf has no cryptkey= for auto-unlock") + def _assert_boot_hooks_restored(ctx: InstallContext) -> None: """Never hand over a system whose UKI rebuild hook is still masked. @@ -1607,6 +1780,90 @@ def _validate_pre_mounted_filesystems(ctx: InstallContext) -> None: raise RuntimeError(f"{crypttab} missing LUKS UUID {storage['luks_uuid']}") +# ───────────────────────────────────────────────────────────────────────────── +# create_factory_snapshot: read-only snapshot of @ kept at the btrfs top level +# as @factory — outside snapper's .snapshots, so cleanup timers and the Limine +# snapshot menu never touch it. Zero bytes at creation; grows only with drift. +# Taken at the end of every install, it is what makes omarchy-system-factory-reset a +# true factory reset. +# ───────────────────────────────────────────────────────────────────────────── + +def create_factory_snapshot(ctx: InstallContext) -> None: + fstype = _findmnt_value(ctx.target, "FSTYPE") + if fstype != "btrfs": + info(f"› target root is {fstype or 'unknown'}, not btrfs; skipping factory snapshot") + return + + options = (_findmnt_value(ctx.target, "OPTIONS") or "").split(",") + if not any(opt in ("subvol=/@", "subvol=@") for opt in options): + info("› target root is not the @ subvolume; skipping factory snapshot") + return + + device = (_findmnt_value(ctx.target, "SOURCE") or "").split("[")[0] + if not device: + raise RuntimeError(f"could not determine the btrfs device backing {ctx.target}") + + top = ctx.state_dir / "factory-top" + top.mkdir(parents=True, exist_ok=True) + subprocess.run(["mount", "-o", "subvolid=5", device, str(top)], check=True) + try: + root_subvol = top / "@" + if not root_subvol.is_dir(): + raise RuntimeError(f"no @ subvolume at the top level of {device}") + + factory = top / "@factory" + if factory.exists(): + subprocess.run( + ["btrfs", "subvolume", "delete", str(factory)], + check=True, capture_output=True, + ) + + info("› snapshotting @ as @factory (read-only)") + subprocess.run( + ["btrfs", "subvolume", "snapshot", str(root_subvol), str(factory)], + check=True, + ) + _scrub_factory_snapshot(factory) + subprocess.run( + ["btrfs", "property", "set", "-ts", str(factory), "ro", "true"], + check=True, + ) + finally: + subprocess.run(["umount", str(top)], check=False, capture_output=True) + + +# Provisioning credentials staged for THIS deployment's first boot must not +# survive into the factory image: a reset years later would otherwise hand the +# next owner the original deployment's SSH keys or rejoin its tailnet, and a +# stale LUKS key (dead after the first re-key) has no business lingering. +# The mkinitcpio/cmdline drop-ins go with the keyfile — a reset rebuild would +# otherwise fail on FILES pointing at a scrubbed path. +FACTORY_SCRUB_PATHS = ( + "var/lib/omarchy/provisioning/authorized_keys", + "var/lib/omarchy/provisioning/luks-key", + "etc/omarchy/provisioning.key", + "etc/limine-entry-tool.d/99-omarchy-provisioning-unlock.conf", + "etc/mkinitcpio.conf.d/99-omarchy-provisioning-key.conf", + "etc/tailscale/authkey", + "etc/systemd/system/omarchy-tailscale-join.service", + "etc/systemd/system/multi-user.target.wants/omarchy-tailscale-join.service", +) + + +def _scrub_factory_snapshot(factory: Path) -> None: + for rel in FACTORY_SCRUB_PATHS: + (factory / rel).unlink(missing_ok=True) + + +def _findmnt_value(path: Path, column: str) -> str | None: + res = subprocess.run( + ["findmnt", "-no", column, str(path)], + capture_output=True, text=True, + ) + value = res.stdout.strip() + return value if res.returncode == 0 and value else None + + CPU_SYSFS = Path("/sys/devices/system/cpu") diff --git a/plans/oem-install.md b/plans/oem-install.md index 726148d..42ddd4a 100644 --- a/plans/oem-install.md +++ b/plans/oem-install.md @@ -102,12 +102,12 @@ user's password. ## Runtime-side changes (omarchy repo) - `omarchy-oem-setup` + `omarchy-oem-setup.service` (the OOBE above). -- `omarchy-reset-computer` (below). +- `omarchy-system-factory-reset` (below). - Relax `omarchy-setup-system`: when the install user doesn't exist yet, the three `usermod -aG` scripts append to `/var/lib/omarchy/oem/groups` instead of failing the getent check. -## Reset: `omarchy-reset-computer` +## Reset: `omarchy-system-factory-reset` Behind a very explicit confirmation (typed, not y/n): @@ -154,7 +154,7 @@ on it existing. So: - VM: OEM install (encrypted + unencrypted) → reboot → OOBE → session; verify LUKS passphrase is the user's, throwaway key gone, groups applied, `omarchy-finalize-user` state marker present. -- VM: normal install → use system → `omarchy-reset-computer` → OOBE as new +- VM: normal install → use system → `omarchy-system-factory-reset` → OOBE as new user; verify no trace of prior user (home, NM connections, tailscale, machine-id, host keys, snapper snapshots). - Autoinstall: cidata drive with `oem` marker and no credentials → unattended diff --git a/test/cidata-load-test.sh b/test/cidata-load-test.sh index abb7970..4914dc6 100644 --- a/test/cidata-load-test.sh +++ b/test/cidata-load-test.sh @@ -124,6 +124,58 @@ run_load || fail "required pair plus authorized_keys loads" [[ ! -e $sandbox/root/tailscale_authkey ]] || fail "absent tailscale_authkey is not copied" pass "present optional files are copied, absent ones skipped" +# A defer-provisioning marker replaces user_credentials.json: deferred-provisioning installs +# creation to first boot, so imaging rigs ship no credentials at all. +new_sandbox +attach_drive cidata +echo '{"disk_config": {}}' >"$sandbox/media/user_configuration.json" +: >"$sandbox/media/defer-provisioning" +run_load || fail "config plus defer-provisioning marker loads" +[[ -f $sandbox/root/defer-provisioning ]] || fail "defer-provisioning marker is copied" +[[ ! -e $sandbox/root/user_credentials.json ]] || fail "defer-provisioning drive copies no credentials" +pass "defer-provisioning marker stands in for credentials" + +# The defer-provisioning marker and credentials can coexist (rig supplies its own LUKS +# passphrase in the credentials file); both are copied. +new_sandbox +attach_drive cidata +write_required_pair +: >"$sandbox/media/defer-provisioning" +run_load || fail "defer-provisioning marker plus credentials loads" +[[ -f $sandbox/root/defer-provisioning && -f $sandbox/root/user_credentials.json ]] || fail "both defer-provisioning marker and credentials are copied" +pass "defer-provisioning marker plus credentials copies both" + +# An defer-provisioning marker without the configuration is still not an autoinstall drive. +new_sandbox +attach_drive cidata +: >"$sandbox/media/defer-provisioning" +! run_load || fail "defer-provisioning marker alone is not an autoinstall drive" +[[ ! -e $sandbox/root/defer-provisioning ]] || fail "defer-provisioning marker alone copies nothing" +pass "defer-provisioning marker alone falls back to the wizard" + +# Stale deferred-provisioning inputs from a previous load in the same session are cleared before +# the current drive is copied: a normal drive must not inherit an old defer-provisioning +# marker or credentials. +new_sandbox +attach_drive cidata +write_required_pair +: >"$sandbox/root/defer-provisioning" # leftover from a prior deferred-provisioning load +echo 'old-keys' >"$sandbox/root/authorized_keys" +run_load || fail "normal drive after a stale defer-provisioning load loads" +[[ ! -e $sandbox/root/defer-provisioning ]] || fail "stale defer-provisioning marker is cleared" +[[ ! -e $sandbox/root/authorized_keys ]] || fail "stale optional inputs are cleared" +pass "stale deferred-provisioning inputs are cleared before loading a normal drive" + +# A drive that isn't an autoinstall drive at all still clears stale inputs so +# the wizard that follows doesn't inherit them. +new_sandbox +attach_drive cidata +echo '{"disk_config": {}}' >"$sandbox/media/user_configuration.json" # half a pair +: >"$sandbox/root/defer-provisioning" +! run_load || fail "half-pair drive still falls back" +[[ ! -e $sandbox/root/defer-provisioning ]] || fail "stale defer-provisioning marker cleared even on fallback" +pass "stale inputs are cleared even when falling back to the wizard" + # Half the required pair is not an autoinstall drive: unmount and fall back. new_sandbox attach_drive cidata diff --git a/test/test_configure_ssh_access.py b/test/test_configure_ssh_access.py index 812761e..50050d2 100644 --- a/test/test_configure_ssh_access.py +++ b/test/test_configure_ssh_access.py @@ -60,7 +60,7 @@ class ConfigureSshAccessTest(unittest.TestCase): if authorized_keys is not None: authorized_keys_path = self.target / "authorized_keys" authorized_keys_path.write_text(authorized_keys) - return types.SimpleNamespace(target=self.target, username="jeff", authorized_keys_path=authorized_keys_path) + return types.SimpleNamespace(target=self.target, username="jeff", authorized_keys_path=authorized_keys_path, defer_provisioning=False) def configure(self, **kwargs): phases_impl.configure_ssh_access(self.ctx(**kwargs)) diff --git a/test/test_provisioning_state.py b/test/test_provisioning_state.py new file mode 100644 index 0000000..d2df6e7 --- /dev/null +++ b/test/test_provisioning_state.py @@ -0,0 +1,440 @@ +"""Unit tests for deferred-provisioning install support in the orchestrator. + +Covers the InstallContext deferred-provisioning plumbing (marker file, credential-less input, +throwaway LUKS passphrase injection) and the stage_provisioning_state / +create_factory_snapshot / configure_login phases against a temp target with +subprocess mocked out. +""" + +import json +import os +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().parent.parent / "configs/airootfs/usr/share/omarchy-iso")) + +sys.modules.setdefault( + "orchestrator.archinstall_adapter", types.ModuleType("orchestrator.archinstall_adapter") +) + +from orchestrator import phases_impl # noqa: E402 +from orchestrator.context import InstallContext # noqa: E402 + + +class ContextDeferProvisioningTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.dir = Path(self.tmp.name) + self.state_dir = self.dir / "state" + + self.env = { + "OMARCHY_INSTALL_CONFIG": str(self.dir / "user_configuration.json"), + "OMARCHY_INSTALL_CREDS": str(self.dir / "user_credentials.json"), + "OMARCHY_INSTALL_STATE_DIR": str(self.state_dir), + } + + def write_config(self, config): + (self.dir / "user_configuration.json").write_text(json.dumps(config)) + + def write_creds(self, creds): + (self.dir / "user_credentials.json").write_text(json.dumps(creds)) + + def from_env(self, **extra_env): + env = {**self.env, **extra_env} + with mock.patch.dict(os.environ, env, clear=False): + for key in ("OMARCHY_INSTALL_DEFER_PROVISIONING_FILE",): + if key not in env: + os.environ.pop(key, None) + return InstallContext.from_env() + + def base_config(self, defer_provisioning=None, disk_encryption=None): + config = { + "disk_config": {"config_type": "default_layout"}, + "omarchy_install": {"mode": "full_disk", "target_mount": "/mnt"}, + } + if defer_provisioning is not None: + config["omarchy_install"]["defer_provisioning"] = defer_provisioning + if disk_encryption is not None: + config["disk_config"]["disk_encryption"] = disk_encryption + return config + + def test_defer_provisioning_flag_from_config(self): + self.write_config(self.base_config(defer_provisioning=True)) + ctx = self.from_env() + self.assertTrue(ctx.defer_provisioning) + self.assertEqual(ctx.user_credentials, {"users": []}) + self.assertEqual(ctx.username, "") + + def test_marker_file_arms_deferred_provisioning_without_credentials(self): + self.write_config(self.base_config()) + marker = self.dir / "defer_provisioning" + marker.touch() + ctx = self.from_env(OMARCHY_INSTALL_DEFER_PROVISIONING_FILE=str(marker)) + self.assertTrue(ctx.defer_provisioning) + self.assertTrue(ctx.omarchy_install["defer_provisioning"]) + + def test_absent_marker_file_is_not_deferred_provisioning(self): + self.write_config(self.base_config()) + self.write_creds({"users": [{"username": "jeff"}]}) + ctx = self.from_env(OMARCHY_INSTALL_DEFER_PROVISIONING_FILE=str(self.dir / "missing-defer_provisioning")) + self.assertFalse(ctx.defer_provisioning) + self.assertEqual(ctx.username, "jeff") + + def test_missing_credentials_without_defer_provisioning_raises(self): + self.write_config(self.base_config()) + with self.assertRaisesRegex(RuntimeError, "credentials file missing"): + self.from_env() + + def test_deferred_provisioning_generates_throwaway_luks_passphrase(self): + self.write_config(self.base_config(defer_provisioning=True, disk_encryption={ + "encryption_type": "luks", "partitions": ["x"], + })) + ctx = self.from_env() + + password = ctx.user_configuration["disk_config"]["disk_encryption"]["encryption_password"] + self.assertTrue(password) + self.assertEqual(ctx.user_credentials["encryption_password"], password) + + # archinstall reads both files from disk; the injected passphrase must + # be present in each. + arch_config = json.loads(ctx.arch_config_path.read_text()) + self.assertEqual( + arch_config["disk_config"]["disk_encryption"]["encryption_password"], password + ) + synthesized = json.loads(ctx.creds_path.read_text()) + self.assertEqual(synthesized["encryption_password"], password) + + def test_deferred_provisioning_reuses_rig_supplied_passphrase(self): + self.write_config(self.base_config(defer_provisioning=True, disk_encryption={ + "encryption_type": "luks", "partitions": ["x"], + })) + self.write_creds({"users": [], "encryption_password": "rig-secret"}) + ctx = self.from_env() + self.assertEqual( + ctx.user_configuration["disk_config"]["disk_encryption"]["encryption_password"], + "rig-secret", + ) + + def test_deferred_provisioning_discards_account_material(self): + # A rig credentials file must not smuggle in users or a root password. + self.write_config(self.base_config(defer_provisioning=True)) + self.write_creds({ + "users": [{"username": "backdoor", "enc_password": "x", "sudo": True}], + "root_enc_password": "x", + "encryption_password": "rig-secret", + }) + ctx = self.from_env() + self.assertEqual(ctx.user_credentials["users"], []) + self.assertNotIn("root_enc_password", ctx.user_credentials) + self.assertEqual(ctx.user_credentials["encryption_password"], "rig-secret") + synthesized = json.loads(ctx.creds_path.read_text()) + self.assertEqual(synthesized["users"], []) + self.assertNotIn("root_enc_password", synthesized) + + def test_defer_provisioning_strips_account_fields(self): + config = self.base_config(defer_provisioning=True) + config["!users"] = [{"username": "rig"}] + config["!root-password"] = "hunter2" + config["root_enc_password"] = "x" + config["auth_config"] = {"users": [{"username": "rig"}], "root_enc_password": "x"} + self.write_config(config) + ctx = self.from_env() + + arch_config = json.loads(ctx.arch_config_path.read_text()) + for key in ("!users", "!root-password", "root_enc_password", "users"): + self.assertNotIn(key, arch_config) + self.assertEqual(arch_config["auth_config"], {}) + + def test_deferred_provisioning_unencrypted_touches_nothing(self): + self.write_config(self.base_config(defer_provisioning=True)) + ctx = self.from_env() + self.assertNotIn("encryption_password", ctx.user_credentials) + + +def make_ctx(target, **overrides): + defaults = dict( + target=target, + defer_provisioning=True, + encrypt=False, + username="", + omarchy_install={"mode": "full_disk", "defer_provisioning": True, "storage": {}}, + user_configuration={"disk_config": {}}, + user_credentials={"users": []}, + state_dir=target / "state", + authorized_keys_path=None, + ) + defaults.update(overrides) + return types.SimpleNamespace(**defaults) + + +class StageProvisioningStateTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.target = Path(self.tmp.name) / "mnt" + self.target.mkdir() + + info_patch = mock.patch.object(phases_impl, "info") + info_patch.start() + self.addCleanup(info_patch.stop) + + # A fake bundled Node tarball on the "live ISO". + self.packages = Path(self.tmp.name) / "opt-packages" + self.packages.mkdir() + (self.packages / "node-v24.0.0-linux-x64.tar.gz").write_bytes(b"node") + node_patch = mock.patch.object(phases_impl, "NODE_PACKAGES_DIR", self.packages) + node_patch.start() + self.addCleanup(node_patch.stop) + + def install_runtime_provisioning_support(self): + service = self.target / "usr/share/omarchy/install/provisioning/omarchy-provision-owner.service" + service.parent.mkdir(parents=True, exist_ok=True) + service.write_text("[Unit]\n") + setup_bin = self.target / "usr/bin/omarchy-provision-owner" + setup_bin.parent.mkdir(parents=True, exist_ok=True) + setup_bin.write_text("#!/bin/bash\n") + + def provisioning_dir(self): + return self.target / "var/lib/omarchy/provisioning" + + def test_normal_install_stages_only_the_node_tarball(self): + ctx = make_ctx(self.target, defer_provisioning=False) + phases_impl.stage_provisioning_state(ctx) + + self.assertTrue((self.provisioning_dir() / "packages/node-v24.0.0-linux-x64.tar.gz").exists()) + self.assertFalse((self.provisioning_dir() / "pending").exists()) + self.assertFalse((self.target / "etc/systemd/system/omarchy-provision-owner.service").exists()) + + def test_deferred_provisioning_against_runtime_without_support_fails_clearly(self): + ctx = make_ctx(self.target) + with self.assertRaisesRegex(RuntimeError, "does not ship"): + phases_impl.stage_provisioning_state(ctx) + + def test_deferred_provisioning_without_node_tarball_fails(self): + for tarball in self.packages.glob("*.tar.gz"): + tarball.unlink() + self.install_runtime_provisioning_support() + ctx = make_ctx(self.target) + with self.assertRaisesRegex(RuntimeError, "Node tarball"): + phases_impl.stage_provisioning_state(ctx) + + def test_missing_node_tarball_fails_normal_installs_too(self): + # The stash is what makes a later factory reset work offline. + for tarball in self.packages.glob("*.tar.gz"): + tarball.unlink() + ctx = make_ctx(self.target, defer_provisioning=False) + with self.assertRaisesRegex(RuntimeError, "Node tarball"): + phases_impl.stage_provisioning_state(ctx) + + def test_deferred_provisioning_arms_first_boot_setup(self): + self.install_runtime_provisioning_support() + ctx = make_ctx(self.target) + phases_impl.stage_provisioning_state(ctx) + + self.assertTrue((self.provisioning_dir() / "pending").exists()) + self.assertTrue((self.target / "etc/systemd/system/omarchy-provision-owner.service").exists()) + link = self.target / "etc/systemd/system/multi-user.target.wants/omarchy-provision-owner.service" + self.assertTrue(link.is_symlink()) + self.assertEqual( + os.readlink(link), "/etc/systemd/system/omarchy-provision-owner.service" + ) + # Unencrypted: no LUKS staging. + self.assertFalse((self.provisioning_dir() / "luks-key").exists()) + + def test_deferred_provisioning_encrypted_stages_luks_auto_unlock(self): + self.install_runtime_provisioning_support() + ctx = make_ctx( + self.target, + user_configuration={"disk_config": {"disk_encryption": { + "encryption_type": "luks", + "encryption_password": "throwaway-secret", + }}}, + ) + phases_impl.stage_provisioning_state(ctx) + + # Byte-for-byte the slot passphrase — no trailing newline. + self.assertEqual((self.provisioning_dir() / "luks-key").read_text(), "throwaway-secret") + keyfile = self.target / "etc/omarchy/provisioning.key" + self.assertEqual(keyfile.read_text(), "throwaway-secret") + self.assertEqual(keyfile.stat().st_mode & 0o777, 0o600) + + cmdline = (self.target / "etc/limine-entry-tool.d/99-omarchy-provisioning-unlock.conf").read_text() + self.assertIn("cryptkey=rootfs:/etc/omarchy/provisioning.key", cmdline) + files = (self.target / "etc/mkinitcpio.conf.d/99-omarchy-provisioning-key.conf").read_text() + self.assertIn("FILES+=(/etc/omarchy/provisioning.key)", files) + + def test_deferred_provisioning_pre_encrypted_without_passphrase_fails(self): + self.install_runtime_provisioning_support() + ctx = make_ctx( + self.target, + omarchy_install={"mode": "protected", "defer_provisioning": True, "storage": {"luks_uuid": "abc"}}, + ) + with self.assertRaisesRegex(RuntimeError, "passphrase"): + phases_impl.stage_provisioning_state(ctx) + + +class ConfigureLoginDeferProvisioningTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.target = Path(self.tmp.name) + self.calls = [] + + run_patch = mock.patch.object( + phases_impl.subprocess, "run", + side_effect=lambda cmd, **kw: (self.calls.append(cmd), CompletedProcess(cmd, 0))[1], + ) + run_patch.start() + self.addCleanup(run_patch.stop) + + def test_deferred_provisioning_login_leaves_user_state_to_first_boot(self): + ctx = make_ctx(self.target, encrypt=True) + phases_impl.configure_login(ctx) + + self.assertTrue((self.target / "etc/sddm.conf.d/99-omarchy-login.conf").exists()) + self.assertFalse((self.target / "etc/sddm.conf.d/autologin.conf").exists()) + self.assertFalse((self.target / "var/lib/sddm/state.conf").exists()) + self.assertTrue(any("sddm.service" in cmd for cmd in self.calls)) + + def test_normal_encrypted_login_still_autologs_in(self): + ctx = make_ctx(self.target, defer_provisioning=False, encrypt=True, username="jeff") + phases_impl.configure_login(ctx) + + autologin = (self.target / "etc/sddm.conf.d/autologin.conf").read_text() + self.assertIn("User=jeff", autologin) + state = (self.target / "var/lib/sddm/state.conf").read_text() + self.assertIn("User=jeff", state) + + +class ConfigureSshAccessDeferProvisioningTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.target = Path(self.tmp.name) / "mnt" + self.target.mkdir() + + info_patch = mock.patch.object(phases_impl, "info") + info_patch.start() + self.addCleanup(info_patch.stop) + + def fake_run(cmd, **kwargs): + self.calls.append(cmd) + if "ufw" in cmd: + rules = self.target / "etc/ufw/user.rules" + rules.parent.mkdir(parents=True, exist_ok=True) + rules.write_text("-A ufw-user-input -p tcp --dport 22 -j ACCEPT\n") + return CompletedProcess(cmd, 0) + + self.calls = [] + run_patch = mock.patch.object(phases_impl.subprocess, "run", side_effect=fake_run) + run_patch.start() + self.addCleanup(run_patch.stop) + + def test_deferred_provisioning_stages_keys_for_first_boot(self): + keys = Path(self.tmp.name) / "authorized_keys" + keys.write_text("ssh-ed25519 AAAA rig@host\n") + ctx = make_ctx(self.target, authorized_keys_path=keys) + + phases_impl.configure_ssh_access(ctx) + + staged = self.target / "var/lib/omarchy/provisioning/authorized_keys" + self.assertEqual(staged.read_text(), "ssh-ed25519 AAAA rig@host\n") + self.assertEqual(staged.stat().st_mode & 0o777, 0o600) + # No user yet — nothing under /home, no chown. + self.assertFalse((self.target / "home").exists()) + self.assertFalse(any("chown" in cmd for cmd in self.calls)) + # The door is still opened for first boot. + self.assertTrue(any("sshd.service" in cmd for cmd in self.calls)) + + +class CreateFactorySnapshotTest(unittest.TestCase): + 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() + + info_patch = mock.patch.object(phases_impl, "info") + info_patch.start() + self.addCleanup(info_patch.stop) + + self.findmnt = { + "FSTYPE": "btrfs", + "OPTIONS": "rw,noatime,compress=zstd:3,subvol=/@", + "SOURCE": "/dev/mapper/omarchy_root[/@]", + } + self.calls = [] + + def fake_run(cmd, **kwargs): + self.calls.append(cmd) + if cmd[0] == "findmnt": + return CompletedProcess(cmd, 0, stdout=self.findmnt[cmd[2]] + "\n", stderr="") + if cmd[0] == "mount": + # Simulate the top level containing @. + (Path(cmd[-1]) / "@").mkdir(parents=True, exist_ok=True) + return CompletedProcess(cmd, 0, stdout="", stderr="") + + run_patch = mock.patch.object(phases_impl.subprocess, "run", side_effect=fake_run) + run_patch.start() + self.addCleanup(run_patch.stop) + + def ctx(self): + return make_ctx(self.target, defer_provisioning=False, state_dir=self.state_dir) + + def test_snapshots_root_as_factory(self): + phases_impl.create_factory_snapshot(self.ctx()) + + top = str(self.state_dir / "factory-top") + self.assertIn(["mount", "-o", "subvolid=5", "/dev/mapper/omarchy_root", top], self.calls) + self.assertIn( + ["btrfs", "subvolume", "snapshot", f"{top}/@", f"{top}/@factory"], + self.calls, + ) + self.assertIn( + ["btrfs", "property", "set", "-ts", f"{top}/@factory", "ro", "true"], + self.calls, + ) + self.assertIn(["umount", top], self.calls) + + def test_factory_snapshot_scrubs_provisioning_credentials(self): + # Simulate the snapshot carrying deployment credentials; the scrub + # must drop them before the snapshot goes read-only. + top = self.state_dir / "factory-top" + factory = top / "@factory" + secrets = [ + factory / "var/lib/omarchy/provisioning/authorized_keys", + factory / "etc/tailscale/authkey", + factory / "etc/omarchy/provisioning.key", + ] + keep = factory / "var/lib/omarchy/provisioning/groups" + for path in [*secrets, keep]: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("secret") + + phases_impl.create_factory_snapshot(self.ctx()) + + for path in secrets: + self.assertFalse(path.exists(), path) + self.assertTrue(keep.exists()) + + def test_non_btrfs_target_skips(self): + self.findmnt["FSTYPE"] = "ext4" + phases_impl.create_factory_snapshot(self.ctx()) + self.assertFalse(any(cmd[0] == "mount" for cmd in self.calls)) + + def test_non_subvol_root_skips(self): + self.findmnt["OPTIONS"] = "rw,noatime" + phases_impl.create_factory_snapshot(self.ctx()) + self.assertFalse(any(cmd[0] == "mount" for cmd in self.calls)) + + +if __name__ == "__main__": + unittest.main()