Compare commits

..
Author SHA1 Message Date
David Heinemeier HanssonandClaude Fable 5 2a46fef776 Do not hold boot hostage for the tailnet join
Review caught that the join unit was Type=oneshot wanted by
multi-user.target: target units implicitly gain After= for their Wants=,
so an offline machine's boot -- SDDM included -- stalled for the full
10-minute timeout, and once timed out the join never retried within that
boot. Type=simple counts as started the moment it forks, so boot proceeds
while the join retries in the background for as long as the boot lasts.
Cleanup moves inside the script, sequenced after tailscale up succeeds,
which preserves the retry-across-boots behavior: key and unit survive a
boot with no connectivity, and the key never outlives a successful join.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xc74h4WSYpuYPGuYbFAR1q
2026-08-07 15:09:11 +02:00
David Heinemeier HanssonandClaude Fable 5 d33487a09e Join the tailnet from a cidata tailscale_authkey
A tailscale_authkey file on the autoinstall drive gets the installed
machine onto the tailnet on its own: the tailscale package is bundled in
the offline mirror and installed only when the key is present, the key is
staged root-only at /etc/tailscale/authkey, and a oneshot first-boot unit
runs tailscale up once the network is really there. On success the key is
deleted and the unit disables itself; on failure both survive to retry on
the next boot, so an offline install joins whenever it first gets
connectivity. ufw allows traffic in on tailscale0 via the same
write-the-rule-then-ignore-the-chroot-exit-code dance as the SSH phase.

Bundling rather than fetching at first boot is deliberate: pacman -Sy
tailscale on a fresh machine is a partial upgrade, and pacman -Syu means
an unattended full system upgrade racing the user's first login. The
offline mirror is a coherent snapshot; installing from it keeps the
target consistent and the install offline-capable. See plans/tailscale.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xc74h4WSYpuYPGuYbFAR1q
2026-08-07 12:54:39 +02:00
16 changed files with 408 additions and 1623 deletions

No files matched your search

