Compare commits

...
Author SHA1 Message Date
David Heinemeier HanssonandClaude f32247afc3 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>
2026-08-09 21:56:01 +02:00
David Heinemeier HanssonandClaude 81de5087ea 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>
2026-08-08 03:13:00 -07:00
David Heinemeier HanssonandClaude Fable 5 d98b383e20 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>
2026-08-08 02:45:57 -07:00
David Heinemeier HanssonandClaude Fable 5 5f2f336aba 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>
2026-08-08 02:37:50 -07:00
David Heinemeier HanssonandClaude Fable 5 7c196f8d12 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>
2026-08-08 01:35:35 -07:00
David Heinemeier HanssonandClaude Fable 5 c11276d49a Tighten the prepare-for-another-owner confirm wording
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 01:33:58 -07:00
David Heinemeier HanssonandClaude Fable 5 944877fdab 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>
2026-08-08 01:30:12 -07:00
David Heinemeier HanssonandClaude Fable 5 57beb18b72 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>
2026-08-08 01:14:56 -07:00
David Heinemeier HanssonandClaude Fable 5 2f54e5579b 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>
2026-08-08 01:08:58 -07:00
David Heinemeier HanssonandClaude Fable 5 30759f2082 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>
2026-08-08 00:58:27 -07:00
David Heinemeier HanssonandClaude Fable 5 98a345c017 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>
2026-08-08 00:52:14 -07:00
David Heinemeier HanssonandClaude Fable 5 a478349ca9 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>
2026-08-07 23:13:53 -07:00
David Heinemeier HanssonandClaude Fable 5 7f1fdf2427 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>
2026-08-07 22:36:56 -07:00
David Heinemeier HanssonandClaude Fable 5 8aee09cb29 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>
2026-08-07 19:03:49 -07:00
David Heinemeier HanssonandClaude Fable 5 e49ea2bc96 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>
2026-08-07 18:09:10 -07:00
David Heinemeier HanssonandClaude Fable 5 445db32792 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>
2026-08-07 16:51:01 -07:00
David Heinemeier HanssonandClaude Fable 5 fbee4ba437 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>
2026-08-07 16:03:15 -07:00
David Heinemeier HanssonandClaude Fable 5 b5f7e96c70 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>
2026-08-07 15:52:55 -07:00
David Heinemeier HanssonandClaude Fable 5 679edce2e3 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>
2026-08-07 15:41:04 -07:00
David Heinemeier Hansson 6004e80485 New plan 2026-08-08 00:08:46 +02:00
David Heinemeier HanssonandClaude Fable 5 0339b13d12 Join the tailnet from a cidata tailscale_authkey (#97)
* Join the tailnet from a cidata tailscale_authkey

A tailscale_authkey file on the autoinstall drive gets the installed
machine onto the tailnet on its own: the tailscale package is bundled in
the offline mirror and installed only when the key is present, the key is
staged root-only at /etc/tailscale/authkey, and a oneshot first-boot unit
runs tailscale up once the network is really there. On success the key is
deleted and the unit disables itself; on failure both survive to retry on
the next boot, so an offline install joins whenever it first gets
connectivity. ufw allows traffic in on tailscale0 via the same
write-the-rule-then-ignore-the-chroot-exit-code dance as the SSH phase.

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xc74h4WSYpuYPGuYbFAR1q

* Do not hold boot hostage for the tailnet join

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xc74h4WSYpuYPGuYbFAR1q

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 15:10:30 +02:00
17 changed files with 1938 additions and 289 deletions

No files matched your search

+3
View File
@@ -38,6 +38,7 @@ These are the configurator's own output files, so the way to get a starting set
| `user_email_address.txt` | No | Git email | | `user_email_address.txt` | No | Git email |
| `user_encrypt_installation.txt` | No | `true` when `user_configuration.json` carries a `disk_encryption` block; defaults to false | | `user_encrypt_installation.txt` | No | `true` when `user_configuration.json` carries a `disk_encryption` block; defaults to false |
| `authorized_keys` | No | SSH public keys in sshd's own format, one per line | | `authorized_keys` | No | SSH public keys in sshd's own format, one per line |
| `tailscale_authkey` | No | Tailscale auth key; the machine joins your tailnet on first boot |
Both required files must be present or the installer falls back to the configurator. Generate the password hash for `user_credentials.json` with `openssl passwd -6 "yourpassword"`. Both required files must be present or the installer falls back to the configurator. Generate the password hash for `user_credentials.json` with `openssl passwd -6 "yourpassword"`.
@@ -51,6 +52,8 @@ ssh-ed25519 AAAA... you@host
When `authorized_keys` is present, autoinstall installs it as the user's `~/.ssh/authorized_keys`, enables `sshd`, and adds a `ufw allow ssh` rule — a stock Omarchy install ships openssh with the service disabled and its firewall opens neither port 22 nor anything else beyond LocalSend. Networking needs nothing extra; NetworkManager is already enabled with DHCP. Password SSH authentication is left at the distro default. An `authorized_keys` with no usable keys fails the install rather than producing a machine nobody can reach. When `authorized_keys` is present, autoinstall installs it as the user's `~/.ssh/authorized_keys`, enables `sshd`, and adds a `ufw allow ssh` rule — a stock Omarchy install ships openssh with the service disabled and its firewall opens neither port 22 nor anything else beyond LocalSend. Networking needs nothing extra; NetworkManager is already enabled with DHCP. Password SSH authentication is left at the distro default. An `authorized_keys` with no usable keys fails the install rather than producing a machine nobody can reach.
When `tailscale_authkey` is present (one key, blank lines and `#` comments ignored), the install adds the `tailscale` package from the ISO's bundled mirror — nothing is fetched from the network at install or boot — and stages the join for first boot: the key lands at `/etc/tailscale/authkey` (root-only), `tailscaled` is enabled, ufw allows traffic in on `tailscale0`, and a background unit runs `tailscale up` once the network is actually up, retrying until it succeeds without holding up the boot. After a successful join the key is deleted and the unit disables itself; until then both survive reboots, so a machine installed offline joins whenever it first gets connectivity. The node appears on the tailnet under the configured hostname. Use a reusable, pre-authorized (tagged) key so one drive image serves many machines — or an ephemeral key for disposable VMs.
### Building the drive ### Building the drive
```bash ```bash
+207 -7
View File
@@ -14,6 +14,8 @@
# Usage: omarchy-iso-test [release/omarchy.iso] [options] # Usage: omarchy-iso-test [release/omarchy.iso] [options]
# #
# --encrypt Drive the encrypted install flow (default: unencrypted) # --encrypt Drive the encrypted install flow (default: unencrypted)
# --provision Drive the deferred-provisioning install flow: no user during install, the
# first boot runs omarchy-provision-owner and creates it there
# --reuse-base Skip the install phase if a base image already exists # --reuse-base Skip the install phase if a base image already exists
# --install-only Stop after producing the installed base image # --install-only Stop after producing the installed base image
# --sync-omarchy DIR Push DIR's test/acceptance into the guest before running # --sync-omarchy DIR Push DIR's test/acceptance into the guest before running
@@ -36,6 +38,7 @@ MEMORY=8192
INSTALL_TIMEOUT=2400 INSTALL_TIMEOUT=2400
BOOT_TIMEOUT=600 BOOT_TIMEOUT=600
ENCRYPT=false ENCRYPT=false
PROVISION=false
REUSE_BASE=false REUSE_BASE=false
INSTALL_ONLY=false INSTALL_ONLY=false
KEEP_RUNNING=false KEEP_RUNNING=false
@@ -50,6 +53,7 @@ GUEST_HOSTNAME="omarchy-test"
while (($#)); do while (($#)); do
case "$1" in case "$1" in
--encrypt) ENCRYPT=true ;; --encrypt) ENCRYPT=true ;;
--provision) PROVISION=true ;;
--reuse-base) REUSE_BASE=true ;; --reuse-base) REUSE_BASE=true ;;
--install-only) INSTALL_ONLY=true ;; --install-only) INSTALL_ONLY=true ;;
--keep-running) KEEP_RUNNING=true ;; --keep-running) KEEP_RUNNING=true ;;
@@ -86,6 +90,7 @@ OVMF_VARS_TEMPLATE="/usr/share/edk2/x64/OVMF_VARS.4m.fd"
BASE_NAME="$(basename "$ISO" .iso)" BASE_NAME="$(basename "$ISO" .iso)"
$ENCRYPT && BASE_NAME+="-encrypted" $ENCRYPT && BASE_NAME+="-encrypted"
$PROVISION && BASE_NAME+="-provision"
BASE_DIR="$REPO_DIR/test-runs/$BASE_NAME" BASE_DIR="$REPO_DIR/test-runs/$BASE_NAME"
RUN_DIR="$BASE_DIR/runs/$(date +%Y%m%d-%H%M%S)" RUN_DIR="$BASE_DIR/runs/$(date +%Y%m%d-%H%M%S)"
BASE_DISK="$BASE_DIR/base.qcow2" BASE_DISK="$BASE_DIR/base.qcow2"
@@ -96,7 +101,9 @@ HTTP_PID=""
mkdir -p "$BASE_DIR" "$RUN_DIR" 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" PIDFILE="$RUN_DIR/qemu.pid"
log() { log() {
@@ -286,6 +293,28 @@ type_text() {
:) press shift-semicolon ;; :) press shift-semicolon ;;
@) press shift-2 ;; @) press shift-2 ;;
\|) press shift-backslash ;; \|) press shift-backslash ;;
";") press semicolon ;;
"'") press apostrophe ;;
'"') press shift-apostrophe ;;
"=") press equal ;;
"+") press shift-equal ;;
"%") press shift-5 ;;
"&") press shift-7 ;;
"*") press shift-8 ;;
"(") press shift-9 ;;
")") press shift-0 ;;
"<") press shift-comma ;;
">") press shift-dot ;;
"!") press shift-1 ;;
"#") press shift-3 ;;
'$') press shift-4 ;;
"?") press shift-slash ;;
"~") press shift-grave_accent ;;
"\\") press backslash ;;
"[") press bracket_left ;;
"]") press bracket_right ;;
"{") press shift-bracket_left ;;
"}") press shift-bracket_right ;;
*) echo "type_text: unsupported character: $ch" >&2; return 1 ;; *) echo "type_text: unsupported character: $ch" >&2; return 1 ;;
esac esac
sleep 0.05 sleep 0.05
@@ -297,6 +326,7 @@ type_text() {
ssh_guest() { ssh_guest() {
ssh -i "$SSH_KEY" -p "$SSH_PORT" \ ssh -i "$SSH_KEY" -p "$SSH_PORT" \
-o BatchMode=yes \ -o BatchMode=yes \
-o IdentitiesOnly=yes \
-o StrictHostKeyChecking=no \ -o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \ -o UserKnownHostsFile=/dev/null \
-o ConnectTimeout=5 \ -o ConnectTimeout=5 \
@@ -576,10 +606,43 @@ audio-panel|meta_l-ctrl-a|omarchy-keyboard-panel|omarchy.audio'
drive_configurator() { drive_configurator() {
log "Driving the installer wizard" log "Driving the installer wizard"
# A greeter (animated logo + tagline) opens the installer; Return begins.
wait_for_screen "Opinionated" 300
capture_console "success-installer-00-greeter"
press ret
# Keyboard is the first screen. Ctrl+C there is the hidden entry into deferred provisioning
# mode; a normal install just selects the layout.
wait_for_screen "keyboard layout" 300 wait_for_screen "keyboard layout" 300
capture_console "success-installer-01-keyboard-layout" capture_console "success-installer-01-keyboard-layout"
if $PROVISION; then
press ctrl-c # arm "prepare for another owner"
wait_for_screen "another owner" 30
capture_console "success-installer-02-prepare-confirm"
press ret # "Yes, prepare for another owner" is the affirmative default
# Jumps straight to disk selection, then the overwrite confirm.
wait_for_screen "install disk" 60
capture_console "success-installer-03-prepare-install-disk"
press ret
wait_for_screen "recovery possible" 60
capture_console "success-installer-04-prepare-disk-warning-encrypted"
if ! $ENCRYPT; then
press ctrl-c
wait_for_screen "without encryption" 30
capture_console "success-installer-05-prepare-disk-warning-unencrypted"
fi
press ret
sleep 2
capture_console "success-installer-13-install-started"
return 0
fi
press ret # English (US) is preselected press ret # English (US) is preselected
# Normal flow: user step comes before disk selection.
wait_for_screen "Username" 120 wait_for_screen "Username" 120
type_text "$GUEST_USER" type_text "$GUEST_USER"
capture_console "success-installer-02-username" capture_console "success-installer-02-username"
@@ -622,17 +685,93 @@ drive_configurator() {
capture_console "success-installer-10-install-disk" capture_console "success-installer-10-install-disk"
press ret # the single virtio disk press ret # the single virtio disk
wait_for_screen "installation mode\|recovery possible" 60
if ocr_screen | grep -qi "installation mode"; then
capture_console "success-installer-11-install-mode"
press ret # "Full disk install" is preselected
fi
wait_for_screen "recovery possible" 60 wait_for_screen "recovery possible" 60
capture_console "success-installer-11-disk-warning-encrypted" capture_console "success-installer-12-disk-warning-encrypted"
if ! $ENCRYPT; then if ! $ENCRYPT; then
press ctrl-c # toggles the confirm to the unencrypted flow press ctrl-c # toggles the confirm to the unencrypted flow
wait_for_screen "without encryption" 30 wait_for_screen "without encryption" 30
capture_console "success-installer-12-disk-warning-unencrypted" capture_console "success-installer-13-disk-warning-unencrypted"
fi fi
press ret press ret
sleep 2 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() { wait_for_install() {
@@ -649,6 +788,15 @@ wait_for_install() {
return 0 return 0
fi fi
# Deferred-provisioning installs skip the celebration and reboot on their own
# (no Reboot Now prompt); the first-boot greeter tagline appearing means the
# install finished and rebooted into setup.
if $PROVISION && grep -qi "Opinionated" <<<"$text"; then
log "Install finished and auto-rebooted into first-boot setup."
capture_console "success-installer-15-autoreboot"
return 0
fi
if grep -qi "installation stopped" <<<"$text"; then if grep -qi "installation stopped" <<<"$text"; then
capture_console "failure-installer-installation-stopped" capture_console "failure-installer-installation-stopped"
echo "Install failed — screenshot saved to $RUN_DIR" >&2 echo "Install failed — screenshot saved to $RUN_DIR" >&2
@@ -751,11 +899,11 @@ EOF
press ret press ret
sleep 3 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" capture_console "success-first-boot-05-bootstrap-command"
press ret 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" capture_console "success-first-boot-06-bootstrap-complete"
kill "$HTTP_PID" 2>/dev/null || true kill "$HTTP_PID" 2>/dev/null || true
@@ -835,11 +983,63 @@ install_phase() {
drive_configurator drive_configurator
wait_for_install wait_for_install
if $ENCRYPT; then if $PROVISION; then
drive_provision_owner
elif $ENCRYPT; then
unlock_luks unlock_luks
fi fi
bootstrap_ssh bootstrap_ssh
# SSH can come up (console bootstrap logs in on a spare TTY) while
# omarchy-provision-owner is still finalizing the user and re-keying LUKS. Saving
# the base mid-finalization produces a flaky image, so wait for the pending
# flag to clear before powering off.
if $PROVISION; then
log "Waiting for provisioning first-boot setup to finish"
local waited=0 rc
while true; do
# Capture rc via `|| rc=$?` so a non-zero result doesn't trip `set -e`
# (this command is intentionally allowed to "fail" — that's the signal).
rc=0
ssh_guest "test -f /var/lib/omarchy/provisioning/pending" 2>/dev/null || rc=$?
# rc 0 = flag still present; rc 255 = SSH not up yet (finalization may
# still be running) — keep waiting. Only a clean "file absent" (rc 1)
# means provisioning finished, so we don't snapshot mid-finalization on a
# transient SSH blip.
(( rc == 0 || rc == 255 )) || break
if ((waited >= 900)); then
capture_console "failure-provision-finalization-timeout"
echo "Timed out waiting for provisioning first-boot setup to complete" >&2
return 1
fi
sleep 10
((waited += 10))
done
# The owner's hostname and timezone are set at first boot now, not baked
# into the install — confirm they actually landed on the running system.
# Hostname is deterministic (we typed it), so a mismatch is a hard failure;
# timezone is geo-guessed, so just report what landed.
local got_host="" got_tz=""
got_host=$(ssh_guest "cat /etc/hostname" 2>/dev/null | tr -d '\r\n') || got_host=""
got_tz=$(ssh_guest "timedatectl show -p Timezone --value" 2>/dev/null | tr -d '\r\n') || got_tz=""
if [[ $got_host != "$GUEST_HOSTNAME" ]]; then
capture_console "failure-provision-hostname-not-applied"
echo "First-boot hostname is '${got_host:-<empty>}', expected '$GUEST_HOSTNAME'" >&2
return 1
fi
log "hostname applied at first boot: $got_host"
if [[ -n $got_tz && $got_tz != "UTC" ]]; then
log "timezone applied at first boot: $got_tz"
else
echo "WARN: timezone is '${got_tz:-<empty>}' (expected the owner's choice, not UTC)" >&2
fi
# Deferred first-boot setup hands straight off to SDDM once the oneshot
# service exits — no celebration screen, no Start Omarchy button to confirm.
capture_console "success-provision-09-handoff"
fi
ssh_guest "echo $GUEST_PASSWORD | sudo -S cat /var/log/omarchy-install-timing.json 2>/dev/null" \ ssh_guest "echo $GUEST_PASSWORD | sudo -S cat /var/log/omarchy-install-timing.json 2>/dev/null" \
>"$RUN_DIR/omarchy-install-timing.json" 2>/dev/null || true >"$RUN_DIR/omarchy-install-timing.json" 2>/dev/null || true
ssh_guest "echo $GUEST_PASSWORD | sudo -S cat /var/log/pacman.log 2>/dev/null" \ ssh_guest "echo $GUEST_PASSWORD | sudo -S cat /var/log/pacman.log 2>/dev/null" \
+1
View File
@@ -12,3 +12,4 @@ openssh
pipewire pipewire
snapper snapper
sof-firmware sof-firmware
tailscale
+13 -1
View File
@@ -101,6 +101,16 @@ else
./configurator ./configurator
fi fi
# Deferred-provisioning installs skip the celebration/reboot prompt and reboot on
# their own — the owner completes setup at first boot. Signalled by the config's
# defer_provisioning flag (interactive) or the defer-provisioning marker (cidata).
# Parse the flag with jq (the same JSON semantics the orchestrator uses) rather
# than a line regex, so a reformatted config can't read as a direct install.
if [[ -f /root/defer-provisioning ]] ||
[[ "$(jq -r '.omarchy_install.defer_provisioning // false' /root/user_configuration.json 2>/dev/null)" == "true" ]]; then
export OMARCHY_UI_DEFER_PROVISIONING=yes
fi
# The foreground dashboard is now the sole visible install UI owner. It starts # The foreground dashboard is now the sole visible install UI owner. It starts
# the actual installer as a non-interactive child, logs child output, waits for # the actual installer as a non-interactive child, logs child output, waits for
# completion, then renders the final installed-time/reboot prompt itself. # completion, then renders the final installed-time/reboot prompt itself.
@@ -116,4 +126,6 @@ rm -f /run/omarchy-install/state.json
--full-name-file /root/user_full_name.txt \ --full-name-file /root/user_full_name.txt \
--email-file /root/user_email_address.txt \ --email-file /root/user_email_address.txt \
--encrypt-file /root/user_encrypt_installation.txt \ --encrypt-file /root/user_encrypt_installation.txt \
--authorized-keys-file /root/authorized_keys --authorized-keys-file /root/authorized_keys \
--tailscale-authkey-file /root/tailscale_authkey \
--defer-provisioning-file /root/defer-provisioning
+266 -50
View File
@@ -38,12 +38,111 @@ measure_terminal() {
} }
measure_terminal measure_terminal
# The live VT resizes late during boot: tty1 starts as the 80-column boot
# console and only grows once the kernel framebuffer/KMS takes over (noticeably
# late in VMs). The logo is wider than 80 columns, so a first draw against that
# transient narrow console wraps the logo ("smushed") and can't center it
# (padding clamps to 0, so it hugs the left). A terminal emulator (or sudo's
# pty) has the same problem the other way: it reports a stale 24x80 for a few
# hundred ms after start before its real winsize is set. Either way, hold the
# first draw until the width settles at (or above) the logo width. A real VT
# that never widens (e.g. nomodeset) still proceeds after the cap.
wait_for_stable_terminal() {
local width last="" stable=0 waited=0
while (( waited < 5000 )); do
width=$(stty size 2>/dev/null </dev/tty | awk '{print $2}')
[[ $width =~ ^[0-9]+$ ]] || width=0
if [[ $width == "$last" ]] && (( width >= LOGO_WIDTH )); then
(( ++stable >= 3 )) && break
else
stable=0
fi
last=$width
sleep 0.2
(( waited += 200 ))
done
}
clear_logo() { clear_logo() {
measure_terminal measure_terminal
printf "\033[H\033[2J" printf "\033[H\033[2J"
gum style --foreground 2 --padding "1 0 0 $PADDING_LEFT" "$(<"$LOGO_PATH")" gum style --foreground 2 --padding "1 0 0 $PADDING_LEFT" "$(<"$LOGO_PATH")"
} }
# Vertically-centered welcome shown once before the keyboard step — the same
# frame the deferred-provisioning first boot opens on, so install and first boot
# bookend the setup identically. Logo, the Omarchy tagline, and a single button,
# centered like the boot logo. No animation.
greeter() {
measure_terminal
local rows logo_h content_h top cols logo_row tagline_row hint_row
local tagline hint tpad hpad anim
cols=$TERM_WIDTH
rows=$(stty size 2>/dev/null </dev/tty | awk '{print $1}')
[[ $rows =~ ^[0-9]+$ ]] || rows=${LINES:-24}
logo_h=$(wc -l <"$LOGO_PATH")
# logo + blank + tagline + blank + hint
content_h=$((logo_h + 4))
top=$(((rows - content_h) / 2))
(( top < 0 )) && top=0
logo_row=$((top + 1))
tagline_row=$((top + logo_h + 2))
hint_row=$((top + logo_h + 4))
printf '\033[?25l\033[H\033[2J'
printf '\033[%d;1H' "$logo_row"
gum style --foreground 2 --padding "0 0 0 $PADDING_LEFT" "$(<"$LOGO_PATH")"
tagline="Beautiful, Modern & Opinionated Linux by DHH"
tpad=$(((cols - ${#tagline}) / 2)); (( tpad < 0 )) && tpad=0
printf '\033[%d;%dH%s' "$tagline_row" "$((tpad + 1))" "$tagline"
hint="Press Return to Start Install"
hpad=$(((cols - ${#hint}) / 2)); (( hpad < 0 )) && hpad=0
printf '\033[%d;%dH\033[2m%s\033[0m' "$hint_row" "$((hpad + 1))" "$hint"
# ColorShift the logo: a green base (indexed color 2) with a cyan accent (6)
# drifting through, settling on green. Indexed ANSI colors, not hex — the
# framebuffer console can't render tte's truecolor faithfully (it crushes the
# palette to a muddy lavender), but the 16 indexed colors map to the Tokyo
# Night palette and render true. One long-running invocation (many cycles) so
# it never restarts — a restart is what flashed. The effect reads from
# /dev/null so it never swallows the Return; --reuse-canvas paints upward from
# the saved cursor, anchored one row below the logo.
if command -v tte >/dev/null 2>&1; then
printf '\033[%d;1H\0337' "$((logo_row + logo_h))"
# Run tte directly (not inside a `while` subshell) so $anim is tte's own
# PID: killing a wrapping subshell would orphan tte, which then keeps
# painting the logo over the keyboard step. --cycles is high enough that it
# never ends on its own before Return.
tte -i "$LOGO_PATH" \
--canvas-width "$cols" \
--anchor-text c \
--frame-rate 60 \
--reuse-canvas \
colorshift \
--gradient-stops 2 10 6 10 \
--gradient-frames 3 \
--cycles 1000 \
--final-gradient-stops 2 \
</dev/null >/dev/tty 2>/dev/null &
anim=$!
fi
IFS= read -r _ </dev/tty || true
# Tolerate tte's kill signal in `wait` so a future `set -e` here can't abort.
[[ -n ${anim:-} ]] && { kill "$anim" 2>/dev/null; wait "$anim" 2>/dev/null || true; }
# tte leaves the tty in raw/no-echo mode when killed; restore it or the gum
# prompts in the keyboard step that follows silently die.
stty sane </dev/tty 2>/dev/null || true
# Reset the screen so the leftover animation frame doesn't linger under the
# keyboard step that follows.
printf '\033[0m\033[H\033[2J\033[?25h'
}
abort() { abort() {
gum style "${1:-Aborted installation}" gum style "${1:-Aborted installation}"
echo echo
@@ -73,11 +172,22 @@ notice() {
# STEP 1: KEYBOARD LAYOUT # STEP 1: KEYBOARD LAYOUT
keyboard_form() { 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 keyboards=$'Azerbaijani|azerty
Belarusian|by Belarusian|by
Belgian|be-latin1 Belgian|be-latin1
Bosnian|ba
Bulgarian|bg-cp1251 Bulgarian|bg-cp1251
Croatian|croat Croatian|croat
Czech|cz Czech|cz
@@ -103,7 +213,6 @@ Irish|ie
Italian|it Italian|it
Japanese|jp106 Japanese|jp106
Kazakh|kazakh Kazakh|kazakh
Khmer (Cambodia)|khmer
Kyrgyz|kyrgyz Kyrgyz|kyrgyz
Lao|la-latin1 Lao|la-latin1
Latvian|lv Latvian|lv
@@ -124,7 +233,19 @@ Swedish|sv-latin1
Tajik|tj_alt-UTF8 Tajik|tj_alt-UTF8
Turkish|trq Turkish|trq
Ukrainian|ua' 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}') keyboard=$(printf '%s\n' "$keyboards" | awk -F'|' -v c="$choice" '$1==c{print $2; exit}')
# Only attempt to load keyboard layout if we're on a real console # Only attempt to load keyboard layout if we're on a real console
@@ -134,9 +255,22 @@ Ukrainian|ua'
fi 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() { user_form() {
step "Let's setup your user account..." step "Let's setup your user account..."
@@ -197,11 +331,12 @@ user_form() {
fi fi
} }
user_form user_step() {
user_form
while true; do while true; do
# Add manual padding since gum table -p doesn't respect padding # Add manual padding since gum table -p doesn't respect padding
echo -e "Field,Value echo -e "Field,Value
Username,$username Username,$username
Password,$(printf "%${#password}s" | tr ' ' '*') Password,$(printf "%${#password}s" | tr ' ' '*')
Full name,${full_name:-[Skipped]} Full name,${full_name:-[Skipped]}
@@ -209,16 +344,17 @@ Email address,${email_address:-[Skipped]}
Hostname,$hostname Hostname,$hostname
Timezone,$timezone Timezone,$timezone
Keyboard,$keyboard" | Keyboard,$keyboard" |
gum table -s "," -p | sed "s/^/${PADDING_LEFT_SPACES}/" gum table -s "," -p | sed "s/^/${PADDING_LEFT_SPACES}/"
echo echo
if gum confirm --negative "No, change it" "Does this look right?"; then if gum confirm --negative "No, change it" "Does this look right?"; then
break break
else else
keyboard_form keyboard_form
user_form user_form
fi fi
done done
}
# STEP 3: DISK # STEP 3: DISK
@@ -347,11 +483,22 @@ wait_for_device() {
# Save the configurator answers the installer consumes: git identity and # Save the configurator answers the installer consumes: git identity and
# encryption choice as plain files, account details as archinstall credentials. # encryption choice as plain files, account details as archinstall credentials.
# Deferred-provisioning installs defer the user to first boot: no credentials, and on encrypted
# targets the orchestrator generates a throwaway LUKS passphrase itself.
write_user_files() { write_user_files() {
echo "$full_name" >user_full_name.txt echo "$full_name" >user_full_name.txt
echo "$email_address" >user_email_address.txt echo "$email_address" >user_email_address.txt
printf '%s\n' "$encrypt_installation" >user_encrypt_installation.txt printf '%s\n' "$encrypt_installation" >user_encrypt_installation.txt
if $defer_provisioning; then
cat <<-_EOF_ >user_credentials.json
{
"users": []
}
_EOF_
return
fi
local password_escaped password_hash_escaped username_escaped local password_escaped password_hash_escaped username_escaped
password_escaped=$(echo -n "$password" | jq -Rsa) password_escaped=$(echo -n "$password" | jq -Rsa)
password_hash_escaped=$(echo -n "$password_hash" | jq -Rsa) password_hash_escaped=$(echo -n "$password_hash" | jq -Rsa)
@@ -413,10 +560,10 @@ not_enough_space() {
return 1 return 1
} }
run_partition_install() { # Decide half of the free-space install: checks, free-space analysis, and the
local dry=false # encryption confirm. Sets the globals run_partition_execute needs. Returns 1
[[ "${1:-}" == "dry" ]] && dry=true # to fall back to the install-mode picker.
run_partition_decide() {
step "Checking for BitLocker on $disk" step "Checking for BitLocker on $disk"
local bl_part local bl_part
bl_part=$(detect_bitlocker || true) bl_part=$(detect_bitlocker || true)
@@ -448,7 +595,8 @@ run_partition_install() {
# An unlabeled disk has no partition table for parted to scan, so we # An unlabeled disk has no partition table for parted to scan, so we
# synthesize a whole-disk free region and mklabel gpt before mkpart. # synthesize a whole-disk free region and mklabel gpt before mkpart.
local FREE_SPACE_INFO 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) pt_type=$(lsblk -dno PTTYPE "$disk" 2>/dev/null)
if [[ -z "$pt_type" ]]; then if [[ -z "$pt_type" ]]; then
disk_size_b=$(lsblk -bdno SIZE "$disk" 2>/dev/null) disk_size_b=$(lsblk -bdno SIZE "$disk" 2>/dev/null)
@@ -491,7 +639,6 @@ run_partition_install() {
local EFI_SIZE_B=$((2 * gib)) local EFI_SIZE_B=$((2 * gib))
local MIN_INSTALL_B=$((32 * gib)) # Minimum total Omarchy allocation local MIN_INSTALL_B=$((32 * gib)) # Minimum total Omarchy allocation
local EFI_START_B EFI_END_B ROOT_START_B ROOT_END_B
EFI_START_B=$(( (FREE_START_B + ALIGN - 1) / ALIGN * ALIGN )) EFI_START_B=$(( (FREE_START_B + ALIGN - 1) / ALIGN * ALIGN ))
EFI_END_B=$((EFI_START_B + EFI_SIZE_B)) EFI_END_B=$((EFI_START_B + EFI_SIZE_B))
ROOT_START_B=$(( (EFI_END_B + 1 + ALIGN - 1) / ALIGN * ALIGN )) ROOT_START_B=$(( (EFI_END_B + 1 + ALIGN - 1) / ALIGN * ALIGN ))
@@ -554,7 +701,6 @@ run_partition_install() {
esac esac
done done
local esp_mount_in_target
if [[ "$encrypt_installation" == "true" ]]; then if [[ "$encrypt_installation" == "true" ]]; then
esp_mount_in_target=/boot esp_mount_in_target=/boot
else else
@@ -563,12 +709,11 @@ run_partition_install() {
kernel_choice=$(detect_kernel) 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) last_part=$(lsblk -n -o PARTN "$disk" 2>/dev/null | grep -E '^[0-9]+$' | sort -n | tail -1)
efi_part_num=$(( ${last_part:-0} + 1 )) efi_part_num=$(( ${last_part:-0} + 1 ))
root_part_num=$(( efi_part_num + 1 )) root_part_num=$(( efi_part_num + 1 ))
local efi_dev root_partition_device root_mapper
efi_dev=$(partition_path "$disk" "$efi_part_num") efi_dev=$(partition_path "$disk" "$efi_part_num")
root_partition_device=$(partition_path "$disk" "$root_part_num") root_partition_device=$(partition_path "$disk" "$root_part_num")
if [[ "$encrypt_installation" == "true" ]]; then if [[ "$encrypt_installation" == "true" ]]; then
@@ -576,6 +721,14 @@ run_partition_install() {
else else
root_mapper="$root_partition_device" root_mapper="$root_partition_device"
fi fi
}
# Execute half of the free-space install: partition, format, mount, and write
# the pre-mounted (protected) install configuration. Runs after the user step
# so LUKS can be formatted with the user's password.
run_partition_execute() {
local dry=false
[[ "${1:-}" == "dry" ]] && dry=true
if $dry; then if $dry; then
$needs_mklabel && say "[dry] would initialize GPT on $disk" $needs_mklabel && say "[dry] would initialize GPT on $disk"
@@ -634,10 +787,6 @@ run_partition_install() {
mount "$efi_dev" /mnt"$esp_mount_in_target" mount "$efi_dev" /mnt"$esp_mount_in_target"
fi fi
clear
write_user_files
local luks_uuid_json="null" luks_uuid local luks_uuid_json="null" luks_uuid
if [[ "$encrypt_installation" == "true" ]]; then if [[ "$encrypt_installation" == "true" ]]; then
if $dry; then if $dry; then
@@ -716,10 +865,6 @@ run_partition_install() {
"version": "3.0.9" "version": "3.0.9"
} }
_EOF_ _EOF_
if $dry; then
print_dry_run_files
fi
} }
disk_form() { disk_form() {
@@ -771,8 +916,7 @@ open_partition_tool() {
} }
# If the disk has no partition table or no unallocated free space, the only # If the disk has no partition table or no unallocated free space, the only
# viable path is a full-disk install. Skip the install-mode picker and go # viable paths write the whole disk, so the free-space mode is not offered.
# straight to confirmation.
requires_full_disk_install() { requires_full_disk_install() {
local pt_type free_space_b local pt_type free_space_b
pt_type=$(lsblk -dno PTTYPE "$disk" 2>/dev/null) pt_type=$(lsblk -dno PTTYPE "$disk" 2>/dev/null)
@@ -799,7 +943,7 @@ install_mode_form() {
# The free-space/protected path registers an EFI boot entry and is UEFI-only. # The free-space/protected path registers an EFI boot entry and is UEFI-only.
# Full-disk installs still support BIOS through the orchestrator's BIOS branch. # Full-disk installs still support BIOS through the orchestrator's BIOS branch.
if [[ -d /sys/firmware/efi ]]; then if [[ -d /sys/firmware/efi ]] && ! $full_disk_only; then
choices+=("Free space install (alongside existing data)") choices+=("Free space install (alongside existing data)")
fi fi
choices+=("Choose a different disk") choices+=("Choose a different disk")
@@ -846,30 +990,43 @@ confirm_disk_overwrite() {
done done
} }
# Decide what and how to install: sets install_target (full_disk|free_space),
# install_target and encrypt_installation. Nothing is written to disk yet —
# the user step runs between this and the execution below. (deferred provisioning never
# reaches here; it's armed by Ctrl+C on the first screen and goes straight to
# disk selection.)
select_installation() { select_installation() {
local full_disk_only local full_disk_only
while true; do while true; do
if requires_full_disk_install; then if requires_full_disk_install; then
full_disk_only=true full_disk_only=true
install_mode="Full disk install"
else else
full_disk_only=false 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 install_mode_form
fi fi
case "$install_mode" in case "$install_mode" in
"Full disk install") "Full disk install")
if confirm_disk_overwrite; then if confirm_disk_overwrite; then
install_target="full_disk"
return 0 return 0
fi fi
if $full_disk_only; then # When the picker was skipped, declining the overwrite means the user
disk_form # wants a different disk.
fi $full_disk_only && disk_form
;; ;;
"Free space install (alongside existing data)") "Free space install"*)
if run_partition_install "$@"; then if run_partition_decide; then
exit 0 install_target="free_space"
return 0
fi fi
;; ;;
"Choose a different disk") "Choose a different disk")
@@ -879,13 +1036,64 @@ select_installation() {
done done
} }
disk_form # STEP 1: KEYBOARD (Ctrl+C here arms deferred provisioning and jumps to disk selection)
select_installation "$@"
defer_provisioning=false
install_target="full_disk"
# Let the live VT reach its settled width before the first screen so the logo
# isn't measured against the transient 80-column boot console.
wait_for_stable_terminal
greeter
keyboard_form true
if $defer_provisioning; then
# Deferred provisioning: no user setup, and the keyboard is deferred to the machine's first
# boot. A defined default keymap so it boots before then; the owner picks
# their real layout in the OOBE, which re-keys the console and, on encrypted
# installs, rebuilds the initramfs so the LUKS prompt matches.
keyboard="us"
username=""
password=""
password_hash=""
full_name=""
email_address=""
# Neutral defaults for the install; the owner overwrites both in the
# first-boot OOBE (omarchy-provision-owner).
hostname="omarchy"
timezone="UTC"
# Jump straight to disk selection + overwrite confirmation (encrypted by
# default, Ctrl+C toggles unencrypted). deferred provisioning is always a full-disk install.
disk_form
until confirm_disk_overwrite; do
disk_form
done
install_target="full_disk"
else
# Normal install: keyboard (done above) → user → disk → install mode. The
# keyboard chosen above is already loaded, so the LUKS passphrase entered in
# the user step is typed under the right layout.
user_step
disk_form
select_installation
fi
clear clear
write_user_files write_user_files
if [[ $install_target == "free_space" ]]; then
run_partition_execute "$@"
if [[ ${1:-} == "dry" ]]; then
print_dry_run_files
fi
exit 0
fi
# Setup partition layout # Setup partition layout
disk_size=$(lsblk -bdno SIZE "$disk") disk_size=$(lsblk -bdno SIZE "$disk")
mib=$((1024 * 1024)) mib=$((1024 * 1024))
@@ -902,14 +1110,21 @@ main_partition_size=$((disk_size_in_mib - main_partition_start - gpt_backup_rese
kernel_choice=$(detect_kernel) kernel_choice=$(detect_kernel)
if [[ $encrypt_installation == true ]]; then if [[ $encrypt_installation == true ]]; then
# Deferred-provisioning installs carry no password: the orchestrator generates a throwaway
# LUKS passphrase and stages it for the first-boot re-key.
if $defer_provisioning; then
encryption_password_line=""
else
encryption_password_line=",
\"encryption_password\": $(echo -n "$password" | jq -Rsa)"
fi
disk_encryption_config=$(cat <<-_EOF_ disk_encryption_config=$(cat <<-_EOF_
, ,
"disk_encryption": { "disk_encryption": {
"encryption_type": "luks", "encryption_type": "luks",
"lvm_volumes": [], "lvm_volumes": [],
"iter_time": 2000, "iter_time": 2000,
"partitions": [ "8c2c2b92-1070-455d-b76a-56263bab24aa" ], "partitions": [ "8c2c2b92-1070-455d-b76a-56263bab24aa" ]$encryption_password_line
"encryption_password": $(echo -n "$password" | jq -Rsa)
} }
_EOF_ _EOF_
) )
@@ -927,6 +1142,7 @@ cat <<-_EOF_ >user_configuration.json
"custom_commands": [], "custom_commands": [],
"omarchy_install": { "omarchy_install": {
"mode": "full_disk", "mode": "full_disk",
"defer_provisioning": $defer_provisioning,
"target_mount": "/mnt", "target_mount": "/mnt",
"boot": { "boot": {
"esp_mount": "/boot", "esp_mount": "/boot",
@@ -11,6 +11,9 @@
# Anything less than the pair means this isn't an autoinstall drive, and the # Anything less than the pair means this isn't an autoinstall drive, and the
# caller falls back to the wizard. # caller falls back to the wizard.
# #
# A `defer-provisioning` marker file stands in for user_credentials.json: deferred-provisioning
# installs defer user creation to first boot, so imaging rigs ship no credentials.
#
# The optional argument prefixes every path the script touches; tests use it to # The optional argument prefixes every path the script touches; tests use it to
# run against a sandbox instead of the live system. # run against a sandbox instead of the live system.
@@ -35,10 +38,20 @@ done
mkdir -p "$mnt" mkdir -p "$mnt"
mount -o ro "$device" "$mnt" || exit 1 mount -o ro "$device" "$mnt" || exit 1
# Clear any inputs a previous load in this live session left in /root before
# copying the current drive: a stale `defer-provisioning` marker or credentials file would
# otherwise force a fresh drive back into deferred provisioning or hand it an old
# LUKS passphrase. The interactive wizard writes these same names, but a
# cidata load always precedes it, so nothing of the user's is discarded here.
optional_inputs=(user_credentials.json defer-provisioning user_full_name.txt user_email_address.txt user_encrypt_installation.txt authorized_keys tailscale_authkey)
for file in user_configuration.json "${optional_inputs[@]}"; do
rm -f "$dest/$file"
done
loaded=1 loaded=1
if [[ -f $mnt/user_configuration.json && -f $mnt/user_credentials.json ]]; then if [[ -f $mnt/user_configuration.json ]] && [[ -f $mnt/user_credentials.json || -f $mnt/defer-provisioning ]]; then
files=("$mnt"/user_configuration.json "$mnt"/user_credentials.json) files=("$mnt"/user_configuration.json)
for file in user_full_name.txt user_email_address.txt user_encrypt_installation.txt authorized_keys; do for file in "${optional_inputs[@]}"; do
[[ -f $mnt/$file ]] && files+=("$mnt/$file") [[ -f $mnt/$file ]] && files+=("$mnt/$file")
done done
if cp "${files[@]}" "$dest"/; then if cp "${files[@]}" "$dest"/; then
@@ -321,11 +321,13 @@ install_progress() {
"Installing Arch + Omarchy") lo=65 hi=700 tau=150 pkg_signal=1 ;; "Installing Arch + Omarchy") lo=65 hi=700 tau=150 pkg_signal=1 ;;
"Configuring hibernation") lo=700 hi=720 tau=10 ;; "Configuring hibernation") lo=700 hi=720 tau=10 ;;
"Configuring system") lo=720 hi=850 tau=120 ;; "Configuring system") lo=720 hi=850 tau=120 ;;
"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 ;; "Finalizing user") lo=950 hi=978 tau=30 ;;
"Configuring login") lo=978 hi=988 tau=5 ;; "Configuring login") lo=978 hi=988 tau=5 ;;
"Configuring DNS resolver") lo=988 hi=994 tau=5 ;; "Configuring DNS resolver") lo=988 hi=994 tau=5 ;;
"Validating boot setup") lo=994 hi=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 # Unknown names used to inherit a 1->99 sweep. Derive an even band from
# the published index instead; with no index, a bounded slice, so a # the published index instead; with no index, a bounded slice, so a
@@ -734,6 +736,17 @@ if (( status != 0 )); then
exit "$failure_status" exit "$failure_status"
fi fi
# Deferred-provisioning installs finish silently and reboot on their own — the
# owner completes setup (and sees the send-off) at first boot, so the "Installed
# Omarchy in ..." celebration and the Reboot Now prompt belong to a direct
# install only.
if [[ ${OMARCHY_UI_DEFER_PROVISIONING:-} == "yes" ]]; then
printf '%s' "$CLEAR" >"$TTY_PATH" 2>/dev/null || true
[[ ${OMARCHY_UI_AUTO_REBOOT:-} == "no" ]] && exit 0
reboot 2>/dev/null || systemctl reboot 2>/dev/null || true
exit 0
fi
set +e set +e
render_finish render_finish
finish_render_status=$? finish_render_status=$?
@@ -17,6 +17,8 @@ while [[ $# -gt 0 ]]; do
--email-file) export OMARCHY_INSTALL_EMAIL_FILE="$2"; shift 2 ;; --email-file) export OMARCHY_INSTALL_EMAIL_FILE="$2"; shift 2 ;;
--encrypt-file) export OMARCHY_INSTALL_ENCRYPT_FILE="$2"; shift 2 ;; --encrypt-file) export OMARCHY_INSTALL_ENCRYPT_FILE="$2"; shift 2 ;;
--authorized-keys-file) export OMARCHY_INSTALL_AUTHORIZED_KEYS_FILE="$2"; shift 2 ;; --authorized-keys-file) export OMARCHY_INSTALL_AUTHORIZED_KEYS_FILE="$2"; shift 2 ;;
--tailscale-authkey-file) export OMARCHY_INSTALL_TAILSCALE_AUTHKEY_FILE="$2"; shift 2 ;;
--defer-provisioning-file) export OMARCHY_INSTALL_DEFER_PROVISIONING_FILE="$2"; shift 2 ;;
*) echo "omarchy-iso-install: unknown arg $1" >&2; exit 2 ;; *) echo "omarchy-iso-install: unknown arg $1" >&2; exit 2 ;;
esac esac
done done
@@ -6,6 +6,7 @@ from __future__ import annotations
import json import json
import os import os
import secrets
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -19,11 +20,13 @@ class InstallContext:
email: str email: str
encrypt: bool encrypt: bool
authorized_keys_path: Path | None authorized_keys_path: Path | None
tailscale_authkey_path: Path | None
user_configuration: dict user_configuration: dict
user_credentials: dict user_credentials: dict
arch_config_path: Path arch_config_path: Path
omarchy_install: dict[str, Any] omarchy_install: dict[str, Any]
defer_provisioning: bool = False
target: Path = Path("/mnt") target: Path = Path("/mnt")
omarchy_path: Path = Path("/usr/share/omarchy") omarchy_path: Path = Path("/usr/share/omarchy")
@@ -47,10 +50,54 @@ class InstallContext:
user_configuration = json.loads(config_path.read_text()) user_configuration = json.loads(config_path.read_text())
omarchy_install = user_configuration.get("omarchy_install") or _default_omarchy_install(user_configuration) omarchy_install = user_configuration.get("omarchy_install") or _default_omarchy_install(user_configuration)
# Deferred provisioning: the whole system installs but user creation is deferred to
# first boot. Selected by the configurator (omarchy_install.defer_provisioning) or by
# an `defer-provisioning` marker file on an autoinstall drive, which also replaces the
# user_credentials.json requirement.
defer_provisioning_marker = _optional_path(os.environ.get("OMARCHY_INSTALL_DEFER_PROVISIONING_FILE"))
defer_provisioning = bool(omarchy_install.get("defer_provisioning")) or defer_provisioning_marker is not None
omarchy_install["defer_provisioning"] = defer_provisioning
if creds_path.exists():
user_credentials = json.loads(creds_path.read_text())
elif defer_provisioning:
user_credentials = {"users": []}
else:
raise RuntimeError(f"credentials file missing: {creds_path}")
if defer_provisioning:
# Deferred provisioning promises no account until first boot. A supplied
# credentials file (a rig handing over its LUKS passphrase) must
# not smuggle in users or a root password.
encryption_password = user_credentials.get("encryption_password")
user_credentials = {"users": []}
if encryption_password:
user_credentials["encryption_password"] = encryption_password
arch_configuration = dict(user_configuration) arch_configuration = dict(user_configuration)
arch_configuration.pop("omarchy_install", None) arch_configuration.pop("omarchy_install", None)
if defer_provisioning:
# The userless invariant covers the archinstall config too: a
# combined/legacy config could carry account authentication that
# archinstall would still act on (users, a root password). Strip
# every such field so no account is created before first boot.
_strip_account_fields(arch_configuration)
state_dir = Path(os.environ.get("OMARCHY_INSTALL_STATE_DIR", "/run/omarchy-install")) state_dir = Path(os.environ.get("OMARCHY_INSTALL_STATE_DIR", "/run/omarchy-install"))
state_dir.mkdir(parents=True, exist_ok=True) state_dir.mkdir(parents=True, exist_ok=True)
if defer_provisioning:
# Encrypted deferred-provisioning installs have no user password to protect LUKS
# with. Generate a throwaway passphrase (staged for first-boot
# re-key by the stage_provisioning_state phase) and hand it to archinstall
# through both places it may look: the disk_encryption block and
# the credentials file.
_inject_provisioning_encryption_password(arch_configuration, user_credentials)
creds_path = state_dir / "provisioning-user_credentials.json"
creds_path.write_text(json.dumps(user_credentials, indent=2) + "\n")
creds_path.chmod(0o600)
arch_config_path = state_dir / "archinstall-user_configuration.json" arch_config_path = state_dir / "archinstall-user_configuration.json"
arch_config_path.write_text(json.dumps(arch_configuration, indent=2) + "\n") arch_config_path.write_text(json.dumps(arch_configuration, indent=2) + "\n")
@@ -61,10 +108,12 @@ class InstallContext:
email=_read_text(os.environ.get("OMARCHY_INSTALL_EMAIL_FILE")), email=_read_text(os.environ.get("OMARCHY_INSTALL_EMAIL_FILE")),
encrypt=_read_text(os.environ.get("OMARCHY_INSTALL_ENCRYPT_FILE")).lower() in ("true", "yes", "1"), encrypt=_read_text(os.environ.get("OMARCHY_INSTALL_ENCRYPT_FILE")).lower() in ("true", "yes", "1"),
authorized_keys_path=_optional_path(os.environ.get("OMARCHY_INSTALL_AUTHORIZED_KEYS_FILE")), authorized_keys_path=_optional_path(os.environ.get("OMARCHY_INSTALL_AUTHORIZED_KEYS_FILE")),
tailscale_authkey_path=_optional_path(os.environ.get("OMARCHY_INSTALL_TAILSCALE_AUTHKEY_FILE")),
user_configuration=user_configuration, user_configuration=user_configuration,
user_credentials=json.loads(creds_path.read_text()), user_credentials=user_credentials,
arch_config_path=arch_config_path, arch_config_path=arch_config_path,
omarchy_install=omarchy_install, omarchy_install=omarchy_install,
defer_provisioning=defer_provisioning,
state_dir=state_dir, state_dir=state_dir,
) )
disk_config = user_configuration.get("disk_config", {}) disk_config = user_configuration.get("disk_config", {})
@@ -75,7 +124,12 @@ class InstallContext:
@property @property
def username(self) -> str: 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 @property
def mode(self) -> str: def mode(self) -> str:
@@ -89,6 +143,38 @@ class InstallContext:
return self.mode == "protected" return self.mode == "protected"
def _strip_account_fields(arch_configuration: dict) -> None:
"""Remove every field archinstall reads to create users or set a root
password, so it cannot ship a hidden provisioning account.
Encryption material lives elsewhere (disk_config.disk_encryption) and is
untouched."""
for key in ("!users", "!root-password", "root_enc_password", "users"):
arch_configuration.pop(key, None)
auth = arch_configuration.get("auth_config")
if isinstance(auth, dict):
for key in ("users", "root_enc_password"):
auth.pop(key, None)
def _inject_provisioning_encryption_password(arch_configuration: dict, user_credentials: dict) -> None:
"""Ensure an encrypted deferred-provisioning install has a LUKS passphrase archinstall can
use. Reuse one the input already carries (an autoinstall rig may supply
its own); otherwise generate a throwaway. Mutates the disk_encryption
block in place — arch_configuration shares it with user_configuration, so
the stage_provisioning_state phase can read the effective passphrase back."""
disk_encryption = (arch_configuration.get("disk_config") or {}).get("disk_encryption")
if disk_encryption is None:
return
password = disk_encryption.get("encryption_password") or user_credentials.get("encryption_password")
if not password:
password = secrets.token_urlsafe(24)
disk_encryption["encryption_password"] = password
user_credentials["encryption_password"] = password
def _default_omarchy_install(user_configuration: dict) -> dict[str, Any]: def _default_omarchy_install(user_configuration: dict) -> dict[str, Any]:
disk_config = user_configuration.get("disk_config", {}) disk_config = user_configuration.get("disk_config", {})
mode = "protected" if disk_config.get("config_type") == "pre_mounted_config" else "full_disk" mode = "protected" if disk_config.get("config_type") == "pre_mounted_config" else "full_disk"
@@ -34,12 +34,15 @@ def build_phases(ctx: InstallContext):
arch_install_system, arch_install_system,
configure_hibernation, configure_hibernation,
run_system_finalizer, run_system_finalizer,
stage_provisioning_state,
finalize_limine_boot, finalize_limine_boot,
run_chroot_finalizer, run_chroot_finalizer,
configure_dns_resolver, configure_dns_resolver,
configure_login, configure_login,
configure_ssh_access, configure_ssh_access,
configure_tailscale,
validate_boot, validate_boot,
create_factory_snapshot,
) )
return [ return [
@@ -48,12 +51,17 @@ def build_phases(ctx: InstallContext):
("Installing Arch + Omarchy", arch_install_system), ("Installing Arch + Omarchy", arch_install_system),
("Configuring hibernation", configure_hibernation), ("Configuring hibernation", configure_hibernation),
("Configuring system", run_system_finalizer), ("Configuring system", run_system_finalizer),
# Before finalize_limine_boot: the deferred-provisioning cryptkey drop-in and keyfile
# must be in place for the final UKI build.
("Staging provisioning", stage_provisioning_state),
("Finalizing Limine boot", finalize_limine_boot), ("Finalizing Limine boot", finalize_limine_boot),
("Finalizing user", run_chroot_finalizer), ("Finalizing user", run_chroot_finalizer),
("Configuring login", configure_login), ("Configuring login", configure_login),
("Configuring SSH access", configure_ssh_access), ("Configuring SSH access", configure_ssh_access),
("Configuring Tailscale", configure_tailscale),
("Configuring DNS resolver", configure_dns_resolver), ("Configuring DNS resolver", configure_dns_resolver),
("Validating boot setup", validate_boot), ("Validating boot setup", validate_boot),
("Creating factory snapshot", create_factory_snapshot),
] ]
@@ -64,7 +72,8 @@ def main() -> int:
error(f"Configuration error: {e}") error(f"Configuration error: {e}")
return 2 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 ( from .phases_impl import (
boost_cpu_governor, boost_cpu_governor,
@@ -13,9 +13,10 @@ Phase ordering (full-disk and protected/pre-mounted):
configure_hibernation → root-owned swap/resume drop-ins configure_hibernation → root-owned swap/resume drop-ins
run_system_finalizer → arch-chroot root omarchy-setup-system, including Snapper run_system_finalizer → arch-chroot root omarchy-setup-system, including Snapper
finalize_limine_boot → final Limine config/UKI build after hardware drop-ins finalize_limine_boot → final Limine config/UKI build after hardware drop-ins
run_chroot_finalizer → arch-chroot -u user omarchy-finalize-user run_chroot_finalizer → arch-chroot -u user omarchy-provision-user
configure_login → sddm state + encrypted-install autologin configure_login → sddm state + encrypted-install autologin
configure_ssh_access → authorized_keys for autoinstall; no-op otherwise configure_ssh_access → authorized_keys for autoinstall; no-op otherwise
configure_tailscale → tailnet join staged for first boot; no-op otherwise
validate_boot → assert UKI / limine.conf / kernel cmdline are sane validate_boot → assert UKI / limine.conf / kernel cmdline are sane
""" """
@@ -290,6 +291,14 @@ def arch_install_system(ctx: InstallContext) -> None:
info("› installing Omarchy runtime + omarchy-base.packages") info("› installing Omarchy runtime + omarchy-base.packages")
installer.add_additional_packages(_runtime_package_list(ctx)) installer.add_additional_packages(_runtime_package_list(ctx))
# Tailscale is bundled in the offline mirror but only installed
# when an autoinstall drive staged an auth key; must happen here,
# while the mirror is still bind-mounted, not in the phase that
# configures the join.
if ctx.tailscale_authkey_path is not None:
info("› installing tailscale (auth key staged for first boot)")
installer.add_additional_packages(["tailscale"])
finally: finally:
_unmask_mkinitcpio_pacman_hooks(ctx) _unmask_mkinitcpio_pacman_hooks(ctx)
_unmount_offline_package_cache(ctx) _unmount_offline_package_cache(ctx)
@@ -997,7 +1006,7 @@ def _debug_run(ctx: InstallContext, cmd: list[str]) -> None:
# 2. bind-mount the offline mirror + /opt/packages into /mnt for target pacman # 2. bind-mount the offline mirror + /opt/packages into /mnt for target pacman
# and bundled language runtimes # and bundled language runtimes
# 3. arch-chroot as root → omarchy-setup-system --first-install # 3. arch-chroot as root → omarchy-setup-system --first-install
# 4. arch-chroot as user → omarchy-finalize-user --first-install # 4. arch-chroot as user → omarchy-provision-user --first-install
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
def _prepare_target_setup(ctx: InstallContext) -> None: def _prepare_target_setup(ctx: InstallContext) -> None:
@@ -1127,16 +1136,142 @@ def _run_target_setup_command(ctx: InstallContext, cmd: list[str], *, user: str
def run_system_finalizer(ctx: InstallContext) -> None: def run_system_finalizer(ctx: InstallContext) -> None:
if ctx.defer_provisioning:
cmd = ["/usr/bin/omarchy-setup-system", "--defer-provisioning", "--first-install"]
else:
cmd = ["/usr/bin/omarchy-setup-system", "--install-user", ctx.username, "--first-install"]
_mask_mkinitcpio_pacman_hooks(ctx, ctx.target, TARGET_DEFERRED_BOOT_HOOKS) _mask_mkinitcpio_pacman_hooks(ctx, ctx.target, TARGET_DEFERRED_BOOT_HOOKS)
try: try:
_run_target_setup_command( _run_target_setup_command(ctx, cmd)
ctx,
["/usr/bin/omarchy-setup-system", "--install-user", ctx.username, "--first-install"],
)
finally: finally:
_unmask_mkinitcpio_pacman_hooks(ctx, ctx.target, TARGET_DEFERRED_BOOT_HOOKS) _unmask_mkinitcpio_pacman_hooks(ctx, ctx.target, TARGET_DEFERRED_BOOT_HOOKS)
# ─────────────────────────────────────────────────────────────────────────────
# stage_provisioning_state: produce the on-disk "provisioning state" the runtime's first-boot
# setup (omarchy-provision-owner) and factory reset (omarchy-system-factory-reset) consume.
#
# Every install stashes the bundled Node tarball in /var/lib/omarchy/provisioning/
# so a later factory reset can finalize the new owner's user offline. deferred-provisioning
# installs additionally arm the first-boot setup service and, on encrypted
# targets, stage the throwaway LUKS passphrase: the keyfile embedded in the
# initramfs auto-unlocks boot during the provisioning window, and first-boot setup
# re-keys the volume to the owner's password and removes it.
#
# Runs before finalize_limine_boot so the cryptkey cmdline drop-in and the
# keyfile land in the final UKI build.
# ─────────────────────────────────────────────────────────────────────────────
PROVISION_STATE_DIR = "var/lib/omarchy/provisioning"
PROVISION_KEYFILE = "etc/omarchy/provisioning.key"
NODE_PACKAGES_DIR = Path("/opt/packages")
def stage_provisioning_state(ctx: InstallContext) -> None:
# World-readable: first-boot finalization reads the Node tarball as the
# new user. The only secret inside (luks-key) is itself 0600 root.
provisioning_dir = ctx.target / PROVISION_STATE_DIR
provisioning_dir.mkdir(parents=True, exist_ok=True)
provisioning_dir.chmod(0o755)
_stage_node_tarball(ctx, provisioning_dir)
if not ctx.defer_provisioning:
return
service_src = ctx.target / "usr/share/omarchy/install/provisioning/omarchy-provision-owner.service"
setup_bin = ctx.target / "usr/bin/omarchy-provision-owner"
if not service_src.exists() or not setup_bin.exists():
raise RuntimeError(
"deferred-provisioning install requested, but the installed Omarchy runtime does not ship "
"first-boot setup (omarchy-provision-owner + install/provisioning/omarchy-provision-owner.service). "
"Update the runtime package this ISO bundles before installing in deferred provisioning."
)
info("› arming first-boot setup")
(provisioning_dir / "pending").touch()
unit_dst = ctx.target / "etc/systemd/system/omarchy-provision-owner.service"
unit_dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(service_src, unit_dst)
wants_dir = ctx.target / "etc/systemd/system/multi-user.target.wants"
wants_dir.mkdir(parents=True, exist_ok=True)
link = wants_dir / "omarchy-provision-owner.service"
link.unlink(missing_ok=True)
link.symlink_to("/etc/systemd/system/omarchy-provision-owner.service")
if _provision_install_encrypted(ctx):
_stage_provisioning_luks_unlock(ctx, provisioning_dir)
def _stage_node_tarball(ctx: InstallContext, provisioning_dir) -> None:
tarballs = sorted(NODE_PACKAGES_DIR.glob("node-v*-linux-x64.tar.gz"))
if not tarballs:
# Hard error on every install, not just deferred-provisioning installs: the stash is what lets a
# later factory reset finalize the next owner offline, and an ISO
# build always bundles the tarball — its absence means a broken build.
raise RuntimeError(
f"no bundled Node tarball in {NODE_PACKAGES_DIR} — first-boot setup "
"and factory reset could not finalize a user offline"
)
packages_dir = provisioning_dir / "packages"
packages_dir.mkdir(parents=True, exist_ok=True)
target_tarball = packages_dir / tarballs[0].name
if not target_tarball.exists():
info("› stashing Node tarball for offline first-boot setup")
shutil.copy2(tarballs[0], target_tarball)
def _provision_install_encrypted(ctx: InstallContext) -> bool:
if _storage_intent(ctx).get("luks_uuid"):
return True
disk_encryption = (ctx.user_configuration.get("disk_config") or {}).get("disk_encryption")
if disk_encryption and disk_encryption.get("encryption_type", "luks") != "no_encryption":
return True
return ctx.encrypt
def _provision_encryption_password(ctx: InstallContext) -> str | None:
disk_encryption = (ctx.user_configuration.get("disk_config") or {}).get("disk_encryption") or {}
return disk_encryption.get("encryption_password") or ctx.user_credentials.get("encryption_password")
def _stage_provisioning_luks_unlock(ctx: InstallContext, provisioning_dir) -> None:
password = _provision_encryption_password(ctx)
if not password:
# Full-disk deferred-provisioning installs get a generated passphrase injected by
# InstallContext; only a pre-mounted (rig-partitioned) LUKS target can
# land here, and it must hand over the passphrase it formatted with.
raise RuntimeError(
"deferred-provisioning install on a pre-encrypted target requires the LUKS passphrase "
"in user_credentials.json (encryption_password) so first boot can re-key"
)
info("› staging LUKS auto-unlock for the provisioning window")
# Byte-for-byte the slot passphrase: no trailing newline anywhere.
luks_key = provisioning_dir / "luks-key"
luks_key.write_text(password)
luks_key.chmod(0o600)
keyfile = ctx.target / PROVISION_KEYFILE
keyfile.parent.mkdir(parents=True, exist_ok=True)
keyfile.write_text(password)
keyfile.chmod(0o600)
cmdline_dropin = ctx.target / "etc/limine-entry-tool.d/99-omarchy-provisioning-unlock.conf"
cmdline_dropin.parent.mkdir(parents=True, exist_ok=True)
cmdline_dropin.write_text(
'KERNEL_CMDLINE[default]+=" cryptkey=rootfs:/etc/omarchy/provisioning.key"\n'
)
files_dropin = ctx.target / "etc/mkinitcpio.conf.d/99-omarchy-provisioning-key.conf"
files_dropin.parent.mkdir(parents=True, exist_ok=True)
files_dropin.write_text("FILES+=(/etc/omarchy/provisioning.key)\n")
def finalize_limine_boot(ctx: InstallContext) -> None: def finalize_limine_boot(ctx: InstallContext) -> None:
"""Finalize Limine after target system setup has written all dynamic """Finalize Limine after target system setup has written all dynamic
boot drop-ins (hibernation, hardware quirks, protected-mode ESP settings). boot drop-ins (hibernation, hardware quirks, protected-mode ESP settings).
@@ -1230,9 +1365,13 @@ def _limine_kernel_cmdline(config_text: str) -> str:
def run_chroot_finalizer(ctx: InstallContext) -> None: def run_chroot_finalizer(ctx: InstallContext) -> None:
if ctx.defer_provisioning:
info("› deferred-provisioning install: user finalization deferred to first boot")
return
_run_target_setup_command( _run_target_setup_command(
ctx, ctx,
["/usr/bin/omarchy-finalize-user", "--force", "--first-install"], ["/usr/bin/omarchy-provision-user", "--force", "--first-install"],
user=ctx.username, user=ctx.username,
) )
@@ -1276,29 +1415,32 @@ def configure_login(ctx: InstallContext) -> None:
) )
autologin_conf = sddm_dir / "autologin.conf" autologin_conf = sddm_dir / "autologin.conf"
if ctx.encrypt: if ctx.encrypt and not ctx.defer_provisioning:
autologin_conf.write_text( autologin_conf.write_text(
"[Autologin]\n" "[Autologin]\n"
f"User={ctx.username}\n" f"User={ctx.username}\n"
"Session=omarchy.desktop\n" "Session=omarchy.desktop\n"
) )
else: else:
# deferred-provisioning installs have no user yet; omarchy-provision-owner writes autologin
# and SDDM state at first boot once the owner exists.
autologin_conf.unlink(missing_ok=True) autologin_conf.unlink(missing_ok=True)
state_dir = ctx.target / "var" / "lib" / "sddm" if not ctx.defer_provisioning:
state_dir.mkdir(parents=True, exist_ok=True) state_dir = ctx.target / "var" / "lib" / "sddm"
(state_dir / "state.conf").write_text( state_dir.mkdir(parents=True, exist_ok=True)
f"[Last]\nSession=omarchy.desktop\nUser={ctx.username}\n" (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 = ctx.target / "etc" / "systemd" / "system" / "getty@tty1.service.d" / "autologin.conf"
autologin.unlink(missing_ok=True) autologin.unlink(missing_ok=True)
subprocess.run(
["arch-chroot", str(ctx.target), "chown", "sddm:sddm",
"/var/lib/sddm", "/var/lib/sddm/state.conf"],
check=False, capture_output=True,
)
subprocess.run( subprocess.run(
["arch-chroot", str(ctx.target), "systemctl", "enable", "sddm.service"], ["arch-chroot", str(ctx.target), "systemctl", "enable", "sddm.service"],
check=False, capture_output=True, check=False, capture_output=True,
@@ -1317,24 +1459,36 @@ def configure_ssh_access(ctx: InstallContext) -> None:
return return
keys = _authorized_keys(ctx.authorized_keys_path) keys = _authorized_keys(ctx.authorized_keys_path)
info(f"› installing {len(keys)} SSH key(s) for {ctx.username}")
ssh_dir = ctx.target / "home" / ctx.username / ".ssh" if ctx.defer_provisioning:
ssh_dir.mkdir(parents=True, exist_ok=True) # No user to authorize yet. Stage the keys in provisioning state for
ssh_dir.chmod(0o700) # 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" ssh_dir = ctx.target / "home" / ctx.username / ".ssh"
authorized_keys.write_text("".join(f"{key}\n" for key in keys)) ssh_dir.mkdir(parents=True, exist_ok=True)
authorized_keys.chmod(0o600) ssh_dir.chmod(0o700)
# Ask the target for the uid rather than assuming the first user is 1000. authorized_keys = ssh_dir / "authorized_keys"
# Uncaptured so a failure shows up in the install log, and checked because authorized_keys.write_text("".join(f"{key}\n" for key in keys))
# a root-owned authorized_keys is one sshd refuses to read. authorized_keys.chmod(0o600)
subprocess.run(
["arch-chroot", str(ctx.target), "chown", "-R", # Ask the target for the uid rather than assuming the first user is 1000.
f"{ctx.username}:{ctx.username}", f"/home/{ctx.username}/.ssh"], # Uncaptured so a failure shows up in the install log, and checked because
check=True, # 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") info("› enabling sshd")
subprocess.run( subprocess.run(
@@ -1381,6 +1535,113 @@ def _authorized_keys(path: Path) -> list[str]:
return keys return keys
# ─────────────────────────────────────────────────────────────────────────────
# configure_tailscale: stage the tailnet join an autoinstall drive asked for.
# `tailscale up` needs a running tailscaled and there is no systemd in the
# chroot, so the install only stages: the key, the enabled services, and a
# oneshot first-boot unit that performs the join once the network is really
# there. The package itself was installed from the offline mirror during
# arch_install_system -- nothing is fetched at boot.
# ─────────────────────────────────────────────────────────────────────────────
TAILSCALE_AUTHKEY_TARGET = "/etc/tailscale/authkey"
# systemd expands $VAR in ExecStart, so the retry loop avoids `$` entirely.
# network-online.target can be reached before there is real connectivity, so
# retry inside the boot -- but NOT as a oneshot: target units implicitly gain
# After= for their Wants=, so a oneshot in multi-user.target holds the whole
# boot (SDDM included) hostage until it finishes. Type=simple counts as
# started the moment it forks, letting boot proceed while the join retries in
# the background for as long as the boot lasts. Cleanup lives inside the
# script because it must only run after a successful join: the key is removed
# and the unit disabled on success, while on a boot with no connectivity both
# survive -- so a machine installed offline joins on the first boot that can.
TAILSCALE_JOIN_UNIT = f"""\
[Unit]
Description=Join the tailnet with the auth key staged by autoinstall
Wants=network-online.target
After=network-online.target tailscaled.service
Requires=tailscaled.service
ConditionPathExists={TAILSCALE_AUTHKEY_TARGET}
[Service]
Type=simple
ExecStart=/usr/bin/sh -c 'until tailscale up --auth-key file:{TAILSCALE_AUTHKEY_TARGET}; do sleep 15; done; rm -f {TAILSCALE_AUTHKEY_TARGET}; systemctl disable omarchy-tailscale-join.service'
[Install]
WantedBy=multi-user.target
"""
def configure_tailscale(ctx: InstallContext) -> None:
if ctx.tailscale_authkey_path is None:
return
key = _tailscale_authkey(ctx.tailscale_authkey_path)
# An ISO built before tailscale was bundled installs nothing, and a staged
# key with no binary would fail silently forever on first boot.
if not (ctx.target / "usr" / "bin" / "tailscale").exists():
raise RuntimeError("tailscale is not installed on the target; this ISO does not bundle it")
info("› staging Tailscale auth key")
ts_dir = ctx.target / "etc" / "tailscale"
ts_dir.mkdir(parents=True, exist_ok=True)
ts_dir.chmod(0o700)
authkey = ts_dir / "authkey"
authkey.write_text(f"{key}\n")
authkey.chmod(0o600)
info("› enabling tailscaled and the first-boot join")
unit = ctx.target / "etc" / "systemd" / "system" / "omarchy-tailscale-join.service"
unit.parent.mkdir(parents=True, exist_ok=True)
unit.write_text(TAILSCALE_JOIN_UNIT)
subprocess.run(
["arch-chroot", str(ctx.target), "systemctl", "enable",
"tailscaled.service", "omarchy-tailscale-join.service"],
check=True,
)
# Same dance as configure_ssh_access: ufw cannot reach netfilter from the
# chroot and exits non-zero, but it records the rule in user.rules first,
# and that file is what ufw.service loads on first boot. Without the rule
# the node joins the tailnet and is then unreachable over it.
info("› allowing tailnet traffic through ufw")
subprocess.run(
["arch-chroot", str(ctx.target), "ufw", "allow", "in", "on", "tailscale0"],
check=False,
)
rules = ctx.target / "etc" / "ufw" / "user.rules"
text = rules.read_text() if rules.exists() else ""
if "-i tailscale0 -j ACCEPT" not in text:
raise RuntimeError(f"ufw did not record an allow rule for tailscale0 in {rules}")
def _tailscale_authkey(path: Path) -> str:
"""Read the autoinstall tailscale_authkey: exactly one key, with blank
lines and # comments dropped. No format validation beyond that -- key
formats are Tailscale's to change.
Raise rather than skip on anything unusable, same reasoning as
_authorized_keys: a machine that "succeeds" into never joining the
tailnet is worse than one that stops with the reason on screen.
"""
try:
lines = path.read_text().splitlines()
except OSError as exc:
raise RuntimeError(f"{path} is not readable: {exc}") from exc
keys = [line.strip() for line in lines]
keys = [key for key in keys if key and not key.startswith("#")]
if not keys:
raise RuntimeError(f"{path} contains no auth key")
if len(keys) > 1:
raise RuntimeError(f"{path} contains {len(keys)} keys; expected exactly one")
return keys[0]
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# validate_boot: hard checks before reboot. If the install ran but produced a # validate_boot: hard checks before reboot. If the install ran but produced a
# boot config or UKI that can't actually boot, halt here rather than surprise # boot config or UKI that can't actually boot, halt here rather than surprise
@@ -1434,6 +1695,34 @@ def validate_boot(ctx: InstallContext) -> None:
if ctx.is_protected: if ctx.is_protected:
_validate_pre_mounted_filesystems(ctx) _validate_pre_mounted_filesystems(ctx)
if ctx.defer_provisioning:
_validate_provisioning_state(ctx)
def _validate_provisioning_state(ctx: InstallContext) -> None:
"""An deferred-provisioning install that boots without a working first-boot setup is a
user-less brick; insist the armed state is complete before reboot."""
provisioning_dir = ctx.target / PROVISION_STATE_DIR
if not (provisioning_dir / "pending").exists():
raise RuntimeError(f"deferred-provisioning install but {provisioning_dir / 'pending'} is missing")
link = ctx.target / "etc/systemd/system/multi-user.target.wants/omarchy-provision-owner.service"
if not link.is_symlink():
raise RuntimeError("deferred-provisioning install but omarchy-provision-owner.service is not enabled")
if not list((provisioning_dir / "packages").glob("node-v*.tar.gz")):
raise RuntimeError(f"deferred-provisioning install but no Node tarball staged in {provisioning_dir / 'packages'}")
if _provision_install_encrypted(ctx):
for required in (provisioning_dir / "luks-key", ctx.target / PROVISION_KEYFILE):
if not required.exists():
raise RuntimeError(f"encrypted deferred-provisioning install but {required} is missing")
limine_conf_text = (
ctx.target / _boot_intent(ctx)["esp_mount"].lstrip("/") / "limine.conf"
).read_text()
if "cryptkey=rootfs:" not in limine_conf_text:
raise RuntimeError("encrypted deferred-provisioning install but limine.conf has no cryptkey= for auto-unlock")
def _assert_boot_hooks_restored(ctx: InstallContext) -> None: def _assert_boot_hooks_restored(ctx: InstallContext) -> None:
"""Never hand over a system whose UKI rebuild hook is still masked. """Never hand over a system whose UKI rebuild hook is still masked.
@@ -1491,6 +1780,90 @@ def _validate_pre_mounted_filesystems(ctx: InstallContext) -> None:
raise RuntimeError(f"{crypttab} missing LUKS UUID {storage['luks_uuid']}") raise RuntimeError(f"{crypttab} missing LUKS UUID {storage['luks_uuid']}")
# ─────────────────────────────────────────────────────────────────────────────
# create_factory_snapshot: read-only snapshot of @ kept at the btrfs top level
# as @factory — outside snapper's .snapshots, so cleanup timers and the Limine
# snapshot menu never touch it. Zero bytes at creation; grows only with drift.
# Taken at the end of every install, it is what makes omarchy-system-factory-reset a
# true factory reset.
# ─────────────────────────────────────────────────────────────────────────────
def create_factory_snapshot(ctx: InstallContext) -> None:
fstype = _findmnt_value(ctx.target, "FSTYPE")
if fstype != "btrfs":
info(f"› target root is {fstype or 'unknown'}, not btrfs; skipping factory snapshot")
return
options = (_findmnt_value(ctx.target, "OPTIONS") or "").split(",")
if not any(opt in ("subvol=/@", "subvol=@") for opt in options):
info("› target root is not the @ subvolume; skipping factory snapshot")
return
device = (_findmnt_value(ctx.target, "SOURCE") or "").split("[")[0]
if not device:
raise RuntimeError(f"could not determine the btrfs device backing {ctx.target}")
top = ctx.state_dir / "factory-top"
top.mkdir(parents=True, exist_ok=True)
subprocess.run(["mount", "-o", "subvolid=5", device, str(top)], check=True)
try:
root_subvol = top / "@"
if not root_subvol.is_dir():
raise RuntimeError(f"no @ subvolume at the top level of {device}")
factory = top / "@factory"
if factory.exists():
subprocess.run(
["btrfs", "subvolume", "delete", str(factory)],
check=True, capture_output=True,
)
info("› snapshotting @ as @factory (read-only)")
subprocess.run(
["btrfs", "subvolume", "snapshot", str(root_subvol), str(factory)],
check=True,
)
_scrub_factory_snapshot(factory)
subprocess.run(
["btrfs", "property", "set", "-ts", str(factory), "ro", "true"],
check=True,
)
finally:
subprocess.run(["umount", str(top)], check=False, capture_output=True)
# Provisioning credentials staged for THIS deployment's first boot must not
# survive into the factory image: a reset years later would otherwise hand the
# next owner the original deployment's SSH keys or rejoin its tailnet, and a
# stale LUKS key (dead after the first re-key) has no business lingering.
# The mkinitcpio/cmdline drop-ins go with the keyfile — a reset rebuild would
# otherwise fail on FILES pointing at a scrubbed path.
FACTORY_SCRUB_PATHS = (
"var/lib/omarchy/provisioning/authorized_keys",
"var/lib/omarchy/provisioning/luks-key",
"etc/omarchy/provisioning.key",
"etc/limine-entry-tool.d/99-omarchy-provisioning-unlock.conf",
"etc/mkinitcpio.conf.d/99-omarchy-provisioning-key.conf",
"etc/tailscale/authkey",
"etc/systemd/system/omarchy-tailscale-join.service",
"etc/systemd/system/multi-user.target.wants/omarchy-tailscale-join.service",
)
def _scrub_factory_snapshot(factory: Path) -> None:
for rel in FACTORY_SCRUB_PATHS:
(factory / rel).unlink(missing_ok=True)
def _findmnt_value(path: Path, column: str) -> str | None:
res = subprocess.run(
["findmnt", "-no", column, str(path)],
capture_output=True, text=True,
)
value = res.stdout.strip()
return value if res.returncode == 0 and value else None
CPU_SYSFS = Path("/sys/devices/system/cpu") CPU_SYSFS = Path("/sys/devices/system/cpu")
+127 -188
View File
@@ -1,224 +1,163 @@
# OEM Install + First-Boot Setup Plan # OEM Install + Factory Reset Plan
## Goal ## Goal
Create a second ISO flavor for OEM preloading that is installed to internal storage at factory time, then boots into customer setup on first power-on (no external media required), and ends in a fully encrypted Omarchy install. An install mode that installs the entire system but defers user account setup to
first boot. On first power-on the machine asks for the user, creates it, and
lands in the already-installed system — the classic OEM out-of-box experience.
The same machinery doubles as a "reset computer" option for wiping a machine
before selling it.
## Product Requirements ## Core idea: "OEM state" as a first-class system state
- Factory can preload one image to internal disk. Define one canonical state — *fully installed, no user, setup pending* — and
- Customer first boot launches setup flow automatically (already works: `.automated_script.sh` runs configurator on tty1). make both paths produce it:
- Customer chooses credentials/settings.
- Final installed OS uses full-disk encryption (LUKS) with customer password (already works: configurator collects password and passes it to archinstall as `encryption_password`).
- Existing consumer ISO behavior remains unchanged.
--- - **ISO OEM mode** ends the install in OEM state instead of creating the user.
- **Reset** returns a running machine to OEM state.
## Chosen Architecture OEM state is: a fully installed system plus `/var/lib/omarchy/oem/` containing:
### Bootstrapping model | File | Purpose |
- OEM devices ship with an OEM bootstrap ISO image written to internal disk. |------|---------|
- On first boot, `copytoram=y` kernel arg loads the live environment into RAM, freeing the internal disk. | `pending` | Flag that arms the first-boot setup service |
- Bootstrap environment launches existing configurator + installer. | `packages/node-v*.tar.gz` | Node tarball copied off the ISO's `/opt/packages` (~30MB) so `omarchy-finalize-user` works offline at first boot |
- Installer performs a fresh install to internal disk with LUKS encryption enabled. | `groups` | Deferred `usermod -aG` groups written by system setup (see below) |
- Bootstrap environment is overwritten during install. | `luks-key` | Throwaway LUKS passphrase for encrypted installs, staged for auto-unlock until first-boot re-key |
### Recovery model: zero-recovery ## Why the seam is nearly clean already
- No permanent recovery partition.
- Simplest and strongest security story.
- Recovery requires external reinstall media.
--- Everything in the orchestrator through `finalize_limine_boot` is
user-independent except five touch points:
## Non-Goals 1. `installer.create_users` — useradd + password (root gets the same password).
2. Three `usermod -aG` calls inside `omarchy-setup-system`'s scripts
(`config/docker.sh`, `hardware/input-group.sh`, `hardware/apple/fix-t2.sh` —
docker/input/video). This is the *only* reason `omarchy-setup-system`
requires the user to exist (its getent check).
3. `run_chroot_finalizer` → `omarchy-finalize-user` as the user. Already
offline-safe: `/etc/skel` does the heavy seeding at useradd time,
`omarchy-mise-install` writes lazy wrappers, and the only external artifact
is the bundled Node tarball from `/opt/packages` (`install/user/mise.sh`).
4. `configure_login` — SDDM `state.conf` and the encrypted-install autologin.
5. Encryption — the user's password *is* the LUKS passphrase.
- No on-device recovery workflow. Nothing in hardware setup, Limine, hibernation, snapper, or package
- No changes to normal installer UX outside explicit OEM mode checks and warnings. installation cares who the user is.
- No factory credential provisioning.
--- ## First boot: `omarchy-oem-setup.service`
## Repo Implementation Plan Ships in the omarchy runtime package (so reset can arm it without the ISO):
## 1) Add OEM build mode - `ConditionPathExists=/var/lib/omarchy/oem/pending`
- `Before=display-manager.service`, conflicts `getty@tty1`, TUI on tty1.
- Runs the user form extracted from the configurator (Step 2: username,
password, full name, email). The configurator is bash+gum, so the form moves
into a runtime-shipped `omarchy-oem-setup` script that reuses the exact ISO
look (logo, Tokyo Night VT palette).
### Files Then it does what the ISO would have done:
- `bin/omarchy-iso-make`
- `builder/build-iso.sh`
### Changes 1. `useradd` with wheel + the groups from `/var/lib/omarchy/oem/groups`,
- Add CLI flag `--oem` to `bin/omarchy-iso-make`. set user + root password.
- Pass `OMARCHY_MODE=oem` into Docker build env (default `normal`). 2. `omarchy-finalize-user` as the user, with a setup context that points at the
- In `builder/build-iso.sh`, write mode marker `/root/omarchy_mode` containing `oem` or `normal` (same pattern as `omarchy_mirror`). stashed Node tarball (a sibling of the existing `iso-chroot` context).
- Keep OEM on current boot stack (`uefi.grub` + `bios.syslinux`); do not add or depend on systemd-boot paths. 3. The `configure_login` writes: SDDM `state.conf`, autologin for encrypted
- When `OMARCHY_MODE=oem`, inject `copytoram=y` into all shipped OEM boot entry points before `mkarchiso` runs: installs.
- `grub/grub.cfg`: append to `linux` cmdline entries. 4. If `luks-key` exists: `cryptsetup luksChangeKey` to the user's password,
- `grub/loopback.cfg`: append to `linux` cmdline entries. delete the stash, remove the auto-unlock keyfile, rebuild initramfs.
- `syslinux/archiso_sys-linux.cfg`: append to `APPEND` lines. 5. Git identity from the form (the `OMARCHY_USER_NAME`/`OMARCHY_USER_EMAIL`
- When `OMARCHY_MODE=oem`, remove accessibility/speakup boot entries: path in `install/user/git.sh`).
- `grub/grub.cfg`: remove `archlinux-accessibility` menuentry. 6. Remove `/var/lib/omarchy/oem/`, hand off to SDDM.
- `grub/loopback.cfg`: remove `archlinux-accessibility` menuentry.
- `syslinux/archiso_sys-linux.cfg`: remove `arch64speech` label block.
- When `OMARCHY_MODE=oem`, override `iso_application` in `profiledef.sh` to `"Omarchy OEM Installer"`.
- Rename output ISO with `-oem` suffix (in addition to existing `-$OMARCHY_ISO_REF` suffix).
### Hardening The user lands in their session exactly like a normal install.
- Add post-injection build assertion that fails build if any expected boot config does not contain `copytoram=y`.
- Add post-injection build assertion that fails build if any OEM boot config still contains accessibility/speakup entries.
- Make injection idempotent (do not duplicate `copytoram=y` if already present).
### Acceptance criteria ## Encryption: keep it, Pop!_OS-style
- `./bin/omarchy-iso-make --oem` builds successfully.
- OEM ISO boot entries contain `copytoram=y` in all supported boot paths.
- OEM ISO does not expose accessibility/speakup boot entries.
- Normal build output and behavior remain unchanged.
### Suggested verification commands Do not force OEM installs unencrypted. Install encrypted with a generated
- `bsdtar -xOf out/<iso>.iso boot/grub/grub.cfg | rg copytoram=y` throwaway passphrase, stage a keyfile so boot auto-unlocks during the OEM
- `bsdtar -xOf out/<iso>.iso boot/grub/loopback.cfg | rg copytoram=y` window, and have first-boot setup re-key to the user's chosen password
- `bsdtar -xOf out/<iso>.iso boot/syslinux/archiso_sys-linux.cfg | rg copytoram=y` (step 4 above). Limine and mkinitcpio don't care what the passphrase is. This
- `bsdtar -xOf out/<iso>.iso boot/grub/grub.cfg | rg 'accessibility|speakup'` (should return no matches) preserves encrypted-by-default without the machine builder ever knowing the end
- `bsdtar -xOf out/<iso>.iso boot/syslinux/archiso_sys-linux.cfg | rg 'speech|accessibility'` (should return no matches) user's password.
--- ## ISO-side changes (this repo)
## 2) Add OEM safety guardrails - **Configurator**: an OEM choice in the install-mode step that skips Step 2
(user), plus a cidata `oem` marker file that replaces the
`user_credentials.json` requirement — that's what actual OEM imaging rigs
will use via autoinstall.
- **Orchestrator**: `oem: true` in the `omarchy_install` JSON. Swaps the three
user phases (`create_users` inside `arch_install_system`,
`run_chroot_finalizer`, the user parts of `configure_login`) for a
"stage OEM state" phase that writes `/var/lib/omarchy/oem/` and enables
`omarchy-oem-setup.service` in the target. `InstallContext.username` needs an
OEM-safe fallback (creds are absent).
- **Factory snapshot**: at the end of *every* install (not just OEM), take a
read-only snapshot of `@` kept at the top level as `@factory` — outside
snapper's `.snapshots`, so cleanup timers and the Limine snapshot menu never
touch it. Zero bytes at creation; grows only with drift. This is what makes
reset a true factory reset.
### Why ## Runtime-side changes (omarchy repo)
OEM image boots from internal disk and later wipes that same disk. Live environment must be fully in RAM before install begins.
### File - `omarchy-oem-setup` + `omarchy-oem-setup.service` (the OOBE above).
- `configs/airootfs/root/.automated_script.sh` - `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.
### Changes ## Reset: `omarchy-system-factory-reset`
- Before `run_configurator`, if `/root/omarchy_mode` is `oem`:
- Verify `/proc/cmdline` contains `copytoram=y`; abort with clear actionable error if missing.
- Verify factory hardware minimum using `MemTotal`:
- Require `MemTotal >= 8 GiB`.
- Abort with clear error if below minimum.
- Verify runtime memory safety using `MemAvailable`:
- Minimum threshold: `1.5 x ISO size + 1 GiB`.
- Abort with clear error if below threshold.
- Verify runtime state indicates live root is in RAM (for example, expected archiso runtime mounts present and boot media not actively mounted as install target path).
### Acceptance criteria Behind a very explicit confirmation (typed, not y/n):
- OEM mode aborts safely if `copytoram=y` missing from cmdline.
- OEM mode aborts safely if `MemTotal < 8 GiB`.
- OEM mode aborts safely if `MemAvailable` below threshold.
- OEM mode aborts safely if runtime state does not confirm RAM-backed live environment.
### Error message requirements 1. Point the default subvolume at a writable snapshot of `@factory`; stage a
- State exactly what failed. next-boot wipe unit.
- State why install is blocked. 2. On reboot: recreate `@home` (subvolume delete/create — instant, no rm -rf),
- Provide next step (reboot using proper OEM media or use external recovery media). clear `@log`, delete snapper snapshots, reset machine-id, SSH host keys,
NetworkManager connections, Tailscale state, SDDM state; run `fstrim`.
3. Re-arm OEM state: `pending` flag, `groups`, Node tarball (kept from install
or re-fetched into `@factory` at install time), and on encrypted machines a
throwaway LUKS re-key — the buyer never needs the seller's passphrase.
--- Next boot is the same OOBE as a fresh OEM install. User-installed packages and
/etc drift are gone, which is what "wipe and sell" actually promises.
## 3) OEM disk warning Machines installed before `@factory` existed get a degraded reset — keep the
current system, wipe users/state only — with that caveat surfaced in the
confirmation.
### Why ### Honest limitations (document these)
With `copytoram=y`, archiso copies squashfs to RAM and releases boot device. Existing configurator call `findmnt -no SOURCE /run/archiso/bootmnt` may return empty, so exclusion filter is skipped and all physical disks are shown, including former boot disk. No disk selection logic change is required.
OEM mode still needs an unmistakable warning before destructive install proceeds. - On unencrypted disks this is deletion, not forensic erasure. `fstrim` helps
on SSDs; offer `cryptsetup reencrypt` or `blkdiscard` + reinstall for the
paranoid.
- `luksChangeKey` changes the passphrase but not the volume key; freed extents
remain readable to someone with raw-device access and the new passphrase.
Same remedy as above.
- A reset machine boots with day-one packages until it updates — same as a
fresh install.
### File ## Sequencing
- `configs/airootfs/root/configurator`
### Changes The OOBE lives in the omarchy runtime package, but the ISO's OEM phase depends
- Read mode from `/root/omarchy_mode`. on it existing. So:
- If mode is `oem`, add OEM-specific warning text before existing destructive confirmation.
- Upgrade confirmation in OEM mode to explicit typed confirmation (for example: `YES, WIPE DISK`) while preserving existing normal-mode flow.
### Acceptance criteria 1. Ship the runtime side first (`omarchy-oem-setup`, service, reset command,
- Normal ISO behavior is unchanged. `omarchy-setup-system` relaxation).
- OEM ISO shows OEM-specific warning before disk wipe confirmation. 2. ISO OEM mode then asserts the service file exists in the target before
- OEM typed confirmation is required before destructive action. offering the mode.
- In VM with `copytoram=y`, former boot disk appears in disk list without exclusion-logic changes. 3. Factory snapshot can land with either half; reset requires both.
--- ## Validation
## 4) Documentation - VM: OEM install (encrypted + unencrypted) → reboot → OOBE → session; verify
LUKS passphrase is the user's, throwaway key gone, groups applied,
### File `omarchy-finalize-user` state marker present.
- `README.md` - VM: normal install → use system → `omarchy-system-factory-reset` → OOBE as new
user; verify no trace of prior user (home, NM connections, tailscale,
### Changes machine-id, host keys, snapper snapshots).
- Document `--oem` build flag and resulting artifact naming. - Autoinstall: cidata drive with `oem` marker and no credentials → unattended
- Document factory workflow: writing OEM ISO to internal disk and expected first-boot UX. OEM install.
- Document failure and recovery behavior: - Negative: OEM ISO built against a runtime package lacking the service fails
- Missing `copytoram=y`, `MemTotal < 8 GiB`, or low `MemAvailable` causes safe abort. the install with a clear error, not a user-less boot loop.
- Interrupted/failed install requires external reinstall media.
- Add short operator checklist for manufacturing line validation.
---
## 5) Validation
### VM test matrix
1. Build OEM ISO.
2. Write OEM ISO to virtual disk (not external media emulation).
3. Boot from virtual disk and verify:
- `/proc/cmdline` contains `copytoram=y`.
- Guardrail checks pass in healthy case.
- OEM boot menu does not include accessibility/speech entries.
4. Complete setup flow on first boot.
5. Install to same disk.
6. Reboot into installed OS.
7. Verify encryption:
- `lsblk` shows crypt mapping.
- `cryptsetup status` reports active LUKS device.
- Boot requires unlock passphrase.
8. Validate OEM warning + typed confirmation appears and gates destructive action.
### Boot path coverage
- UEFI + GRUB path.
- BIOS/syslinux path (if still shipped).
### Hardware test matrix
- UEFI test on at least two hardware families (Intel + AMD).
- Confirm no external media required.
- Confirm first boot starts setup automatically.
### Negative tests
- OEM boot without `copytoram=y` -> safe abort with clear error.
- `MemTotal < 8 GiB` -> safe abort with clear error.
- Low `MemAvailable` -> safe abort with clear error.
- Runtime state not RAM-backed -> safe abort with clear error.
- Power interruption before installer starts -> deterministic reboot behavior.
- Power interruption during install -> recovery requires external media (documented).
---
## 6) Security hardening checklist
- No factory credentials exist; customer sets all passwords at first boot.
- Clear shell history and transient setup artifacts at end of install.
- Verify no secrets in logs shipped to customer.
- Regenerate host identity on first installed boot as needed:
- `machine-id`
- SSH host keys (if installed/enabled)
- Keep package signature verification and keyring handling intact.
---
## Rollout Plan
1. Implement OEM build flag, mode marker, boot config injection, and build assertions.
2. Implement copytoram, memory, and runtime-state guardrails in `.automated_script.sh`.
3. Add OEM warning text and typed destructive confirmation in configurator.
4. Add docs and operator checklist.
5. Run VM matrix, including negative tests.
6. Run hardware matrix.
7. Cut first OEM preproduction release for manufacturing pilot.
---
## Definition of Done
- `--oem` build exists and is documented.
- Device preloaded with OEM image boots directly to setup.
- Setup installs to internal disk without external media.
- Final installed system is LUKS-encrypted with customer-owned passphrase.
- OEM safety guardrails fail closed with actionable errors.
- Existing non-OEM ISO behavior remains unchanged.
- VM and hardware QA matrix pass.
+124
View File
@@ -0,0 +1,124 @@
# Tailscale Autoinstall Plan
## Goal
Let an autoinstall drive carry a Tailscale auth key so the installed machine
joins the tailnet on its own. Combined with `authorized_keys`, this completes
the disposable-VM story: create VM, boot, walk away, `ssh user@machine` over
the tailnet from anywhere — no LAN discovery, no port forwarding.
## Product Requirements
- Same trigger philosophy as the rest of autoinstall: presence of a
`tailscale_authkey` file on the `cidata` drive, no new mode flag. No file,
no change — byte-identical behavior to today.
- The join happens when the network is available, not when the installer runs.
- No package installs from the network on first boot.
- The auth key does not persist on the installed system after a successful join.
---
## Chosen Architecture
### The package is bundled, not fetched
The `tailscale` package rides in the offline mirror (~10MB on the ISO) and is
installed during the ordinary package phase — but only when the auth key file
is present, so a stock install stays stock.
The alternative — installing from the network on first boot — was rejected
outright. A bare `pacman -Sy tailscale` on a fresh machine is a partial
upgrade waiting to corrupt something, and the "correct" version, `pacman -Syu
tailscale`, means an unattended full system upgrade racing the user's first
login (potentially swapping the kernel out on a machine that has booted once).
It would also fight Omarchy's own update flow and make the join depend on
mirror availability. The offline mirror is a coherent snapshot; installing
from it keeps the target consistent and the install deterministic and
offline-capable.
### The join is a first-boot unit, not an install step
`tailscale up` needs a running tailscaled, and there is no systemd in the
chroot. So the installer only stages: the key at `/etc/tailscale/authkey`
(root, `0600`), `tailscaled.service` enabled, and an
`omarchy-tailscale-join.service` that runs `tailscale up --auth-key
file:/etc/tailscale/authkey` on first boot.
"When the network is available" lives in the unit, not the installer:
`After=network-online.target` for ordering, plus an in-unit retry loop
(`until tailscale up …; do sleep 15; done`), because network-online can be
reached before there is real connectivity.
The unit is `Type=simple`, not oneshot, and that is load-bearing: target
units implicitly gain `After=` for their `Wants=`, so a oneshot wanted by
`multi-user.target` holds the whole boot — SDDM included — hostage until the
join finishes or times out, and once timed out it never retries within that
boot. (Caught by review.) Type=simple counts as started the moment it forks:
boot proceeds normally while the join retries in the background for as long
as the boot lasts.
Cleanup is sequenced inside the script, after `tailscale up` succeeds — the
key is removed and the unit disables itself, so the key never outlives the
join and the unit never runs again. On a boot that never gets connectivity
both survive (`ConditionPathExists` gates re-runs on the key), so a machine
installed offline joins on the first boot that can.
The machine appears on the tailnet under the hostname from
`user_configuration.json` — nothing extra to configure.
### Firewall
`ufw allow in on tailscale0`, the same write-the-rule-then-ignore-the-chroot-
exit-code dance as `configure_ssh_access` (ufw cannot reach netfilter in a
chroot but records the rule in `user.rules`, which is what first boot loads).
Without it the node joins and is then unreachable over the tailnet — worse
than not joining.
### Secrets
Same stance the autoinstall plan takes for the LUKS passphrase: whoever
builds the `cidata` drive owns the key on it. Docs recommend a reusable,
pre-authorized (tagged) key so one drive image serves N machines, and note
that ephemeral keys fit disposable VMs. No validation of the key beyond
"exactly one non-comment line" — Tailscale key formats are theirs to change.
---
## Implementation
- `builder/archinstall.packages`: add `tailscale` so it lands in the offline
mirror on every build.
- `omarchy-cidata-load`: add `tailscale_authkey` to the optional copy list.
- `.automated_script.sh`: pass `--tailscale-authkey-file /root/tailscale_authkey`
(always; the orchestrator no-ops when the file is absent, like
`--authorized-keys-file`).
- `omarchy-iso-install`: parse the flag into
`OMARCHY_INSTALL_TAILSCALE_AUTHKEY_FILE`.
- `context.py`: `tailscale_authkey_path: Path | None` via `_optional_path`.
- `phases_impl.py`:
- `arch_install_system`: when the key is present, add `tailscale` to the
additional packages while the offline mirror is still bind-mounted.
- `configure_tailscale(ctx)`: no-op when the path is `None`; otherwise
stage key + unit, enable services, open ufw. Fails loudly on an empty or
ambiguous key file and when the tailscale binary is missing from the
target (an ISO built before the package was bundled).
- `main.py`: register `("Configuring Tailscale", configure_tailscale)` after
`configure_ssh_access`.
- `README.md`: file-table row + a Tailscale paragraph in the Autoinstall
section.
- Tests: `tailscale_authkey` cases in `cidata-load-test.sh`;
`test_configure_tailscale.py` mirroring the SSH phase tests.
## Acceptance criteria
- Drive with `tailscale_authkey`: machine appears on the tailnet under its
configured hostname after first boot with network; `/etc/tailscale/authkey`
is gone; `omarchy-tailscale-join.service` is disabled.
- First boot without network: no failure surfaced to the user; the join
happens on the first boot that has connectivity.
- Drive without the file: no tailscale package on the target, no unit, no
ufw rule — indistinguishable from today.
- Empty or multi-line key file: install stops in the Tailscale phase with a
visible error.
- Interactive installs: unaffected; the flag is passed but the file never
exists.
+55 -1
View File
@@ -89,8 +89,9 @@ echo "Jeff" >"$sandbox/media/user_full_name.txt"
echo "jeff@example.com" >"$sandbox/media/user_email_address.txt" echo "jeff@example.com" >"$sandbox/media/user_email_address.txt"
echo "false" >"$sandbox/media/user_encrypt_installation.txt" echo "false" >"$sandbox/media/user_encrypt_installation.txt"
echo 'ssh-ed25519 AAAA jeff@host' >"$sandbox/media/authorized_keys" echo 'ssh-ed25519 AAAA jeff@host' >"$sandbox/media/authorized_keys"
echo 'tskey-auth-kFAKEKEY' >"$sandbox/media/tailscale_authkey"
run_load || fail "full file set loads" run_load || fail "full file set loads"
for file in user_configuration.json user_credentials.json user_full_name.txt user_email_address.txt user_encrypt_installation.txt authorized_keys; do for file in user_configuration.json user_credentials.json user_full_name.txt user_email_address.txt user_encrypt_installation.txt authorized_keys tailscale_authkey; do
[[ -f $sandbox/root/$file ]] || fail "full file set copies $file" [[ -f $sandbox/root/$file ]] || fail "full file set copies $file"
done done
grep -q '^umount ' "$TEST_LOG" || fail "full file set unmounts the drive" grep -q '^umount ' "$TEST_LOG" || fail "full file set unmounts the drive"
@@ -120,8 +121,61 @@ echo 'ssh-ed25519 AAAA jeff@host' >"$sandbox/media/authorized_keys"
run_load || fail "required pair plus authorized_keys loads" run_load || fail "required pair plus authorized_keys loads"
[[ -f $sandbox/root/authorized_keys ]] || fail "authorized_keys is copied when present" [[ -f $sandbox/root/authorized_keys ]] || fail "authorized_keys is copied when present"
[[ ! -e $sandbox/root/user_full_name.txt ]] || fail "absent optional files are not copied" [[ ! -e $sandbox/root/user_full_name.txt ]] || fail "absent optional files are not copied"
[[ ! -e $sandbox/root/tailscale_authkey ]] || fail "absent tailscale_authkey is not copied"
pass "present optional files are copied, absent ones skipped" pass "present optional files are copied, absent ones skipped"
# A defer-provisioning marker replaces user_credentials.json: deferred-provisioning installs
# creation to first boot, so imaging rigs ship no credentials at all.
new_sandbox
attach_drive cidata
echo '{"disk_config": {}}' >"$sandbox/media/user_configuration.json"
: >"$sandbox/media/defer-provisioning"
run_load || fail "config plus defer-provisioning marker loads"
[[ -f $sandbox/root/defer-provisioning ]] || fail "defer-provisioning marker is copied"
[[ ! -e $sandbox/root/user_credentials.json ]] || fail "defer-provisioning drive copies no credentials"
pass "defer-provisioning marker stands in for credentials"
# The defer-provisioning marker and credentials can coexist (rig supplies its own LUKS
# passphrase in the credentials file); both are copied.
new_sandbox
attach_drive cidata
write_required_pair
: >"$sandbox/media/defer-provisioning"
run_load || fail "defer-provisioning marker plus credentials loads"
[[ -f $sandbox/root/defer-provisioning && -f $sandbox/root/user_credentials.json ]] || fail "both defer-provisioning marker and credentials are copied"
pass "defer-provisioning marker plus credentials copies both"
# An defer-provisioning marker without the configuration is still not an autoinstall drive.
new_sandbox
attach_drive cidata
: >"$sandbox/media/defer-provisioning"
! run_load || fail "defer-provisioning marker alone is not an autoinstall drive"
[[ ! -e $sandbox/root/defer-provisioning ]] || fail "defer-provisioning marker alone copies nothing"
pass "defer-provisioning marker alone falls back to the wizard"
# Stale deferred-provisioning inputs from a previous load in the same session are cleared before
# the current drive is copied: a normal drive must not inherit an old defer-provisioning
# marker or credentials.
new_sandbox
attach_drive cidata
write_required_pair
: >"$sandbox/root/defer-provisioning" # leftover from a prior deferred-provisioning load
echo 'old-keys' >"$sandbox/root/authorized_keys"
run_load || fail "normal drive after a stale defer-provisioning load loads"
[[ ! -e $sandbox/root/defer-provisioning ]] || fail "stale defer-provisioning marker is cleared"
[[ ! -e $sandbox/root/authorized_keys ]] || fail "stale optional inputs are cleared"
pass "stale deferred-provisioning inputs are cleared before loading a normal drive"
# A drive that isn't an autoinstall drive at all still clears stale inputs so
# the wizard that follows doesn't inherit them.
new_sandbox
attach_drive cidata
echo '{"disk_config": {}}' >"$sandbox/media/user_configuration.json" # half a pair
: >"$sandbox/root/defer-provisioning"
! run_load || fail "half-pair drive still falls back"
[[ ! -e $sandbox/root/defer-provisioning ]] || fail "stale defer-provisioning marker cleared even on fallback"
pass "stale inputs are cleared even when falling back to the wizard"
# Half the required pair is not an autoinstall drive: unmount and fall back. # Half the required pair is not an autoinstall drive: unmount and fall back.
new_sandbox new_sandbox
attach_drive cidata attach_drive cidata
+1 -1
View File
@@ -60,7 +60,7 @@ class ConfigureSshAccessTest(unittest.TestCase):
if authorized_keys is not None: if authorized_keys is not None:
authorized_keys_path = self.target / "authorized_keys" authorized_keys_path = self.target / "authorized_keys"
authorized_keys_path.write_text(authorized_keys) authorized_keys_path.write_text(authorized_keys)
return types.SimpleNamespace(target=self.target, username="jeff", authorized_keys_path=authorized_keys_path) return types.SimpleNamespace(target=self.target, username="jeff", authorized_keys_path=authorized_keys_path, defer_provisioning=False)
def configure(self, **kwargs): def configure(self, **kwargs):
phases_impl.configure_ssh_access(self.ctx(**kwargs)) phases_impl.configure_ssh_access(self.ctx(**kwargs))
+164
View File
@@ -0,0 +1,164 @@
"""Unit tests for the orchestrator's configure_tailscale phase.
Same harness as the SSH access tests: the phase touches the target only
through the filesystem and arch-chroot, so it runs against a temp directory
with subprocess.run recorded and the ufw side effect (writing user.rules)
simulated by the fake.
"""
import stat
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"))
# phases_impl imports the archinstall adapter at module scope, which pulls in
# the archinstall library that only exists on the live ISO. configure_tailscale
# never touches it, so stub the adapter out before the import.
sys.modules["orchestrator.archinstall_adapter"] = types.ModuleType("orchestrator.archinstall_adapter")
from orchestrator import phases_impl # noqa: E402
UFW_RULE = "-A ufw-user-input -i tailscale0 -j ACCEPT\n"
class ConfigureTailscaleTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.target = Path(self.tmp.name)
self.calls = []
info_patch = mock.patch.object(phases_impl, "info")
info_patch.start()
self.addCleanup(info_patch.stop)
run_patch = mock.patch.object(phases_impl.subprocess, "run", side_effect=self.fake_run)
run_patch.start()
self.addCleanup(run_patch.stop)
self.ufw_writes_rule = True
def fake_run(self, cmd, **kwargs):
self.calls.append(cmd)
if cmd[2] == "ufw":
if self.ufw_writes_rule:
rules = self.target / "etc" / "ufw" / "user.rules"
rules.parent.mkdir(parents=True, exist_ok=True)
rules.write_text(UFW_RULE)
# ufw cannot reach netfilter inside a chroot and exits non-zero
# even when it recorded the rule; the phase must not trust this.
return CompletedProcess(cmd, 1)
return CompletedProcess(cmd, 0)
def ctx(self, authkey=None, tailscale_installed=True):
authkey_path = None
if authkey is not None:
authkey_path = self.target / "tailscale_authkey"
authkey_path.write_text(authkey)
if tailscale_installed:
binary = self.target / "usr" / "bin" / "tailscale"
binary.parent.mkdir(parents=True, exist_ok=True)
binary.touch()
return types.SimpleNamespace(target=self.target, tailscale_authkey_path=authkey_path)
def configure(self, **kwargs):
phases_impl.configure_tailscale(self.ctx(**kwargs))
def staged_key(self):
return self.target / "etc" / "tailscale" / "authkey"
def unit(self):
return self.target / "etc" / "systemd" / "system" / "omarchy-tailscale-join.service"
def chrooted(self, program):
return [cmd for cmd in self.calls if cmd[:2] == ["arch-chroot", str(self.target)] and cmd[2] == program]
def test_no_authkey_is_a_no_op(self):
self.configure()
self.assertEqual(self.calls, [])
self.assertFalse((self.target / "etc" / "tailscale").exists())
def test_stages_the_key(self):
self.configure(authkey="tskey-auth-kFAKEKEY\n")
self.assertEqual(self.staged_key().read_text(), "tskey-auth-kFAKEKEY\n")
def test_drops_blank_lines_and_comments(self):
self.configure(authkey="# reusable, tagged\n\n tskey-auth-kFAKEKEY \n")
self.assertEqual(self.staged_key().read_text(), "tskey-auth-kFAKEKEY\n")
def test_key_dir_and_file_are_root_private(self):
self.configure(authkey="tskey-auth-kFAKEKEY\n")
self.assertEqual(stat.S_IMODE((self.target / "etc" / "tailscale").stat().st_mode), 0o700)
self.assertEqual(stat.S_IMODE(self.staged_key().stat().st_mode), 0o600)
def test_installs_the_join_unit(self):
self.configure(authkey="tskey-auth-kFAKEKEY\n")
text = self.unit().read_text()
self.assertIn("ConditionPathExists=/etc/tailscale/authkey", text)
# The key must not outlive a successful join, the unit must not run
# again after one, and cleanup must be sequenced after the join
# succeeds -- inside the script, not in ExecStartPost.
self.assertIn(
"ExecStart=/usr/bin/sh -c 'until tailscale up --auth-key file:/etc/tailscale/authkey;"
" do sleep 15; done; rm -f /etc/tailscale/authkey;"
" systemctl disable omarchy-tailscale-join.service'",
text,
)
def test_join_unit_does_not_hold_up_boot(self):
# A Type=oneshot wanted by multi-user.target holds the whole boot
# (SDDM included) hostage until the join finishes or times out --
# target units implicitly gain After= for their Wants=.
self.configure(authkey="tskey-auth-kFAKEKEY\n")
text = self.unit().read_text()
self.assertIn("Type=simple", text)
self.assertNotIn("Type=oneshot", text)
self.assertNotIn("TimeoutStartSec", text)
def test_unit_avoids_systemd_variable_expansion(self):
self.configure(authkey="tskey-auth-kFAKEKEY\n")
self.assertNotIn("$", self.unit().read_text())
def test_enables_tailscaled_and_the_join(self):
self.configure(authkey="tskey-auth-kFAKEKEY\n")
self.assertEqual(self.chrooted("systemctl"), [
["arch-chroot", str(self.target), "systemctl", "enable",
"tailscaled.service", "omarchy-tailscale-join.service"],
])
def test_allows_tailnet_traffic_through_ufw_despite_chroot_exit_status(self):
self.configure(authkey="tskey-auth-kFAKEKEY\n")
self.assertEqual(self.chrooted("ufw"), [
["arch-chroot", str(self.target), "ufw", "allow", "in", "on", "tailscale0"],
])
def test_fails_when_ufw_does_not_record_the_rule(self):
self.ufw_writes_rule = False
with self.assertRaisesRegex(RuntimeError, "allow rule for tailscale0"):
self.configure(authkey="tskey-auth-kFAKEKEY\n")
def test_fails_when_tailscale_is_not_on_the_target(self):
with self.assertRaisesRegex(RuntimeError, "not installed on the target"):
self.configure(authkey="tskey-auth-kFAKEKEY\n", tailscale_installed=False)
def test_empty_file_fails_the_phase(self):
with self.assertRaisesRegex(RuntimeError, "contains no auth key"):
self.configure(authkey="")
def test_comment_only_file_fails_the_phase(self):
with self.assertRaisesRegex(RuntimeError, "contains no auth key"):
self.configure(authkey="# no key here\n")
def test_multiple_keys_fail_the_phase(self):
with self.assertRaisesRegex(RuntimeError, "expected exactly one"):
self.configure(authkey="tskey-auth-kONE\ntskey-auth-kTWO\n")
if __name__ == "__main__":
unittest.main()
+440
View File
@@ -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()