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