+7 -207
View File
@@ -14,8 +14,6 @@
# Usage: omarchy-iso-test [release/omarchy.iso] [options] # Usage: omarchy-iso-test [release/omarchy.iso] [options]
# #
# --encrypt Drive the encrypted install flow (default: unencrypted) # --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 # --reuse-base Skip the install phase if a base image already exists
# --install-only Stop after producing the installed base image # --install-only Stop after producing the installed base image
# --sync-omarchy DIR Push DIR's test/acceptance into the guest before running # --sync-omarchy DIR Push DIR's test/acceptance into the guest before running
@@ -38,7 +36,6 @@ MEMORY=8192
INSTALL_TIMEOUT=2400 INSTALL_TIMEOUT=2400
BOOT_TIMEOUT=600 BOOT_TIMEOUT=600
ENCRYPT=false ENCRYPT=false
PROVISION=false
REUSE_BASE=false REUSE_BASE=false
INSTALL_ONLY=false INSTALL_ONLY=false
KEEP_RUNNING=false KEEP_RUNNING=false
@@ -53,7 +50,6 @@ GUEST_HOSTNAME="omarchy-test"
while (($#)); do while (($#)); do
case "$1" in case "$1" in
--encrypt) ENCRYPT=true ;; --encrypt) ENCRYPT=true ;;
--provision) PROVISION=true ;;
--reuse-base) REUSE_BASE=true ;; --reuse-base) REUSE_BASE=true ;;
--install-only) INSTALL_ONLY=true ;; --install-only) INSTALL_ONLY=true ;;
--keep-running) KEEP_RUNNING=true ;; --keep-running) KEEP_RUNNING=true ;;
@@ -90,7 +86,6 @@ OVMF_VARS_TEMPLATE="/usr/share/edk2/x64/OVMF_VARS.4m.fd"
BASE_NAME="$(basename "$ISO" .iso)" BASE_NAME="$(basename "$ISO" .iso)"
$ENCRYPT && BASE_NAME+="-encrypted" $ENCRYPT && BASE_NAME+="-encrypted"
$PROVISION && BASE_NAME+="-provision"
BASE_DIR="$REPO_DIR/test-runs/$BASE_NAME" BASE_DIR="$REPO_DIR/test-runs/$BASE_NAME"
RUN_DIR="$BASE_DIR/runs/$(date +%Y%m%d-%H%M%S)" RUN_DIR="$BASE_DIR/runs/$(date +%Y%m%d-%H%M%S)"
BASE_DISK="$BASE_DIR/base.qcow2" BASE_DISK="$BASE_DIR/base.qcow2"
@@ -101,9 +96,7 @@ HTTP_PID=""
mkdir -p "$BASE_DIR" "$RUN_DIR" mkdir -p "$BASE_DIR" "$RUN_DIR"
# Unix socket paths are capped at ~108 bytes, which a run directory nested in QMP_SOCK="$RUN_DIR/qmp.sock"
# test-runs/<iso-name>/runs/<timestamp> can exceed. Keep the socket in /tmp.
QMP_SOCK=$(mktemp -u "${TMPDIR:-/tmp}/omarchy-iso-test-qmp.XXXXXX.sock")
PIDFILE="$RUN_DIR/qemu.pid" PIDFILE="$RUN_DIR/qemu.pid"
log() { log() {
@@ -293,28 +286,6 @@ type_text() {
:) press shift-semicolon ;; :) press shift-semicolon ;;
@) press shift-2 ;; @) press shift-2 ;;
\|) press shift-backslash ;; \|) 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 ;; *) echo "type_text: unsupported character: $ch" >&2; return 1 ;;
esac esac
sleep 0.05 sleep 0.05
@@ -326,7 +297,6 @@ type_text() {
ssh_guest() { ssh_guest() {
ssh -i "$SSH_KEY" -p "$SSH_PORT" \ ssh -i "$SSH_KEY" -p "$SSH_PORT" \
-o BatchMode=yes \ -o BatchMode=yes \
-o IdentitiesOnly=yes \
-o StrictHostKeyChecking=no \ -o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \ -o UserKnownHostsFile=/dev/null \
-o ConnectTimeout=5 \ -o ConnectTimeout=5 \
@@ -606,43 +576,10 @@ audio-panel|meta_l-ctrl-a|omarchy-keyboard-panel|omarchy.audio'
drive_configurator() { drive_configurator() {
log "Driving the installer wizard" 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 wait_for_screen "keyboard layout" 300
capture_console "success-installer-01-keyboard-layout" 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 press ret # English (US) is preselected
# Normal flow: user step comes before disk selection.
wait_for_screen "Username" 120 wait_for_screen "Username" 120
type_text "$GUEST_USER" type_text "$GUEST_USER"
capture_console "success-installer-02-username" capture_console "success-installer-02-username"
@@ -685,93 +622,17 @@ drive_configurator() {
capture_console "success-installer-10-install-disk" capture_console "success-installer-10-install-disk"
press ret # the single virtio 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 wait_for_screen "recovery possible" 60
capture_console "success-installer-12-disk-warning-encrypted" capture_console "success-installer-11-disk-warning-encrypted"
if ! $ENCRYPT; then if ! $ENCRYPT; then
press ctrl-c # toggles the confirm to the unencrypted flow press ctrl-c # toggles the confirm to the unencrypted flow
wait_for_screen "without encryption" 30 wait_for_screen "without encryption" 30
capture_console "success-installer-13-disk-warning-unencrypted" capture_console "success-installer-12-disk-warning-unencrypted"
fi fi
press ret press ret
sleep 2 sleep 2
capture_console "success-installer-14-install-started" capture_console "success-installer-13-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 below the preselected English (US), which leads
# the list) 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 down
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() { wait_for_install() {
@@ -788,15 +649,6 @@ wait_for_install() {
return 0 return 0
fi 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 if grep -qi "installation stopped" <<<"$text"; then
capture_console "failure-installer-installation-stopped" capture_console "failure-installer-installation-stopped"
echo "Install failed — screenshot saved to $RUN_DIR" >&2 echo "Install failed — screenshot saved to $RUN_DIR" >&2
@@ -899,11 +751,11 @@ EOF
press ret press ret
sleep 3 sleep 3
type_text "curl -fsS http://10.0.2.2:$HTTP_PORT/bootstrap -o /tmp/bs && bash /tmp/bs" type_text "curl -fsS http://10.0.2.2:$HTTP_PORT/bootstrap | bash"
capture_console "success-first-boot-05-bootstrap-command" capture_console "success-first-boot-05-bootstrap-command"
press ret press ret
wait_for_ssh 360 "failure-first-boot-ssh-timeout" wait_for_ssh 180 "failure-first-boot-ssh-timeout"
capture_console "success-first-boot-06-bootstrap-complete" capture_console "success-first-boot-06-bootstrap-complete"
kill "$HTTP_PID" 2>/dev/null || true kill "$HTTP_PID" 2>/dev/null || true
@@ -983,63 +835,11 @@ install_phase() {
drive_configurator drive_configurator
wait_for_install wait_for_install
if $PROVISION; then if $ENCRYPT; then
drive_provision_owner
elif $ENCRYPT; then
unlock_luks unlock_luks
fi fi
bootstrap_ssh 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:-<empty>}', 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:-<empty>}' (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" \ 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 >"$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" \ ssh_guest "echo $GUEST_PASSWORD | sudo -S cat /var/log/pacman.log 2>/dev/null" \
-26
View File
@@ -139,7 +139,6 @@ sed -i -E '/^(linux|broadcom-wl)$/d' "$build_cache_dir/packages.x86_64"
# pulls the published omarchy* from the network mirror like any other package. # pulls the published omarchy* from the network mirror like any other package.
if [[ -d /omarchy-source ]]; then if [[ -d /omarchy-source ]]; then
base_pkg_lists=(/omarchy-source/install/omarchy-base.packages /omarchy-source/install/omarchy-other.packages) base_pkg_lists=(/omarchy-source/install/omarchy-base.packages /omarchy-source/install/omarchy-other.packages)
setup_form=/omarchy-source/install/provisioning/setup-form.sh
else else
# Pull the same package lists out of the freshly-downloaded Omarchy runtime # Pull the same package lists out of the freshly-downloaded Omarchy runtime
# package so we don't need a local checkout in the non-local-source path. # package so we don't need a local checkout in the non-local-source path.
@@ -155,37 +154,12 @@ else
mkdir -p /tmp/omarchy-pkglists mkdir -p /tmp/omarchy-pkglists
bsdtar -xf "$omarchy_pkg" -C /tmp/omarchy-pkglists usr/share/omarchy/install/omarchy-base.packages usr/share/omarchy/install/omarchy-other.packages bsdtar -xf "$omarchy_pkg" -C /tmp/omarchy-pkglists usr/share/omarchy/install/omarchy-base.packages usr/share/omarchy/install/omarchy-other.packages
base_pkg_lists=(/tmp/omarchy-pkglists/usr/share/omarchy/install/omarchy-base.packages /tmp/omarchy-pkglists/usr/share/omarchy/install/omarchy-other.packages) base_pkg_lists=(/tmp/omarchy-pkglists/usr/share/omarchy/install/omarchy-base.packages /tmp/omarchy-pkglists/usr/share/omarchy/install/omarchy-other.packages)
# Extracted on its own, tolerating a miss: bsdtar exits non-zero for a member
# it can't find, so asking for this alongside the package lists would abort the
# build here (set -e) with a bare "Not found in archive" instead of the
# actionable error below.
bsdtar -xf "$omarchy_pkg" -C /tmp/omarchy-pkglists usr/share/omarchy/install/provisioning/setup-form.sh 2>/dev/null || true
setup_form=/tmp/omarchy-pkglists/usr/share/omarchy/install/provisioning/setup-form.sh
fi fi
mkdir -p "$build_cache_dir/airootfs/usr/share/omarchy-iso" mkdir -p "$build_cache_dir/airootfs/usr/share/omarchy-iso"
cp "${base_pkg_lists[0]}" "$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-base.packages" cp "${base_pkg_lists[0]}" "$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-base.packages"
cp "${base_pkg_lists[1]}" "$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-other.packages" cp "${base_pkg_lists[1]}" "$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-other.packages"
# The configurator's setup form comes from the runtime this ISO bundles, so the
# installer and the first-boot setup that finishes a deferred install can never
# disagree. A runtime predating the split ships no such file, which would leave
# the configurator with no prompts at all.
if [[ ! -f $setup_form ]]; then
if [[ -d /omarchy-source ]]; then
echo "ERROR: the --local-source checkout ships no install/provisioning/setup-form.sh" >&2
remedy="Update the checkout to a revision carrying the shared setup form."
else
echo "ERROR: $OMARCHY_RUNTIME_PACKAGE does not ship install/provisioning/setup-form.sh" >&2
remedy="Publish a runtime carrying the shared setup form, or build with --local-source against a checkout that has it."
fi
echo " The configurator sources its prompts from that file, so this ISO" >&2
echo " would boot into an installer with no questions to ask." >&2
echo " $remedy" >&2
exit 1
fi
cp "$setup_form" "$build_cache_dir/airootfs/usr/share/omarchy-iso/setup-form.sh"
# Collect every package we want available in the offline mirror. # Collect every package we want available in the offline mirror.
declare -a all_packages declare -a all_packages
mapfile -t all_packages < <( mapfile -t all_packages < <(
+1 -12
View File
@@ -101,16 +101,6 @@ else
./configurator ./configurator
fi 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 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 # the actual installer as a non-interactive child, logs child output, waits for
# completion, then renders the final installed-time/reboot prompt itself. # completion, then renders the final installed-time/reboot prompt itself.
@@ -127,5 +117,4 @@ rm -f /run/omarchy-install/state.json
--email-file /root/user_email_address.txt \ --email-file /root/user_email_address.txt \
--encrypt-file /root/user_encrypt_installation.txt \ --encrypt-file /root/user_encrypt_installation.txt \
--authorized-keys-file /root/authorized_keys \ --authorized-keys-file /root/authorized_keys \
--tailscale-authkey-file /root/tailscale_authkey \ --tailscale-authkey-file /root/tailscale_authkey
--defer-provisioning-file /root/defer-provisioning
+154 -309
View File
@@ -9,21 +9,6 @@ OMARCHY_SETTINGS_PACKAGE="${OMARCHY_SETTINGS_PACKAGE:-omarchy-settings}"
LOGO_PATH="$OMARCHY_PATH/logo.txt" LOGO_PATH="$OMARCHY_PATH/logo.txt"
# The setup form — keyboard, account, hostname, and timezone questions, plus the
# rules their answers are checked against — shared verbatim with the first-boot
# owner setup that finishes a deferred install. build-iso.sh vendors it out of
# the runtime package this ISO bundles, so the two can never offer different
# layouts or accept different usernames. On the ISO that vendored copy is what
# loads; a dry run outside the ISO falls back to the omarchy checkout
# bin/omarchy-iso-configurator already points OMARCHY_PATH at.
SETUP_FORM="${SETUP_FORM:-/usr/share/omarchy-iso/setup-form.sh}"
[[ -r $SETUP_FORM ]] || SETUP_FORM="$OMARCHY_PATH/install/provisioning/setup-form.sh"
if [[ ! -r $SETUP_FORM ]]; then
echo "ERROR: shared setup form not found at $SETUP_FORM" >&2
exit 1
fi
source "$SETUP_FORM"
LOGO_WIDTH=$(awk '{ if (length > max) max = length } END { print max+0 }' "$LOGO_PATH") LOGO_WIDTH=$(awk '{ if (length > max) max = length } END { print max+0 }' "$LOGO_PATH")
export GUM_CONFIRM_PROMPT_FOREGROUND="6" export GUM_CONFIRM_PROMPT_FOREGROUND="6"
@@ -53,111 +38,12 @@ measure_terminal() {
} }
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 </dev/tty | awk '{print $2}')
[[ $width =~ ^[0-9]+$ ]] || width=0
if [[ $width == "$last" ]] && (( width >= LOGO_WIDTH )); then
(( ++stable >= 3 )) && break
else
stable=0
fi
last=$width
sleep 0.2
(( waited += 200 ))
done
}
clear_logo() { clear_logo() {
measure_terminal measure_terminal
printf "\033[H\033[2J" printf "\033[H\033[2J"
gum style --foreground 2 --padding "1 0 0 $PADDING_LEFT" "$(<"$LOGO_PATH")" 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/tty | awk '{print $1}')
[[ $rows =~ ^[0-9]+$ ]] || rows=${LINES:-24}
logo_h=$(wc -l <"$LOGO_PATH")
# logo + blank + tagline + blank + hint
content_h=$((logo_h + 4))
top=$(((rows - content_h) / 2))
(( top < 0 )) && top=0
logo_row=$((top + 1))
tagline_row=$((top + logo_h + 2))
hint_row=$((top + logo_h + 4))
printf '\033[?25l\033[H\033[2J'
printf '\033[%d;1H' "$logo_row"
gum style --foreground 2 --padding "0 0 0 $PADDING_LEFT" "$(<"$LOGO_PATH")"
tagline="Beautiful, Modern & Opinionated Linux by DHH"
tpad=$(((cols - ${#tagline}) / 2)); (( tpad < 0 )) && tpad=0
printf '\033[%d;%dH%s' "$tagline_row" "$((tpad + 1))" "$tagline"
hint="Press Return to Start Install"
hpad=$(((cols - ${#hint}) / 2)); (( hpad < 0 )) && hpad=0
printf '\033[%d;%dH\033[2m%s\033[0m' "$hint_row" "$((hpad + 1))" "$hint"
# ColorShift the logo: a green base (indexed color 2) with a cyan accent (6)
# drifting through, settling on green. Indexed ANSI colors, not hex — the
# framebuffer console can't render tte's truecolor faithfully (it crushes the
# palette to a muddy lavender), but the 16 indexed colors map to the Tokyo
# Night palette and render true. One long-running invocation (many cycles) so
# it never restarts — a restart is what flashed. The effect reads from
# /dev/null so it never swallows the Return; --reuse-canvas paints upward from
# the saved cursor, anchored one row below the logo.
if command -v tte >/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/null >/dev/tty 2>/dev/null &
anim=$!
fi
IFS= read -r _ </dev/tty || true
# Tolerate tte's kill signal in `wait` so a future `set -e` here can't abort.
[[ -n ${anim:-} ]] && { kill "$anim" 2>/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/tty 2>/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() { abort() {
gum style "${1:-Aborted installation}" gum style "${1:-Aborted installation}"
echo echo
@@ -187,37 +73,59 @@ notice() {
# STEP 1: KEYBOARD LAYOUT # STEP 1: KEYBOARD LAYOUT
keyboard_form() { keyboard_form() {
# The Ctrl+C hint sits directly under the header (no blank line between), step "Let's setup your machine..."
# matching the disk-overwrite screen's unencrypted offer. Ctrl+C on the first keyboards=$'Azerbaijani|azerty
# screen is the hidden entry into deferred provisioning; $1 is "true" only for that first Belarusian|by
# invocation, so the review-loop re-edit can't flip a user install into it. Belgian|be-latin1
local allow_defer_provisioning="${1:-false}" Bosnian|ba
local status Bulgarian|bg-cp1251
Croatian|croat
while true; do Czech|cz
clear_logo Danish|dk-latin1
echo Dutch|nl
say "Let's setup your machine..." English (UK)|uk
if $allow_defer_provisioning; then English (US)|us
say --foreground 8 "Press Ctrl+C to prepare this machine for another owner." English (US, Dvorak)|dvorak
fi English (US, Colemak)|colemak
echo Estonian|et
Finnish|fi
omarchy_prompt_keyboard && status=0 || status=$? French|fr
((status == 0)) && break French (Canada)|cf
French (Switzerland)|fr_CH
# Esc means "back", and nothing precedes the first screen, so re-ask. Georgian|ge
((status == OMARCHY_FORM_BACK)) && continue German|de
German (Switzerland)|de_CH-latin1
if $allow_defer_provisioning && ((status == OMARCHY_FORM_SIGNAL)); then Greek|gr
if confirm_prepare_for_another_owner; then Hebrew|il
defer_provisioning=true Hungarian|hu
return 0 Icelandic|is-latin1
fi Irish|ie
continue # declined: back to the keyboard picker Italian|it
fi Japanese|jp106
abort Kazakh|kazakh
done Khmer (Cambodia)|khmer
Kyrgyz|kyrgyz
Lao|la-latin1
Latvian|lv
Lithuanian|lt
Macedonian|mk-utf
Norwegian|no-latin1
Polish|pl
Portuguese|pt-latin1
Portuguese (Brazil)|br-abnt2
Romanian|ro
Russian|ru
Serbian|sr-latin
Slovak|sk-qwertz
Slovenian|slovene
Spanish|es
Spanish (Latin American)|la-latin1
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
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 # Only attempt to load keyboard layout if we're on a real console
# loadkeys only works on Linux virtual consoles (tty), not in terminal emulators # loadkeys only works on Linux virtual consoles (tty), not in terminal emulators
@@ -226,56 +134,74 @@ keyboard_form() {
fi fi
} }
# Ctrl+C on the first screen offers to prepare the machine for another owner. keyboard_form
# 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?"
}
# The user step. Deferred-provisioning installs skip it entirely — the machine's first owner # STEP 2: USER
# picks their keyboard and creates their user in the first-boot setup, so the
# operator sets nothing user-specific.
user_form() { user_form() {
step "Let's setup your user account..." step "Let's setup your user account..."
# Each prompt reports 0 (set), OMARCHY_FORM_BACK (Esc), or OMARCHY_FORM_SIGNAL while true; do
# (Ctrl+C). Both non-zero cases unwind to user_step, which decides what they username=$(gum input --placeholder "Alphanumeric without spaces (like dhh)" --prompt.foreground="#845DF9" --prompt "Username> ") || abort
# mean here; the form itself never aborts.
omarchy_prompt_username || return $?
omarchy_prompt_password || return $?
# Hash the password with SHA-512 crypt ($6$), which is what openssl passwd offers if [[ "$username" =~ ^[a-z_][a-z0-9_-]*[$]?$ ]]; then
password_hash=$(printf '%s' "$password" | openssl passwd -6 -stdin) if [[ "$username" =~ ^(root|bin|daemon|mail|ftp|http|nobody|dbus|systemd-coredump|systemd-network|systemd-oom|systemd-journal-remote|systemd-resolve|systemd-timesync|tss|uuidd|alpm|git|avahi|cups|lp|_talkd|polkitd|rtkit|qemu|brltty|gluster|rpc|libvirt-qemu|pcscd|nvidia-persistenced|sddm)$ ]] ; then
notice "Username is reserved for system" 1
omarchy_prompt_identity || return $? else
omarchy_prompt_hostname || return $? break
omarchy_prompt_timezone || return $? fi
} else
notice "Username must be alphanumeric with no spaces" 1
user_step() { fi
local status done
while true; do while true; do
user_form && status=0 || status=$? password=$(gum input --placeholder "Used for user + root, and disk encryption when enabled" --prompt.foreground="#845DF9" --password --prompt "Password> ") || abort
if ((status != 0)); then password_confirmation=$(gum input --placeholder "Must match the password you just typed" --prompt.foreground="#845DF9" --password --prompt "Confirm> ") || abort
# Esc unwinds to the start of the form. Ctrl+C has no side channel in the
# user step (only the keyboard and disk screens give it a meaning), so it
# keeps its long-standing behaviour of ending the install.
((status == OMARCHY_FORM_BACK)) || abort
keyboard_form
continue
fi
# Add manual padding since gum table -p doesn't respect padding if [[ -n "$password" && "$password" == "$password_confirmation" ]]; then
echo -e "Field,Value break
elif [[ -z "$password" ]]; then
notice "Your password can't be blank!" 1
else
notice "Passwords didn't match!" 1
fi
done
# Hash the password using yescrypt
password_hash=$(printf '%s' "$password" | openssl passwd -6 -stdin)
full_name=$(gum input --placeholder "Used for git authentication (hit return to skip)" --prompt.foreground="#845DF9" --prompt "Full name> ")
email_address=$(gum input --placeholder "Used for git authentication (hit return to skip)" --prompt.foreground="#845DF9" --prompt "Email address> ")
while true; do
hostname=$(gum input --placeholder "Letters, digits, and dashes (or return for 'omarchy')" --prompt.foreground="#845DF9" --prompt "Hostname> ")
if [[ "$hostname" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$ ]]; then
break
elif [[ -z $hostname ]]; then
hostname="omarchy"
break
else
notice "Hostname must be 1-63 letters, digits, or dashes, and cannot start or end with a dash" 1
fi
done
# Pick timezone
geo_guessed_timezone=$(tzupdate -p 2>/dev/null)
if [[ -n $geo_guessed_timezone ]]; then
timezone=$(timedatectl list-timezones | gum choose --height 10 --selected "$geo_guessed_timezone" --header "Timezone") || abort
else
timezone=$(timedatectl list-timezones | gum filter --height 10 --header "Timezone") || abort
fi
}
user_form
while true; do
# Add manual padding since gum table -p doesn't respect padding
echo -e "Field,Value
Username,$username Username,$username
Password,$(printf "%${#password}s" | tr ' ' '*') Password,$(printf "%${#password}s" | tr ' ' '*')
Full name,${full_name:-[Skipped]} Full name,${full_name:-[Skipped]}
@@ -283,16 +209,16 @@ Email address,${email_address:-[Skipped]}
Hostname,$hostname Hostname,$hostname
Timezone,$timezone Timezone,$timezone
Keyboard,$keyboard" | Keyboard,$keyboard" |
gum table -s "," -p | sed "s/^/${PADDING_LEFT_SPACES}/" gum table -s "," -p | sed "s/^/${PADDING_LEFT_SPACES}/"
echo echo
if gum confirm --negative "No, change it" "Does this look right?"; then if gum confirm --negative "No, change it" "Does this look right?"; then
break break
else else
keyboard_form keyboard_form
fi user_form
done fi
} done
# STEP 3: DISK # STEP 3: DISK
@@ -421,22 +347,11 @@ wait_for_device() {
# Save the configurator answers the installer consumes: git identity and # Save the configurator answers the installer consumes: git identity and
# encryption choice as plain files, account details as archinstall credentials. # 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() { write_user_files() {
echo "$full_name" >user_full_name.txt echo "$full_name" >user_full_name.txt
echo "$email_address" >user_email_address.txt echo "$email_address" >user_email_address.txt
printf '%s\n' "$encrypt_installation" >user_encrypt_installation.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 local password_escaped password_hash_escaped username_escaped
password_escaped=$(echo -n "$password" | jq -Rsa) password_escaped=$(echo -n "$password" | jq -Rsa)
password_hash_escaped=$(echo -n "$password_hash" | jq -Rsa) password_hash_escaped=$(echo -n "$password_hash" | jq -Rsa)
@@ -498,18 +413,17 @@ not_enough_space() {
return 1 return 1
} }
# Decide half of the free-space install: checks, free-space analysis, and the run_partition_install() {
# encryption confirm. Sets the globals run_partition_execute needs. Returns 1 local dry=false
# to fall back to the install-mode picker. [[ "${1:-}" == "dry" ]] && dry=true
run_partition_decide() {
step "Checking for BitLocker on $disk" step "Checking for BitLocker on $disk"
local bl_part local bl_part
bl_part=$(detect_bitlocker || true) bl_part=$(detect_bitlocker || true)
if [[ -n "$bl_part" ]]; then if [[ -n "$bl_part" ]]; then
say --foreground 1 "BitLocker signature detected on $bl_part." say --foreground 1 "BitLocker signature detected on $bl_part."
say "Turn BitLocker off in Windows and wait for the drive to finish decrypting, then try again." say "Suspend or disable BitLocker in Windows before installing alongside it."
say "Suspending BitLocker is not enough — the drive stays encrypted." abort "Aborted: BitLocker is active on this disk."
abort "Aborted: BitLocker is enabled on this disk."
fi fi
# Omarchy always creates its own dedicated ESP in free space and never # Omarchy always creates its own dedicated ESP in free space and never
@@ -534,8 +448,7 @@ run_partition_decide() {
# An unlabeled disk has no partition table for parted to scan, so we # An unlabeled disk has no partition table for parted to scan, so we
# synthesize a whole-disk free region and mklabel gpt before mkpart. # synthesize a whole-disk free region and mklabel gpt before mkpart.
local FREE_SPACE_INFO pt_type disk_size_b local FREE_SPACE_INFO needs_mklabel=false pt_type disk_size_b
needs_mklabel=false
pt_type=$(lsblk -dno PTTYPE "$disk" 2>/dev/null) pt_type=$(lsblk -dno PTTYPE "$disk" 2>/dev/null)
if [[ -z "$pt_type" ]]; then if [[ -z "$pt_type" ]]; then
disk_size_b=$(lsblk -bdno SIZE "$disk" 2>/dev/null) disk_size_b=$(lsblk -bdno SIZE "$disk" 2>/dev/null)
@@ -578,6 +491,7 @@ run_partition_decide() {
local EFI_SIZE_B=$((2 * gib)) local EFI_SIZE_B=$((2 * gib))
local MIN_INSTALL_B=$((32 * gib)) # Minimum total Omarchy allocation 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_START_B=$(( (FREE_START_B + ALIGN - 1) / ALIGN * ALIGN ))
EFI_END_B=$((EFI_START_B + EFI_SIZE_B)) EFI_END_B=$((EFI_START_B + EFI_SIZE_B))
ROOT_START_B=$(( (EFI_END_B + 1 + ALIGN - 1) / ALIGN * ALIGN )) ROOT_START_B=$(( (EFI_END_B + 1 + ALIGN - 1) / ALIGN * ALIGN ))
@@ -640,6 +554,7 @@ run_partition_decide() {
esac esac
done done
local esp_mount_in_target
if [[ "$encrypt_installation" == "true" ]]; then if [[ "$encrypt_installation" == "true" ]]; then
esp_mount_in_target=/boot esp_mount_in_target=/boot
else else
@@ -648,11 +563,12 @@ run_partition_decide() {
kernel_choice=$(detect_kernel) kernel_choice=$(detect_kernel)
local last_part local last_part efi_part_num root_part_num
last_part=$(lsblk -n -o PARTN "$disk" 2>/dev/null | grep -E '^[0-9]+$' | sort -n | tail -1) 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 )) efi_part_num=$(( ${last_part:-0} + 1 ))
root_part_num=$(( efi_part_num + 1 )) root_part_num=$(( efi_part_num + 1 ))
local efi_dev root_partition_device root_mapper
efi_dev=$(partition_path "$disk" "$efi_part_num") efi_dev=$(partition_path "$disk" "$efi_part_num")
root_partition_device=$(partition_path "$disk" "$root_part_num") root_partition_device=$(partition_path "$disk" "$root_part_num")
if [[ "$encrypt_installation" == "true" ]]; then if [[ "$encrypt_installation" == "true" ]]; then
@@ -660,14 +576,6 @@ run_partition_decide() {
else else
root_mapper="$root_partition_device" root_mapper="$root_partition_device"
fi 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 if $dry; then
$needs_mklabel && say "[dry] would initialize GPT on $disk" $needs_mklabel && say "[dry] would initialize GPT on $disk"
@@ -726,6 +634,10 @@ run_partition_execute() {
mount "$efi_dev" /mnt"$esp_mount_in_target" mount "$efi_dev" /mnt"$esp_mount_in_target"
fi fi
clear
write_user_files
local luks_uuid_json="null" luks_uuid local luks_uuid_json="null" luks_uuid
if [[ "$encrypt_installation" == "true" ]]; then if [[ "$encrypt_installation" == "true" ]]; then
if $dry; then if $dry; then
@@ -804,6 +716,10 @@ run_partition_execute() {
"version": "3.0.9" "version": "3.0.9"
} }
_EOF_ _EOF_
if $dry; then
print_dry_run_files
fi
} }
disk_form() { disk_form() {
@@ -855,7 +771,8 @@ open_partition_tool() {
} }
# If the disk has no partition table or no unallocated free space, the only # If the disk has no partition table or no unallocated free space, the only
# viable paths write the whole disk, so the free-space mode is not offered. # viable path is a full-disk install. Skip the install-mode picker and go
# straight to confirmation.
requires_full_disk_install() { requires_full_disk_install() {
local pt_type free_space_b local pt_type free_space_b
pt_type=$(lsblk -dno PTTYPE "$disk" 2>/dev/null) pt_type=$(lsblk -dno PTTYPE "$disk" 2>/dev/null)
@@ -882,7 +799,7 @@ install_mode_form() {
# The free-space/protected path registers an EFI boot entry and is UEFI-only. # 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. # Full-disk installs still support BIOS through the orchestrator's BIOS branch.
if [[ -d /sys/firmware/efi ]] && ! $full_disk_only; then if [[ -d /sys/firmware/efi ]]; then
choices+=("Free space install (alongside existing data)") choices+=("Free space install (alongside existing data)")
fi fi
choices+=("Choose a different disk") choices+=("Choose a different disk")
@@ -929,43 +846,30 @@ confirm_disk_overwrite() {
done 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() { select_installation() {
local full_disk_only local full_disk_only
while true; do while true; do
if requires_full_disk_install; then if requires_full_disk_install; then
full_disk_only=true full_disk_only=true
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" install_mode="Full disk install"
else else
full_disk_only=false
install_mode_form install_mode_form
fi fi
case "$install_mode" in case "$install_mode" in
"Full disk install") "Full disk install")
if confirm_disk_overwrite; then if confirm_disk_overwrite; then
install_target="full_disk"
return 0 return 0
fi fi
# When the picker was skipped, declining the overwrite means the user if $full_disk_only; then
# wants a different disk. disk_form
$full_disk_only && disk_form fi
;; ;;
"Free space install"*) "Free space install (alongside existing data)")
if run_partition_decide; then if run_partition_install "$@"; then
install_target="free_space" exit 0
return 0
fi fi
;; ;;
"Choose a different disk") "Choose a different disk")
@@ -975,64 +879,13 @@ select_installation() {
done done
} }
# STEP 1: KEYBOARD (Ctrl+C here arms deferred provisioning and jumps to disk selection) disk_form
select_installation "$@"
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 clear
write_user_files 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 # Setup partition layout
disk_size=$(lsblk -bdno SIZE "$disk") disk_size=$(lsblk -bdno SIZE "$disk")
mib=$((1024 * 1024)) mib=$((1024 * 1024))
@@ -1049,21 +902,14 @@ main_partition_size=$((disk_size_in_mib - main_partition_start - gpt_backup_rese
kernel_choice=$(detect_kernel) kernel_choice=$(detect_kernel)
if [[ $encrypt_installation == true ]]; then 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_config=$(cat <<-_EOF_
, ,
"disk_encryption": { "disk_encryption": {
"encryption_type": "luks", "encryption_type": "luks",
"lvm_volumes": [], "lvm_volumes": [],
"iter_time": 2000, "iter_time": 2000,
"partitions": [ "8c2c2b92-1070-455d-b76a-56263bab24aa" ]$encryption_password_line "partitions": [ "8c2c2b92-1070-455d-b76a-56263bab24aa" ],
"encryption_password": $(echo -n "$password" | jq -Rsa)
} }
_EOF_ _EOF_
) )
@@ -1081,7 +927,6 @@ cat <<-_EOF_ >user_configuration.json
"custom_commands": [], "custom_commands": [],
"omarchy_install": { "omarchy_install": {
"mode": "full_disk", "mode": "full_disk",
"defer_provisioning": $defer_provisioning,
"target_mount": "/mnt", "target_mount": "/mnt",
"boot": { "boot": {
"esp_mount": "/boot", "esp_mount": "/boot",
@@ -11,9 +11,6 @@
# Anything less than the pair means this isn't an autoinstall drive, and the # Anything less than the pair means this isn't an autoinstall drive, and the
# caller falls back to the wizard. # 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 # The optional argument prefixes every path the script touches; tests use it to
# run against a sandbox instead of the live system. # run against a sandbox instead of the live system.
@@ -38,20 +35,10 @@ done
mkdir -p "$mnt" mkdir -p "$mnt"
mount -o ro "$device" "$mnt" || exit 1 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 loaded=1
if [[ -f $mnt/user_configuration.json ]] && [[ -f $mnt/user_credentials.json || -f $mnt/defer-provisioning ]]; then if [[ -f $mnt/user_configuration.json && -f $mnt/user_credentials.json ]]; then
files=("$mnt"/user_configuration.json) files=("$mnt"/user_configuration.json "$mnt"/user_credentials.json)
for file in "${optional_inputs[@]}"; do for file in user_full_name.txt user_email_address.txt user_encrypt_installation.txt authorized_keys tailscale_authkey; do
[[ -f $mnt/$file ]] && files+=("$mnt/$file") [[ -f $mnt/$file ]] && files+=("$mnt/$file")
done done
if cp "${files[@]}" "$dest"/; then if cp "${files[@]}" "$dest"/; then
@@ -321,13 +321,11 @@ install_progress() {
"Installing Arch + Omarchy") lo=65 hi=700 tau=150 pkg_signal=1 ;; "Installing Arch + Omarchy") lo=65 hi=700 tau=150 pkg_signal=1 ;;
"Configuring hibernation") lo=700 hi=720 tau=10 ;; "Configuring hibernation") lo=700 hi=720 tau=10 ;;
"Configuring system") lo=720 hi=850 tau=120 ;; "Configuring system") lo=720 hi=850 tau=120 ;;
"Staging provisioning") lo=850 hi=855 tau=5 ;; "Finalizing Limine boot") lo=850 hi=950 tau=90 ;;
"Finalizing Limine boot") lo=855 hi=950 tau=90 ;;
"Finalizing user") lo=950 hi=978 tau=30 ;; "Finalizing user") lo=950 hi=978 tau=30 ;;
"Configuring login") lo=978 hi=988 tau=5 ;; "Configuring login") lo=978 hi=988 tau=5 ;;
"Configuring DNS resolver") lo=988 hi=994 tau=5 ;; "Configuring DNS resolver") lo=988 hi=994 tau=5 ;;
"Validating boot setup") lo=994 hi=998 tau=5 ;; "Validating boot setup") lo=994 hi=1000 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 # 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 # the published index instead; with no index, a bounded slice, so a
@@ -736,17 +734,6 @@ if (( status != 0 )); then
exit "$failure_status" exit "$failure_status"
fi 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 set +e
render_finish render_finish
finish_render_status=$? finish_render_status=$?
@@ -18,7 +18,6 @@ while [[ $# -gt 0 ]]; do
--encrypt-file) export OMARCHY_INSTALL_ENCRYPT_FILE="$2"; shift 2 ;; --encrypt-file) export OMARCHY_INSTALL_ENCRYPT_FILE="$2"; shift 2 ;;
--authorized-keys-file) export OMARCHY_INSTALL_AUTHORIZED_KEYS_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 ;; --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 ;; *) echo "omarchy-iso-install: unknown arg $1" >&2; exit 2 ;;
esac esac
done done
@@ -6,7 +6,6 @@ from __future__ import annotations
import json import json
import os import os
import secrets
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -26,7 +25,6 @@ class InstallContext:
user_credentials: dict user_credentials: dict
arch_config_path: Path arch_config_path: Path
omarchy_install: dict[str, Any] omarchy_install: dict[str, Any]
defer_provisioning: bool = False
target: Path = Path("/mnt") target: Path = Path("/mnt")
omarchy_path: Path = Path("/usr/share/omarchy") omarchy_path: Path = Path("/usr/share/omarchy")
@@ -50,54 +48,10 @@ class InstallContext:
user_configuration = json.loads(config_path.read_text()) user_configuration = json.loads(config_path.read_text())
omarchy_install = user_configuration.get("omarchy_install") or _default_omarchy_install(user_configuration) 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 = dict(user_configuration)
arch_configuration.pop("omarchy_install", None) 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 = Path(os.environ.get("OMARCHY_INSTALL_STATE_DIR", "/run/omarchy-install"))
state_dir.mkdir(parents=True, exist_ok=True) 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 = state_dir / "archinstall-user_configuration.json"
arch_config_path.write_text(json.dumps(arch_configuration, indent=2) + "\n") arch_config_path.write_text(json.dumps(arch_configuration, indent=2) + "\n")
@@ -110,10 +64,9 @@ class InstallContext:
authorized_keys_path=_optional_path(os.environ.get("OMARCHY_INSTALL_AUTHORIZED_KEYS_FILE")), 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")), tailscale_authkey_path=_optional_path(os.environ.get("OMARCHY_INSTALL_TAILSCALE_AUTHKEY_FILE")),
user_configuration=user_configuration, user_configuration=user_configuration,
user_credentials=user_credentials, user_credentials=json.loads(creds_path.read_text()),
arch_config_path=arch_config_path, arch_config_path=arch_config_path,
omarchy_install=omarchy_install, omarchy_install=omarchy_install,
defer_provisioning=defer_provisioning,
state_dir=state_dir, state_dir=state_dir,
) )
disk_config = user_configuration.get("disk_config", {}) disk_config = user_configuration.get("disk_config", {})
@@ -124,12 +77,7 @@ class InstallContext:
@property @property
def username(self) -> str: def username(self) -> str:
users = self.user_credentials.get("users") or [] return self.user_credentials["users"][0]["username"]
if users:
return users[0]["username"]
if self.defer_provisioning:
return ""
raise RuntimeError("user_credentials.json contains no users")
@property @property
def mode(self) -> str: def mode(self) -> str:
@@ -143,38 +91,6 @@ class InstallContext:
return self.mode == "protected" 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]: def _default_omarchy_install(user_configuration: dict) -> dict[str, Any]:
disk_config = user_configuration.get("disk_config", {}) disk_config = user_configuration.get("disk_config", {})
mode = "protected" if disk_config.get("config_type") == "pre_mounted_config" else "full_disk" mode = "protected" if disk_config.get("config_type") == "pre_mounted_config" else "full_disk"
@@ -18,9 +18,8 @@ from pathlib import Path
def configure_keyboard(target: Path, language: str) -> bool: def configure_keyboard(target: Path, language: str) -> bool:
"""Write the console keymap into a mounted target without booting it. """Write the console keymap into a mounted target without booting it.
Returns False for layouts localectl doesn't know, matching archinstall: Returns False for layouts localectl doesn't know (the configurator offers
warn and keep the default. Every layout the configurator offers is known; two, ba and khmer), matching archinstall: warn and keep the default.
the guard is for the kb_layout an autoinstall drive can name freely.
""" """
if not language.strip(): if not language.strip():
return True return True
@@ -34,7 +34,6 @@ def build_phases(ctx: InstallContext):
arch_install_system, arch_install_system,
configure_hibernation, configure_hibernation,
run_system_finalizer, run_system_finalizer,
stage_provisioning_state,
finalize_limine_boot, finalize_limine_boot,
run_chroot_finalizer, run_chroot_finalizer,
configure_dns_resolver, configure_dns_resolver,
@@ -42,7 +41,6 @@ def build_phases(ctx: InstallContext):
configure_ssh_access, configure_ssh_access,
configure_tailscale, configure_tailscale,
validate_boot, validate_boot,
create_factory_snapshot,
) )
return [ return [
@@ -51,9 +49,6 @@ def build_phases(ctx: InstallContext):
("Installing Arch + Omarchy", arch_install_system), ("Installing Arch + Omarchy", arch_install_system),
("Configuring hibernation", configure_hibernation), ("Configuring hibernation", configure_hibernation),
("Configuring system", run_system_finalizer), ("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 Limine boot", finalize_limine_boot),
("Finalizing user", run_chroot_finalizer), ("Finalizing user", run_chroot_finalizer),
("Configuring login", configure_login), ("Configuring login", configure_login),
@@ -61,7 +56,6 @@ def build_phases(ctx: InstallContext):
("Configuring Tailscale", configure_tailscale), ("Configuring Tailscale", configure_tailscale),
("Configuring DNS resolver", configure_dns_resolver), ("Configuring DNS resolver", configure_dns_resolver),
("Validating boot setup", validate_boot), ("Validating boot setup", validate_boot),
("Creating factory snapshot", create_factory_snapshot),
] ]
@@ -72,8 +66,7 @@ def main() -> int:
error(f"Configuration error: {e}") error(f"Configuration error: {e}")
return 2 return 2
who = ctx.username or "deferred provisioning (user created at first boot)" info(f"Installing Omarchy for {ctx.username} → {ctx.target}")
info(f"Installing Omarchy for {who} → {ctx.target}")
from .phases_impl import ( from .phases_impl import (
boost_cpu_governor, boost_cpu_governor,
@@ -13,7 +13,7 @@ Phase ordering (full-disk and protected/pre-mounted):
configure_hibernation → root-owned swap/resume drop-ins configure_hibernation → root-owned swap/resume drop-ins
run_system_finalizer → arch-chroot root omarchy-setup-system, including Snapper run_system_finalizer → arch-chroot root omarchy-setup-system, including Snapper
finalize_limine_boot → final Limine config/UKI build after hardware drop-ins finalize_limine_boot → final Limine config/UKI build after hardware drop-ins
run_chroot_finalizer → arch-chroot -u user omarchy-provision-user run_chroot_finalizer → arch-chroot -u user omarchy-finalize-user
configure_login → sddm state + encrypted-install autologin configure_login → sddm state + encrypted-install autologin
configure_ssh_access → authorized_keys for autoinstall; no-op otherwise configure_ssh_access → authorized_keys for autoinstall; no-op otherwise
configure_tailscale → tailnet join staged for first boot; 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 # 2. bind-mount the offline mirror + /opt/packages into /mnt for target pacman
# and bundled language runtimes # and bundled language runtimes
# 3. arch-chroot as root → omarchy-setup-system --first-install # 3. arch-chroot as root → omarchy-setup-system --first-install
# 4. arch-chroot as user → omarchy-provision-user --first-install # 4. arch-chroot as user → omarchy-finalize-user --first-install
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
def _prepare_target_setup(ctx: InstallContext) -> None: def _prepare_target_setup(ctx: InstallContext) -> None:
@@ -1136,142 +1136,16 @@ def _run_target_setup_command(ctx: InstallContext, cmd: list[str], *, user: str
def run_system_finalizer(ctx: InstallContext) -> None: 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) _mask_mkinitcpio_pacman_hooks(ctx, ctx.target, TARGET_DEFERRED_BOOT_HOOKS)
try: try:
_run_target_setup_command(ctx, cmd) _run_target_setup_command(
ctx,
["/usr/bin/omarchy-setup-system", "--install-user", ctx.username, "--first-install"],
)
finally: finally:
_unmask_mkinitcpio_pacman_hooks(ctx, ctx.target, TARGET_DEFERRED_BOOT_HOOKS) _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: def finalize_limine_boot(ctx: InstallContext) -> None:
"""Finalize Limine after target system setup has written all dynamic """Finalize Limine after target system setup has written all dynamic
boot drop-ins (hibernation, hardware quirks, protected-mode ESP settings). boot drop-ins (hibernation, hardware quirks, protected-mode ESP settings).
@@ -1365,13 +1239,9 @@ def _limine_kernel_cmdline(config_text: str) -> str:
def run_chroot_finalizer(ctx: InstallContext) -> None: 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( _run_target_setup_command(
ctx, ctx,
["/usr/bin/omarchy-provision-user", "--force", "--first-install"], ["/usr/bin/omarchy-finalize-user", "--force", "--first-install"],
user=ctx.username, user=ctx.username,
) )
@@ -1415,32 +1285,29 @@ def configure_login(ctx: InstallContext) -> None:
) )
autologin_conf = sddm_dir / "autologin.conf" autologin_conf = sddm_dir / "autologin.conf"
if ctx.encrypt and not ctx.defer_provisioning: if ctx.encrypt:
autologin_conf.write_text( autologin_conf.write_text(
"[Autologin]\n" "[Autologin]\n"
f"User={ctx.username}\n" f"User={ctx.username}\n"
"Session=omarchy.desktop\n" "Session=omarchy.desktop\n"
) )
else: 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) autologin_conf.unlink(missing_ok=True)
if not ctx.defer_provisioning: state_dir = ctx.target / "var" / "lib" / "sddm"
state_dir = ctx.target / "var" / "lib" / "sddm" state_dir.mkdir(parents=True, exist_ok=True)
state_dir.mkdir(parents=True, exist_ok=True) (state_dir / "state.conf").write_text(
(state_dir / "state.conf").write_text( f"[Last]\nSession=omarchy.desktop\nUser={ctx.username}\n"
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 = ctx.target / "etc" / "systemd" / "system" / "getty@tty1.service.d" / "autologin.conf"
autologin.unlink(missing_ok=True) 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( subprocess.run(
["arch-chroot", str(ctx.target), "systemctl", "enable", "sddm.service"], ["arch-chroot", str(ctx.target), "systemctl", "enable", "sddm.service"],
check=False, capture_output=True, check=False, capture_output=True,
@@ -1459,36 +1326,24 @@ def configure_ssh_access(ctx: InstallContext) -> None:
return return
keys = _authorized_keys(ctx.authorized_keys_path) keys = _authorized_keys(ctx.authorized_keys_path)
info(f"› installing {len(keys)} SSH key(s) for {ctx.username}")
if ctx.defer_provisioning: ssh_dir = ctx.target / "home" / ctx.username / ".ssh"
# No user to authorize yet. Stage the keys in provisioning state for ssh_dir.mkdir(parents=True, exist_ok=True)
# omarchy-provision-owner to install once first boot creates the owner, and ssh_dir.chmod(0o700)
# 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}")
ssh_dir = ctx.target / "home" / ctx.username / ".ssh" authorized_keys = ssh_dir / "authorized_keys"
ssh_dir.mkdir(parents=True, exist_ok=True) authorized_keys.write_text("".join(f"{key}\n" for key in keys))
ssh_dir.chmod(0o700) authorized_keys.chmod(0o600)
authorized_keys = ssh_dir / "authorized_keys" # Ask the target for the uid rather than assuming the first user is 1000.
authorized_keys.write_text("".join(f"{key}\n" for key in keys)) # Uncaptured so a failure shows up in the install log, and checked because
authorized_keys.chmod(0o600) # a root-owned authorized_keys is one sshd refuses to read.
subprocess.run(
# Ask the target for the uid rather than assuming the first user is 1000. ["arch-chroot", str(ctx.target), "chown", "-R",
# Uncaptured so a failure shows up in the install log, and checked because f"{ctx.username}:{ctx.username}", f"/home/{ctx.username}/.ssh"],
# a root-owned authorized_keys is one sshd refuses to read. check=True,
subprocess.run( )
["arch-chroot", str(ctx.target), "chown", "-R",
f"{ctx.username}:{ctx.username}", f"/home/{ctx.username}/.ssh"],
check=True,
)
info("› enabling sshd") info("› enabling sshd")
subprocess.run( subprocess.run(
@@ -1695,34 +1550,6 @@ def validate_boot(ctx: InstallContext) -> None:
if ctx.is_protected: if ctx.is_protected:
_validate_pre_mounted_filesystems(ctx) _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: def _assert_boot_hooks_restored(ctx: InstallContext) -> None:
"""Never hand over a system whose UKI rebuild hook is still masked. """Never hand over a system whose UKI rebuild hook is still masked.
@@ -1780,90 +1607,6 @@ def _validate_pre_mounted_filesystems(ctx: InstallContext) -> None:
raise RuntimeError(f"{crypttab} missing LUKS UUID {storage['luks_uuid']}") 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") CPU_SYSFS = Path("/sys/devices/system/cpu")
+188 -127
View File
@@ -1,163 +1,224 @@
# OEM Install + Factory Reset Plan # OEM Install + First-Boot Setup Plan
## Goal ## Goal
An install mode that installs the entire system but defers user account setup to Create a second ISO flavor for OEM preloading that is installed to internal storage at factory time, then boots into customer setup on first power-on (no external media required), and ends in a fully encrypted Omarchy install.
first boot. On first power-on the machine asks for the user, creates it, and
lands in the already-installed system — the classic OEM out-of-box experience.
The same machinery doubles as a "reset computer" option for wiping a machine
before selling it.
## Core idea: "OEM state" as a first-class system state ## Product Requirements
Define one canonical state — *fully installed, no user, setup pending* — and - Factory can preload one image to internal disk.
make both paths produce it: - Customer first boot launches setup flow automatically (already works: `.automated_script.sh` runs configurator on tty1).
- Customer chooses credentials/settings.
- Final installed OS uses full-disk encryption (LUKS) with customer password (already works: configurator collects password and passes it to archinstall as `encryption_password`).
- Existing consumer ISO behavior remains unchanged.
- **ISO OEM mode** ends the install in OEM state instead of creating the user. ---
- **Reset** returns a running machine to OEM state.
OEM state is: a fully installed system plus `/var/lib/omarchy/oem/` containing: ## Chosen Architecture
| File | Purpose | ### Bootstrapping model
|------|---------| - OEM devices ship with an OEM bootstrap ISO image written to internal disk.
| `pending` | Flag that arms the first-boot setup service | - On first boot, `copytoram=y` kernel arg loads the live environment into RAM, freeing the internal disk.
| `packages/node-v*.tar.gz` | Node tarball copied off the ISO's `/opt/packages` (~30MB) so `omarchy-finalize-user` works offline at first boot | - Bootstrap environment launches existing configurator + installer.
| `groups` | Deferred `usermod -aG` groups written by system setup (see below) | - Installer performs a fresh install to internal disk with LUKS encryption enabled.
| `luks-key` | Throwaway LUKS passphrase for encrypted installs, staged for auto-unlock until first-boot re-key | - Bootstrap environment is overwritten during install.
## Why the seam is nearly clean already ### Recovery model: zero-recovery
- No permanent recovery partition.
- Simplest and strongest security story.
- Recovery requires external reinstall media.
Everything in the orchestrator through `finalize_limine_boot` is ---
user-independent except five touch points:
1. `installer.create_users` — useradd + password (root gets the same password). ## Non-Goals
2. Three `usermod -aG` calls inside `omarchy-setup-system`'s scripts
(`config/docker.sh`, `hardware/input-group.sh`, `hardware/apple/fix-t2.sh` —
docker/input/video). This is the *only* reason `omarchy-setup-system`
requires the user to exist (its getent check).
3. `run_chroot_finalizer` → `omarchy-finalize-user` as the user. Already
offline-safe: `/etc/skel` does the heavy seeding at useradd time,
`omarchy-mise-install` writes lazy wrappers, and the only external artifact
is the bundled Node tarball from `/opt/packages` (`install/user/mise.sh`).
4. `configure_login` — SDDM `state.conf` and the encrypted-install autologin.
5. Encryption — the user's password *is* the LUKS passphrase.
Nothing in hardware setup, Limine, hibernation, snapper, or package - No on-device recovery workflow.
installation cares who the user is. - No changes to normal installer UX outside explicit OEM mode checks and warnings.
- No factory credential provisioning.
## First boot: `omarchy-oem-setup.service` ---
Ships in the omarchy runtime package (so reset can arm it without the ISO): ## Repo Implementation Plan
- `ConditionPathExists=/var/lib/omarchy/oem/pending` ## 1) Add OEM build mode
- `Before=display-manager.service`, conflicts `getty@tty1`, TUI on tty1.
- Runs the user form extracted from the configurator (Step 2: username,
password, full name, email). The configurator is bash+gum, so the form moves
into a runtime-shipped `omarchy-oem-setup` script that reuses the exact ISO
look (logo, Tokyo Night VT palette).
Then it does what the ISO would have done: ### Files
- `bin/omarchy-iso-make`
- `builder/build-iso.sh`
1. `useradd` with wheel + the groups from `/var/lib/omarchy/oem/groups`, ### Changes
set user + root password. - Add CLI flag `--oem` to `bin/omarchy-iso-make`.
2. `omarchy-finalize-user` as the user, with a setup context that points at the - Pass `OMARCHY_MODE=oem` into Docker build env (default `normal`).
stashed Node tarball (a sibling of the existing `iso-chroot` context). - In `builder/build-iso.sh`, write mode marker `/root/omarchy_mode` containing `oem` or `normal` (same pattern as `omarchy_mirror`).
3. The `configure_login` writes: SDDM `state.conf`, autologin for encrypted - Keep OEM on current boot stack (`uefi.grub` + `bios.syslinux`); do not add or depend on systemd-boot paths.
installs. - When `OMARCHY_MODE=oem`, inject `copytoram=y` into all shipped OEM boot entry points before `mkarchiso` runs:
4. If `luks-key` exists: `cryptsetup luksChangeKey` to the user's password, - `grub/grub.cfg`: append to `linux` cmdline entries.
delete the stash, remove the auto-unlock keyfile, rebuild initramfs. - `grub/loopback.cfg`: append to `linux` cmdline entries.
5. Git identity from the form (the `OMARCHY_USER_NAME`/`OMARCHY_USER_EMAIL` - `syslinux/archiso_sys-linux.cfg`: append to `APPEND` lines.
path in `install/user/git.sh`). - When `OMARCHY_MODE=oem`, remove accessibility/speakup boot entries:
6. Remove `/var/lib/omarchy/oem/`, hand off to SDDM. - `grub/grub.cfg`: remove `archlinux-accessibility` menuentry.
- `grub/loopback.cfg`: remove `archlinux-accessibility` menuentry.
- `syslinux/archiso_sys-linux.cfg`: remove `arch64speech` label block.
- When `OMARCHY_MODE=oem`, override `iso_application` in `profiledef.sh` to `"Omarchy OEM Installer"`.
- Rename output ISO with `-oem` suffix (in addition to existing `-$OMARCHY_ISO_REF` suffix).
The user lands in their session exactly like a normal install. ### Hardening
- Add post-injection build assertion that fails build if any expected boot config does not contain `copytoram=y`.
- Add post-injection build assertion that fails build if any OEM boot config still contains accessibility/speakup entries.
- Make injection idempotent (do not duplicate `copytoram=y` if already present).
## Encryption: keep it, Pop!_OS-style ### Acceptance criteria
- `./bin/omarchy-iso-make --oem` builds successfully.
- OEM ISO boot entries contain `copytoram=y` in all supported boot paths.
- OEM ISO does not expose accessibility/speakup boot entries.
- Normal build output and behavior remain unchanged.
Do not force OEM installs unencrypted. Install encrypted with a generated ### Suggested verification commands
throwaway passphrase, stage a keyfile so boot auto-unlocks during the OEM - `bsdtar -xOf out/<iso>.iso boot/grub/grub.cfg | rg copytoram=y`
window, and have first-boot setup re-key to the user's chosen password - `bsdtar -xOf out/<iso>.iso boot/grub/loopback.cfg | rg copytoram=y`
(step 4 above). Limine and mkinitcpio don't care what the passphrase is. This - `bsdtar -xOf out/<iso>.iso boot/syslinux/archiso_sys-linux.cfg | rg copytoram=y`
preserves encrypted-by-default without the machine builder ever knowing the end - `bsdtar -xOf out/<iso>.iso boot/grub/grub.cfg | rg 'accessibility|speakup'` (should return no matches)
user's password. - `bsdtar -xOf out/<iso>.iso boot/syslinux/archiso_sys-linux.cfg | rg 'speech|accessibility'` (should return no matches)
## ISO-side changes (this repo) ---
- **Configurator**: an OEM choice in the install-mode step that skips Step 2 ## 2) Add OEM safety guardrails
(user), plus a cidata `oem` marker file that replaces the
`user_credentials.json` requirement — that's what actual OEM imaging rigs
will use via autoinstall.
- **Orchestrator**: `oem: true` in the `omarchy_install` JSON. Swaps the three
user phases (`create_users` inside `arch_install_system`,
`run_chroot_finalizer`, the user parts of `configure_login`) for a
"stage OEM state" phase that writes `/var/lib/omarchy/oem/` and enables
`omarchy-oem-setup.service` in the target. `InstallContext.username` needs an
OEM-safe fallback (creds are absent).
- **Factory snapshot**: at the end of *every* install (not just OEM), take a
read-only snapshot of `@` kept at the 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. This is what makes
reset a true factory reset.
## Runtime-side changes (omarchy repo) ### Why
OEM image boots from internal disk and later wipes that same disk. Live environment must be fully in RAM before install begins.
- `omarchy-oem-setup` + `omarchy-oem-setup.service` (the OOBE above). ### File
- `omarchy-system-factory-reset` (below). - `configs/airootfs/root/.automated_script.sh`
- 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-system-factory-reset` ### Changes
- Before `run_configurator`, if `/root/omarchy_mode` is `oem`:
- Verify `/proc/cmdline` contains `copytoram=y`; abort with clear actionable error if missing.
- Verify factory hardware minimum using `MemTotal`:
- Require `MemTotal >= 8 GiB`.
- Abort with clear error if below minimum.
- Verify runtime memory safety using `MemAvailable`:
- Minimum threshold: `1.5 x ISO size + 1 GiB`.
- Abort with clear error if below threshold.
- Verify runtime state indicates live root is in RAM (for example, expected archiso runtime mounts present and boot media not actively mounted as install target path).
Behind a very explicit confirmation (typed, not y/n): ### Acceptance criteria
- OEM mode aborts safely if `copytoram=y` missing from cmdline.
- OEM mode aborts safely if `MemTotal < 8 GiB`.
- OEM mode aborts safely if `MemAvailable` below threshold.
- OEM mode aborts safely if runtime state does not confirm RAM-backed live environment.
1. Point the default subvolume at a writable snapshot of `@factory`; stage a ### Error message requirements
next-boot wipe unit. - State exactly what failed.
2. On reboot: recreate `@home` (subvolume delete/create — instant, no rm -rf), - State why install is blocked.
clear `@log`, delete snapper snapshots, reset machine-id, SSH host keys, - Provide next step (reboot using proper OEM media or use external recovery media).
NetworkManager connections, Tailscale state, SDDM state; run `fstrim`.
3. Re-arm OEM state: `pending` flag, `groups`, Node tarball (kept from install
or re-fetched into `@factory` at install time), and on encrypted machines a
throwaway LUKS re-key — the buyer never needs the seller's passphrase.
Next boot is the same OOBE as a fresh OEM install. User-installed packages and ---
/etc drift are gone, which is what "wipe and sell" actually promises.
Machines installed before `@factory` existed get a degraded reset — keep the ## 3) OEM disk warning
current system, wipe users/state only — with that caveat surfaced in the
confirmation.
### Honest limitations (document these) ### Why
With `copytoram=y`, archiso copies squashfs to RAM and releases boot device. Existing configurator call `findmnt -no SOURCE /run/archiso/bootmnt` may return empty, so exclusion filter is skipped and all physical disks are shown, including former boot disk. No disk selection logic change is required.
- On unencrypted disks this is deletion, not forensic erasure. `fstrim` helps OEM mode still needs an unmistakable warning before destructive install proceeds.
on SSDs; offer `cryptsetup reencrypt` or `blkdiscard` + reinstall for the
paranoid.
- `luksChangeKey` changes the passphrase but not the volume key; freed extents
remain readable to someone with raw-device access and the new passphrase.
Same remedy as above.
- A reset machine boots with day-one packages until it updates — same as a
fresh install.
## Sequencing ### File
- `configs/airootfs/root/configurator`
The OOBE lives in the omarchy runtime package, but the ISO's OEM phase depends ### Changes
on it existing. So: - Read mode from `/root/omarchy_mode`.
- If mode is `oem`, add OEM-specific warning text before existing destructive confirmation.
- Upgrade confirmation in OEM mode to explicit typed confirmation (for example: `YES, WIPE DISK`) while preserving existing normal-mode flow.
1. Ship the runtime side first (`omarchy-oem-setup`, service, reset command, ### Acceptance criteria
`omarchy-setup-system` relaxation). - Normal ISO behavior is unchanged.
2. ISO OEM mode then asserts the service file exists in the target before - OEM ISO shows OEM-specific warning before disk wipe confirmation.
offering the mode. - OEM typed confirmation is required before destructive action.
3. Factory snapshot can land with either half; reset requires both. - In VM with `copytoram=y`, former boot disk appears in disk list without exclusion-logic changes.
## Validation ---
- VM: OEM install (encrypted + unencrypted) → reboot → OOBE → session; verify ## 4) Documentation
LUKS passphrase is the user's, throwaway key gone, groups applied,
`omarchy-finalize-user` state marker present. ### File
- VM: normal install → use system → `omarchy-system-factory-reset` → OOBE as new - `README.md`
user; verify no trace of prior user (home, NM connections, tailscale,
machine-id, host keys, snapper snapshots). ### Changes
- Autoinstall: cidata drive with `oem` marker and no credentials → unattended - Document `--oem` build flag and resulting artifact naming.
OEM install. - Document factory workflow: writing OEM ISO to internal disk and expected first-boot UX.
- Negative: OEM ISO built against a runtime package lacking the service fails - Document failure and recovery behavior:
the install with a clear error, not a user-less boot loop. - Missing `copytoram=y`, `MemTotal < 8 GiB`, or low `MemAvailable` causes safe abort.
- Interrupted/failed install requires external reinstall media.
- Add short operator checklist for manufacturing line validation.
---
## 5) Validation
### VM test matrix
1. Build OEM ISO.
2. Write OEM ISO to virtual disk (not external media emulation).
3. Boot from virtual disk and verify:
- `/proc/cmdline` contains `copytoram=y`.
- Guardrail checks pass in healthy case.
- OEM boot menu does not include accessibility/speech entries.
4. Complete setup flow on first boot.
5. Install to same disk.
6. Reboot into installed OS.
7. Verify encryption:
- `lsblk` shows crypt mapping.
- `cryptsetup status` reports active LUKS device.
- Boot requires unlock passphrase.
8. Validate OEM warning + typed confirmation appears and gates destructive action.
### Boot path coverage
- UEFI + GRUB path.
- BIOS/syslinux path (if still shipped).
### Hardware test matrix
- UEFI test on at least two hardware families (Intel + AMD).
- Confirm no external media required.
- Confirm first boot starts setup automatically.
### Negative tests
- OEM boot without `copytoram=y` -> safe abort with clear error.
- `MemTotal < 8 GiB` -> safe abort with clear error.
- Low `MemAvailable` -> safe abort with clear error.
- Runtime state not RAM-backed -> safe abort with clear error.
- Power interruption before installer starts -> deterministic reboot behavior.
- Power interruption during install -> recovery requires external media (documented).
---
## 6) Security hardening checklist
- No factory credentials exist; customer sets all passwords at first boot.
- Clear shell history and transient setup artifacts at end of install.
- Verify no secrets in logs shipped to customer.
- Regenerate host identity on first installed boot as needed:
- `machine-id`
- SSH host keys (if installed/enabled)
- Keep package signature verification and keyring handling intact.
---
## Rollout Plan
1. Implement OEM build flag, mode marker, boot config injection, and build assertions.
2. Implement copytoram, memory, and runtime-state guardrails in `.automated_script.sh`.
3. Add OEM warning text and typed destructive confirmation in configurator.
4. Add docs and operator checklist.
5. Run VM matrix, including negative tests.
6. Run hardware matrix.
7. Cut first OEM preproduction release for manufacturing pilot.
---
## Definition of Done
- `--oem` build exists and is documented.
- Device preloaded with OEM image boots directly to setup.
- Setup installs to internal disk without external media.
- Final installed system is LUKS-encrypted with customer-owned passphrase.
- OEM safety guardrails fail closed with actionable errors.
- Existing non-OEM ISO behavior remains unchanged.
- VM and hardware QA matrix pass.
-52
View File
@@ -124,58 +124,6 @@ run_load || fail "required pair plus authorized_keys loads"
[[ ! -e $sandbox/root/tailscale_authkey ]] || fail "absent tailscale_authkey is not copied" [[ ! -e $sandbox/root/tailscale_authkey ]] || fail "absent tailscale_authkey is not copied"
pass "present optional files are copied, absent ones skipped" 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. # Half the required pair is not an autoinstall drive: unmount and fall back.
new_sandbox new_sandbox
attach_drive cidata attach_drive cidata
+1 -1
View File
@@ -60,7 +60,7 @@ class ConfigureSshAccessTest(unittest.TestCase):
if authorized_keys is not None: if authorized_keys is not None:
authorized_keys_path = self.target / "authorized_keys" authorized_keys_path = self.target / "authorized_keys"
authorized_keys_path.write_text(authorized_keys) authorized_keys_path.write_text(authorized_keys)
return types.SimpleNamespace(target=self.target, username="jeff", authorized_keys_path=authorized_keys_path, defer_provisioning=False) return types.SimpleNamespace(target=self.target, username="jeff", authorized_keys_path=authorized_keys_path)
def configure(self, **kwargs): def configure(self, **kwargs):
phases_impl.configure_ssh_access(self.ctx(**kwargs)) phases_impl.configure_ssh_access(self.ctx(**kwargs))
+14 -30
View File
@@ -14,30 +14,14 @@ KEYBOARD = importlib.util.module_from_spec(SPEC)
assert SPEC.loader assert SPEC.loader
SPEC.loader.exec_module(KEYBOARD) SPEC.loader.exec_module(KEYBOARD)
# The layout list lives in the Omarchy runtime now, shared verbatim with the CONFIGURATOR = (ROOT / "configs/airootfs/root/configurator").read_text()
# first-boot owner setup; build-iso.sh vendors it onto the ISO. Read it from KEYBOARD_BLOCK = re.search(r"keyboards=\$'(.*?)'\n choice=", CONFIGURATOR, re.DOTALL)
# wherever this checkout can see a runtime, and skip the coverage test rather assert KEYBOARD_BLOCK
# than fail when none is around (a bare CI checkout of just this repo). SUPPORTED_KEYMAPS = [
SETUP_FORM_CANDIDATES = ( line.split("|", 1)[1]
Path("/omarchy-source/install/provisioning/setup-form.sh"), for line in KEYBOARD_BLOCK.group(1).splitlines()
ROOT.parent / "omarchy/install/provisioning/setup-form.sh", ]
Path("/usr/share/omarchy/install/provisioning/setup-form.sh"), UNSUPPORTED_KEYMAPS = {"ba", "khmer"}
)
def supported_keymaps():
for candidate in SETUP_FORM_CANDIDATES:
if not candidate.is_file():
continue
block = re.search(
r"OMARCHY_KEYBOARD_LAYOUTS=\$'(.*?)'\n", candidate.read_text(), re.DOTALL
)
assert block, f"no layout list in {candidate}"
return [line.split("|", 1)[1] for line in block.group(1).splitlines()]
return None
SUPPORTED_KEYMAPS = supported_keymaps()
class KeyboardConfigurationTest(unittest.TestCase): class KeyboardConfigurationTest(unittest.TestCase):
@@ -61,15 +45,15 @@ class KeyboardConfigurationTest(unittest.TestCase):
self.assertEqual(lines.count("FONT=default8x16"), 1) self.assertEqual(lines.count("FONT=default8x16"), 1)
def test_all_configurator_keymaps_are_known_to_localectl(self): def test_all_configurator_keymaps_are_known_to_localectl(self):
if SUPPORTED_KEYMAPS is None:
self.skipTest("no Omarchy runtime checkout to read the layout list from")
for keymap in SUPPORTED_KEYMAPS: for keymap in SUPPORTED_KEYMAPS:
with self.subTest(keymap=keymap), tempfile.TemporaryDirectory() as directory: with self.subTest(keymap=keymap), tempfile.TemporaryDirectory() as directory:
target = self.target(directory) target = self.target(directory)
self.assertTrue(KEYBOARD.configure_keyboard(target, keymap)) configured = KEYBOARD.configure_keyboard(target, keymap)
lines = (target / "etc/vconsole.conf").read_text().splitlines() self.assertEqual(configured, keymap not in UNSUPPORTED_KEYMAPS)
self.assertIn(f"KEYMAP={keymap}", lines) if configured:
self.assertEqual(lines.count("FONT=default8x16"), 1) lines = (target / "etc/vconsole.conf").read_text().splitlines()
self.assertIn(f"KEYMAP={keymap}", lines)
self.assertEqual(lines.count("FONT=default8x16"), 1)
def test_unknown_and_empty_keymaps_match_archinstall_behavior(self): def test_unknown_and_empty_keymaps_match_archinstall_behavior(self):
for keymap, expected in (("definitely-not-a-keymap", False), ("", True)): for keymap, expected in (("definitely-not-a-keymap", False), ("", True)):
-440
View File
@@ -1,440 +0,0 @@
"""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()