Compare commits

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xc74h4WSYpuYPGuYbFAR1q
2026-08-07 12:54:39 +02:00
Axel Fontaine 4d10233371 Update boot command to use full path (#95) 2026-08-04 13:40:21 -05:00
David Heinemeier Hansson c9d445cf33 Merge branch 'autoinstall' into quattro 2026-08-04 10:00:30 -07:00
David Heinemeier HanssonandClaude Fable 5 f11a70c6ba Take authorized_keys on the cidata drive instead of ssh.json
The JSON wrapper was the one piece of autoinstall input that wasn't an
existing format: the other files are the configurator's own output, and
the keys end up in sshd's authorized_keys anyway. So take that file
directly -- one key per line, comments and key options work as they do
everywhere else -- and drop the parsing in between. The only thing JSON
bought was parse-error detection, and the phase still fails loudly when
the file carries no usable keys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 06:08:33 -07:00
David Heinemeier HanssonandClaude Fable 5 be68a76a44 True up the autoinstall docs
user_encrypt_installation.txt does not encrypt anything: encryption is
driven by the disk_encryption block inside user_configuration.json,
which carries the passphrase in plaintext. The flag must match that
block, since it drives the encrypted install's autologin and the final
boot validation. The plan also claimed sshd ships enabled -- it ships
disabled, which is the whole reason the SSH phase exists -- and now
reflects the extracted loader and the unit tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 05:56:33 -07:00
David Heinemeier HanssonandClaude Fable 5 69ec1b4b8e Add unit tests for cidata loading and the SSH access phase
./test/all runs every suite under test/: the shell tests exercise
omarchy-cidata-load against a sandbox with mount/umount/udevadm stubbed,
and test_configure_ssh_access.py runs the orchestrator phase against a
temp target with subprocess.run recorded and ufw's
write-the-rule-then-fail chroot behavior simulated. The Python tests go
through unittest discovery, which also picks up the repo's existing
test_*.py suites that previously had no runner.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 05:56:33 -07:00
David Heinemeier HanssonandClaude Fable 5 dd5c53cd3c Trust the target to have ufw when opening SSH access
ufw is part of Omarchy's default package set, so the presence check was
guarding a machine that cannot exist -- and if it somehow did, skipping
the firewall would produce an unreachable install, which is exactly what
this phase fails loudly to prevent. The user.rules assertion already
raises in that case. With the check gone, the helper is small enough to
live inline in the phase.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 05:56:33 -07:00
David Heinemeier HanssonandClaude Fable 5 e8a7e3cfce Extract cidata loading into a standalone omarchy-cidata-load command
The loader now settles udev before probing, so a fast boot cannot miss
the drive and silently drop a headless install into the wizard. All the
files come over in a single cp, and a failed copy removes anything it
already copied and falls back to the wizard: the wizard cleans up only
what it writes itself, so a half-copied ssh.json would otherwise leak
into the interactive install that follows. The command takes an optional
path prefix so tests can run it against a sandbox instead of the live
system.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 05:56:33 -07:00
Jeffrey Guenther 7e782bb7cf Document autoinstall
Claude-Session: https://claude.ai/code/session_01Sy3HeoBqb13ahLuaiP2UkM
2026-08-04 05:54:00 -07:00
Jeffrey Guenther 5f601f2c54 Make the installed machine reachable over SSH
An autoinstall drive can carry an ssh.json of public keys. Install them
for the user, enable sshd, and open port 22.

All three are needed: Omarchy ships openssh with the service disabled,
and install/config/firewall.sh opens LocalSend and docker DNS but not
22, so keys alone leave the machine pingable with SSH timing out.

The ufw call exits non-zero from a chroot -- it cannot reach netfilter
-- but writes the rule to user.rules before failing, and that file is
what ufw.service loads on first boot. Check the file rather than the
exit status, since the file is the thing that has to be right.

Unusable key files fail the phase. A machine that installs
"successfully" and then can't be logged into is worse than one that
stops with the reason on screen.

Claude-Session: https://claude.ai/code/session_01Sy3HeoBqb13ahLuaiP2UkM
2026-08-04 05:54:00 -07:00
Jeffrey Guenther b323a4cf08 Skip the dashboard's prompts when nobody is at the keyboard
The dashboard owns the two prompts that outlive an install -- the reboot
confirm and the failure menu -- and both read from the TTY, so both hang
forever on a headless VM. Gate them on OMARCHY_UI_INTERACTIVE here, in
the one process that owns the visible UI, rather than patching prompts
out of the scripts they live in.

The existing OMARCHY_UI_AUTO_REBOOT check still guards the reboot
itself, so setting both stops on the finish screen without a keyboard.

Claude-Session: https://claude.ai/code/session_01Sy3HeoBqb13ahLuaiP2UkM
2026-08-04 05:54:00 -07:00
Jeffrey Guenther a4ec97d174 Autoinstall from a cidata drive instead of the configurator
Look for a drive labeled cidata at startup. If it carries the
configurator's own output files, copy them into /root and skip the
wizard -- everything downstream then runs the ordinary path against
ordinary inputs. No drive, no change.

The required pair matches what the orchestrator treats as mandatory, so
a drive with less than that falls back to the wizard rather than failing
the boot.

Claude-Session: https://claude.ai/code/session_01Sy3HeoBqb13ahLuaiP2UkM
2026-08-04 05:54:00 -07:00
Jeffrey Guenther f5aa88b06c Plan autoinstall against the quattro install pipeline
PR #72 targeted the pre-quattro tree and can't be rebased -- the bash
seams it hooked into are gone. This plans the same feature against the
configurator/dashboard/orchestrator split, and drops the three parts of
that PR that were flagged as questionable.

Claude-Session: https://claude.ai/code/session_01Sy3HeoBqb13ahLuaiP2UkM
2026-08-04 05:54:00 -07:00
David Heinemeier Hansson f66b6fc28e Prune unneeded packages from the ISO mirror cache 2026-08-03 12:22:49 -07:00
17532793fd [INSTALL SPEED] Configure target keyboard without temporary boot (#93)
* Configure target keyboard without temporary boot

* Slim the offline keyboard configuration to what Omarchy consumes

Drop the Xorg 00-keyboard.conf writer: the installed system only reads
the XKB* settings in vconsole.conf (via detect-keyboard-layout.sh, which
copies XKBLAYOUT/XKBVARIANT into Hyprland's kb_layout), and nothing on
an Omarchy system reads xorg.conf.d. systemd-firstboot already writes
those vconsole settings.

Drop the set_keyboard_language monkey-patch: passing an empty kb_layout
to minimal_installation takes archinstall's documented skip path (no
nspawn boot), and the keymap is configured offline right after instead.

Append the preserved FONT= line instead of reproducing localectl's exact
file layout, and slim the tests to the behavior that matters: XKB
settings present, font preserved, every configurator keymap valid, and
archinstall-compatible invalid/empty handling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 20:00:56 -05:00
N1R4 0b4d74f6af Filter phantom entries when rewriting EFI BootOrder (#94)
The Limine registration helper rebuilds the UEFI BootOrder from the
pre-state read, but passes every entry through verbatim. Some firmware
(listed in the BootOrder but with no matching BootXXXX variable) causes
efibootmgr --bootorder to fail with exit status 8, aborting the whole
install at the bootloader step.

Only carry forward entries that still exist as boot variables.
2026-08-01 13:07:09 -05:00
David Heinemeier HanssonandClaude Opus 5 a76f599eaa Drop the Qt5 stack from the recorded manifests
basecamp/omarchy#6431 takes kvantum-qt5 and qt5-wayland out of
omarchy-base.packages, and the rest of the stack follows them out of a
fresh install: qt5-base, qt5-declarative, qt5-svg, qt5-translations and
qt5-x11extras. kvantum stays put, moving from dependency to explicit now
that the package list names it directly instead of reaching it through
kvantum-qt5.

Hand-applied rather than re-recorded, so everything else in both files
still reflects the 2026-05-25 capture.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 11:14:40 -07:00
Ryan Hughes 64152dda3f Always makes sure the docker container is updated 2026-07-29 12:25:53 -04:00
Ryan Hughes 56eb451b92 Merge pull request #86 from omacom-io/fix-t2-kernel
Boot the T2 kernel the ISO says it boots
2026-07-28 15:52:12 -04:00
d64ffb0e00 Fix the nightly ISO build
The workflow has been running ./build_iso.sh, which has not existed for a long
time — the build moved to bin/omarchy-iso-make. Every scheduled run in the last
100 has failed.

Run the real build driver instead, with NO_BOOT_OFFER=1 so it skips the
interactive "Boot this ISO?" prompt at the end, and collect from ./release/
where the ISO actually lands. Drop the outer archlinux container: omarchy-iso-make
starts its own privileged container, so the job only needs Docker on the runner.
Add a disk cleanup step, since a ~6.5GB ISO plus a ~4.5GB offline mirror does not
fit in a stock runner's free space.

Taken from #67 by @karthikkalidas, which was opened against main. main is 143
commits behind and is no longer the default branch, and scheduled workflows only
run from the default branch, so merging it there would have fixed nothing.

Co-Authored-By: Karthik Kalidas <karthik.kalidas@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 08:50:02 -07:00
5b626d2a92 Update the copyright year
From #61 by @LukaMarinkic, applied to quattro; it was opened against main.

Co-Authored-By: LukaMarinkic <147918748+LukaMarinkic@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 08:43:43 -07:00
3efdb5c94b Reject reserved system usernames in the configurator
The username regex accepted names that already exist on the target, so
useradd failed inside arch_install_system — after the disk had been
partitioned and formatted, which is the worst point to discover it.

From #59 by @wolfgangw, applied to quattro; it was opened against main.

Co-Authored-By: Wolfgang Woehl <tito@online.de>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 08:43:43 -07:00
b3cf0c6f29 Run the live CPUs at the performance governor during install (#89)
archiso boots on whatever governor the kernel defaults to, which on most
laptops ramps conservatively. Package extraction and the UKI build are both
CPU-bound, so pin performance for the whole install and put it back afterwards.

Reduced from #87 by @davydotcom, which also disabled every non-POLL cpuidle
state. That half is left out deliberately: C1 wake latency is 1us on current
hardware, and disabling it leaves POLL as the only idle state, so idle cores
busy-spin instead of halting. Package extraction is single-threaded, so on an
8-core laptop that is 7 cores burning power for no work — on a thermally
limited machine that costs the working core its turbo headroom, which is a
plausible way to make the install slower. #87 measured the two tweaks together
with a page-cache prefetch and an updatedb deferral, so the C-state
contribution was never isolated.

Scoped to the whole install rather than just the pacstrap window, which #87
used: finalize_limine_boot runs mkinitcpio and compresses a UKI, and is the
most purely CPU-bound phase there is.

Restoring only matters when an install fails and the user keeps using the live
environment; a successful one reboots out of it.

Co-authored-by: David Estes <davydotcom@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 08:24:13 -07:00
David Heinemeier HanssonandClaude Opus 5 4f60a18c87 Drive the install bar from packages, not from a stopwatch
install_progress() mapped each phase to a start%, an end% and a hard-coded
"expected seconds", then interpolated on elapsed time. Those seconds were
calibrated on one machine, so the bar was only ever honest on that machine.
Measured against a real 69s install on an XPS 13, the table expected 145s: 95s
for a phase that took 58.6s, 10s for one that took 2.6s. On slow media it is
wrong the other way — the bar reaches each phase's ceiling in a fraction of the
real time and then sits there. Today's clamp turns that into a 386-second freeze
at 85% on a USB2 install.

Phases do not even scale together: package installation is bound by media read
and disk write, the UKI rebuild is CPU and compression bound. No single seconds
table can be right for both.

So stop predicting durations. Position is now max(floor, work), both
non-decreasing:

  floor  an asymptote over the phase's band, on time since this process first
         saw the phase. tau is a shape constant, not a prediction — wrong by 3x
         it changes the pace of a crawl, never the correctness.
  work   only for "Installing Arch + Omarchy", which is ~85% of the install.
         libalpm creates one directory per package under
         <target>/var/lib/pacman/local, so counting them counts finished work.
         The denominator is resolved at ISO build time by pacman's own resolver
         against the offline mirror, so there is no second package list to
         drift. phases.py records expected and actual in the timing JSON on
         every install, so drift shows up in acceptance runs.

There is deliberately no give-up predicate: two discontinuous curves selected by
a toggle, behind a monotonic clamp, is a freeze generator. If the signal never
appears — no denominator, wrong target, implausible count — the floor alone
still walks the band to its end.

Position moves to per-mille. That is load-bearing, not precision theatre: these
are asymptotes, and in whole percent an asymptote over a 2-point band has two
reachable values and stops moving within seconds.

Time now comes from /proc/uptime. The live ISO runs tzupdate and
systemd-timesyncd, and the old code read EPOCHSECONDS, so a clock step
mid-install jumped every band straight to its ceiling.

Also fixes drift the old table had accumulated: it still weighted five phases
that no longer exist and had no entry at all for "Preparing install target",
which fell through to a 1->99 sweep. Unknown names now derive an even band from
the index the orchestrator already publishes.

bin/omarchy-iso-installer fakes a pacman database so the preview exercises the
package path instead of the fallback; PREVIEW_PACKAGES tunes the count.

Verified by simulation against the real logic: a 69s NVMe install, a 6-minute
USB2 install with a 70s pre-extraction stall, an implausible denominator, an
unknown phase name, and a target that already holds packages. All monotonic, all
reach 100%. Not yet run on real hardware.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 07:54:05 -07:00
30 changed files with 1944 additions and 197 deletions

No files matched your search

+26 -32
View File
@@ -2,40 +2,34 @@ name: Nightly ISO Build
on: on:
schedule: schedule:
- cron: '1 0 * * *' # Daily at 00:01 UTC - cron: "1 0 * * *" # Daily at 00:01 UTC
workflow_dispatch: # Allow manual triggering workflow_dispatch: # Allow manual triggering
jobs: jobs:
build-iso: build-iso:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container:
image: archlinux/archlinux:latest
options: --privileged
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Build ISO in Arch Linux container - name: Free Disk Space
run: | uses: jlumbroso/free-disk-space@v1.3.1
docker run --rm \
--privileged \ - name: Build ISO
-v "${{ github.workspace }}:/workspace" \ run: NO_BOOT_OFFER=1 ./bin/omarchy-iso-make
-w /workspace \
archlinux:latest \ - name: Delete previously uploaded ISO artifact
bash -c " uses: philips-labs/action-delete-artifacts@v1.0.0
pacman -Syu --noconfirm && \ with:
chmod +x build_iso.sh && \ workflow: nightly-build.yml
./build_iso.sh continue-on-error: true
"
- name: Upload newly built ISO artifact
- name: Upload ISO artifact uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v4 with:
with: name: omarchy-iso-${{ github.run_number }}
name: omarchy-iso-${{ github.run_number }} path: ./release/*.iso
path: out/*.iso retention-days: 1
retention-days: 30
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2025 Anton Hvornum Copyright (c) 2026 Anton Hvornum
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
+64
View File
@@ -20,10 +20,74 @@ Despite the local folder name, the first argument is the Omarchy source checkout
Use `--dev` or `--rc` to build against those package channels. Both `--dev` and `--edge` select the dev packages from the edge mirror. Use `--dev` or `--rc` to build against those package channels. Both `--dev` and `--edge` select the dev packages from the edge mirror.
## Autoinstall
The shipped ISO installs itself with no keyboard when it finds its configuration on a second drive. Attach a drive labeled `cidata` alongside the ISO and the installer copies the config off it and skips the configurator; with no such drive, nothing changes and the wizard runs as usual. No rebuild, no extra boot entry.
`cidata` is the cloud-init `NoCloud` label, so Proxmox, libvirt, and Packer already know how to attach one.
### Configuration files
These are the configurator's own output files, so the way to get a starting set is to run one interactive install and copy what it wrote into `/root`.
| File | Required | Purpose |
|------|----------|---------|
| `user_configuration.json` | Yes | archinstall config: disk, hostname, timezone, keyboard |
| `user_credentials.json` | Yes | Username and password hash |
| `user_full_name.txt` | No | Git full name |
| `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 |
| `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"`.
Encryption itself is configured by the `disk_encryption` block inside `user_configuration.json` — which carries the passphrase in plaintext, so treat a drive built from an encrypted install accordingly. The flag file must match it: it drives the encrypted install's SDDM autologin and the final boot validation, not the encryption.
`authorized_keys` is the same file sshd reads — copy your own or write one key per line:
```
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 `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
```bash
mkdir cidata
cp user_configuration.json user_credentials.json authorized_keys cidata/
genisoimage -output cidata.iso -volid cidata -joliet -rock cidata/
```
### Proxmox example
```bash
qm create 101 --name my-omarchy \
--bios ovmf --machine q35 --cpu host --cores 4 --memory 8192 \
--ostype l26 --scsihw virtio-scsi-single \
--efidisk0 local-lvm:0,efitype=4m,pre-enrolled-keys=0 \
--scsi0 local-lvm:40,discard=on,iothread=1 \
--net0 virtio,bridge=vmbr0 --vga virtio --serial0 socket \
--ide2 local:iso/omarchy.iso,media=cdrom \
--ide3 local:iso/cidata.iso,media=cdrom \
--boot order='scsi0;ide2'
qm start 101
```
Boot order is disk first: the empty disk falls through to the ISO on the first boot, and the installed system boots from disk afterwards. The machine reboots into Omarchy on its own when the install finishes.
Encrypted autoinstalls are not fully unattended — the LUKS passphrase prompt still needs someone at the first boot.
## Testing the ISO ## Testing the ISO
Run `./bin/omarchy-iso-boot [release/omarchy.iso]`. Run `./bin/omarchy-iso-boot [release/omarchy.iso]`.
Run `./test/all` for the unit tests, which cover cidata autoinstall loading and the orchestrator's SSH access phase without needing a built ISO.
To exercise installation alongside existing Windows-style partitions, run To exercise installation alongside existing Windows-style partitions, run
`./bin/omarchy-iso-test-windows-disk [release/omarchy.iso]`. It creates a `./bin/omarchy-iso-test-windows-disk [release/omarchy.iso]`. It creates a
synthetic disk in `/tmp` with an existing ESP and data partition plus ample synthetic disk in `/tmp` with an existing ESP and data partition plus ample
+52 -6
View File
@@ -2,49 +2,89 @@
# Preview the shipped Omarchy installer dashboard without building an ISO. # Preview the shipped Omarchy installer dashboard without building an ISO.
set -euo pipefail set -euo pipefail
# The dashboard drives the package phase from the target's pacman database, so
# fake one here; without it the preview falls back to its time curve and the bar
# barely moves, which reads as a bug.
PREVIEW_PACKAGES="${PREVIEW_PACKAGES:-925}"
write_simulation_state() { write_simulation_state() {
local state_file="$1" started_at="$2" phase_started_at="$3" current_phase="$4" local state_file="$1" started_at="$2" phase_started_at="$3" current_phase="$4"
local finished_at="${5:-null}" temporary="${state_file}.tmp" local target="$5" index="$6" total="$7"
local finished_at="${8:-null}" temporary="${state_file}.tmp"
jq -n \ jq -n \
--argjson started_at "$started_at" \ --argjson started_at "$started_at" \
--argjson phase_started_at "$phase_started_at" \ --argjson phase_started_at "$phase_started_at" \
--arg current_phase "$current_phase" \ --arg current_phase "$current_phase" \
--arg target "$target" \
--argjson current_index "$index" \
--argjson total_phases "$total" \
--argjson finished_at "$finished_at" \ --argjson finished_at "$finished_at" \
'{ '{
started_at: $started_at, started_at: $started_at,
phase_started_at: $phase_started_at, phase_started_at: $phase_started_at,
current_phase: $current_phase current_phase: $current_phase,
target: $target,
current_index: $current_index,
total_phases: $total_phases
} + if $finished_at == null then {} else {finished_at: $finished_at} end' \ } + if $finished_at == null then {} else {finished_at: $finished_at} end' \
>"$temporary" >"$temporary"
mv -f -- "$temporary" "$state_file" mv -f -- "$temporary" "$state_file"
} }
# Fill the fake database over the phase's duration, so the bar is driven by the
# same count it uses on a real install.
simulate_packages() {
local db="$1" duration="$2" total="$3" steps=25 i n
for ((i = 1; i <= steps; i++)); do
n=$(( total * i / steps ))
while (( $(ls -1 "$db" 2>/dev/null | wc -l) < n )); do
mkdir -p "$db/preview-$(( $(ls -1 "$db" 2>/dev/null | wc -l) ))-1.0-1"
done
sleep "$(awk -v d="$duration" -v s="$steps" 'BEGIN { printf "%.3f", d / s }')"
done
}
simulate_install() { simulate_install() {
local state_file="$1" local state_file="$1"
local started_at="$EPOCHREALTIME" local started_at="$EPOCHREALTIME"
local phase phase_started_at finished_at duration i local phase phase_started_at finished_at duration i index total
local target db
target="$(dirname -- "$state_file")/target"
db="$target/var/lib/pacman/local"
mkdir -p "$db"
local -a phases=( local -a phases=(
"Starting installation" 0.5 "Starting installation" 0.5
"Preparing live environment" 0.8 "Preparing live environment" 0.8
"Preparing install target" 0.3
"Installing Arch + Omarchy" 5.0 "Installing Arch + Omarchy" 5.0
"Configuring hibernation" 0.4
"Configuring system" 1.2 "Configuring system" 1.2
"Finalizing Limine boot" 1.0 "Finalizing Limine boot" 1.0
"Finalizing user" 1.0 "Finalizing user" 1.0
"Configuring login" 0.6 "Configuring login" 0.6
"Configuring DNS resolver" 0.3
"Validating boot setup" 0.8 "Validating boot setup" 0.8
) )
total=$(( ${#phases[@]} / 2 ))
for ((i = 0; i < ${#phases[@]}; i += 2)); do for ((i = 0; i < ${#phases[@]}; i += 2)); do
phase="${phases[i]}" phase="${phases[i]}"
duration="${phases[i + 1]}" duration="${phases[i + 1]}"
index=$(( i / 2 ))
phase_started_at="$EPOCHREALTIME" phase_started_at="$EPOCHREALTIME"
write_simulation_state "$state_file" "$started_at" "$phase_started_at" "$phase" write_simulation_state "$state_file" "$started_at" "$phase_started_at" \
sleep "$duration" "$phase" "$target" "$index" "$total"
if [[ $phase == "Installing Arch + Omarchy" ]]; then
simulate_packages "$db" "$duration" "$PREVIEW_PACKAGES"
else
sleep "$duration"
fi
done done
finished_at="$EPOCHREALTIME" finished_at="$EPOCHREALTIME"
write_simulation_state "$state_file" "$started_at" "$finished_at" "Installation complete" "$finished_at" write_simulation_state "$state_file" "$started_at" "$finished_at" \
"Installation complete" "$target" "$(( total - 1 ))" "$total" "$finished_at"
sleep 0.3 sleep 0.3
} }
@@ -91,6 +131,12 @@ log_file="$preview_dir/install.log"
state_file="$preview_dir/state.json" state_file="$preview_dir/state.json"
touch "$log_file" touch "$log_file"
# Match what the simulated database will reach, so the preview shows the
# package-driven bar rather than the missing-signal fallback.
printf '%s\n' "$PREVIEW_PACKAGES" >"$preview_dir/expected-packages"
OMARCHY_UI_AUTO_REBOOT=no \ OMARCHY_UI_AUTO_REBOOT=no \
OMARCHY_EXPECTED_PACKAGES_FILE="$preview_dir/expected-packages" \
PREVIEW_PACKAGES="$PREVIEW_PACKAGES" \
"$dashboard" "$log_file" "$state_file" -- \ "$dashboard" "$log_file" "$state_file" -- \
"$0" --simulate-install "$state_file" "$0" --simulate-install "$state_file"
+5 -1
View File
@@ -143,6 +143,10 @@ if [[ -z "$NO_CACHE" ]]; then
OFFLINE_REPO_BUILD_CACHE_DIR="$HOME/.cache/omarchy/iso_${OMARCHY_MIRROR}/airootfs/var/cache/omarchy" OFFLINE_REPO_BUILD_CACHE_DIR="$HOME/.cache/omarchy/iso_${OMARCHY_MIRROR}/airootfs/var/cache/omarchy"
mkdir -p "$OFFLINE_REPO_BUILD_CACHE_DIR" mkdir -p "$OFFLINE_REPO_BUILD_CACHE_DIR"
DOCKER_ARGS+=(-v "$OFFLINE_REPO_BUILD_CACHE_DIR:/var/cache/airootfs/var/cache/omarchy") DOCKER_ARGS+=(-v "$OFFLINE_REPO_BUILD_CACHE_DIR:/var/cache/airootfs/var/cache/omarchy")
else
# The locally cached :latest image is a cache too — docker run never
# re-checks the registry for it, so refresh it when building cold.
DOCKER_ARGS+=(--pull=always)
fi fi
docker run "${DOCKER_ARGS[@]}" archlinux/archlinux:latest /$BUILD_SCRIPT docker run "${DOCKER_ARGS[@]}" archlinux/archlinux:latest /$BUILD_SCRIPT
@@ -153,5 +157,5 @@ mv -f "$latest_iso" "$iso_ref"
if [[ -z "${NO_BOOT_OFFER:-}" ]]; then if [[ -z "${NO_BOOT_OFFER:-}" ]]; then
echo echo
gum confirm "Boot $iso_ref?" && omarchy-iso-boot "$iso_ref" gum confirm "Boot $iso_ref?" && "$BUILD_ROOT/bin/omarchy-iso-boot" "$iso_ref"
fi fi
+1
View File
@@ -12,3 +12,4 @@ openssh
pipewire pipewire
snapper snapper
sof-firmware sof-firmware
tailscale
+108 -39
View File
@@ -25,7 +25,11 @@ export OMARCHY_RUNTIME_PACKAGE OMARCHY_SETTINGS_PACKAGE OMARCHY_NVIM_PACKAGE
# Packages installed into the Arch container used to build the ISO. # Packages installed into the Arch container used to build the ISO.
pacman-key --init pacman-key --init
pacman --noconfirm -Sy archlinux-keyring pacman --noconfirm -Sy archlinux-keyring
pacman --noconfirm -Sy archiso git sudo base-devel jq grub imagemagick neovim nodejs npm tree-sitter-cli # Full upgrade, not just -Sy: docker never re-pulls :latest once it's cached,
# so this container can be months behind the mirror it installs from. A plain
# -Sy install is then a partial upgrade — new packages linked against a glibc
# the container doesn't have yet.
pacman --noconfirm -Syu archiso git sudo base-devel jq grub imagemagick neovim nodejs npm tree-sitter-cli
# Pre-import the omarchy signing key (so pacman trusts our [omarchy] repo # Pre-import the omarchy signing key (so pacman trusts our [omarchy] repo
# during the build without keyserver lookups). # during the build without keyserver lookups).
@@ -197,48 +201,49 @@ if ! download_offline_packages; then
download_offline_packages download_offline_packages
fi fi
prune_stale_package_versions() { # Resolve the exact filenames chosen by the same synced package databases used
local dir="$1" # for the download. Pruning by this transaction (rather than merely keeping the
local pkgfile name version cmp # newest version of every cached package name) removes packages that have left
local -a stale=() # the lists or dependency closure, such as an old Electron major version.
declare -A newest_file=() if ! resolved_package_files="$(
declare -A newest_version=() pacman --config "/configs/pacman-online-${OMARCHY_MIRROR}.conf" --noconfirm \
--dbpath /tmp/offlinedb -S --print --print-format '%f' "${all_packages[@]}"
)"; then
echo "ERROR: could not resolve the package files required by the offline mirror" >&2
exit 1
fi
mapfile -t required_package_files <<< "$resolved_package_files"
while IFS= read -r pkgfile; do # The online transaction intentionally excludes packages built from the local
read -r name version < <(pacman -Qp "$pkgfile" 2>/dev/null) || continue # checkouts. Add those exact artifacts back to the keep-set after verifying
[[ -n $name && -n $version ]] || continue # that the local build left exactly one file for each selected package name.
if [[ -n ${LOCAL_OMARCHY_BUILD:-} ]]; then
if [[ -z ${newest_version[$name]+x} ]]; then for local_package_name in \
newest_version[$name]="$version" "$OMARCHY_RUNTIME_PACKAGE" "$OMARCHY_SETTINGS_PACKAGE" "$OMARCHY_NVIM_PACKAGE"; do
newest_file[$name]="$pkgfile" local_package_file=""
continue for candidate in "$offline_mirror_dir/$local_package_name-"*.pkg.tar.*; do
fi [[ -f $candidate && $candidate != *.sig ]] || continue
read -r candidate_name _ < <(pacman -Qp "$candidate" 2>/dev/null) || continue
cmp=$(vercmp "$version" "${newest_version[$name]}") [[ $candidate_name == "$local_package_name" ]] || continue
if (( cmp > 0 )); then if [[ -n $local_package_file ]]; then
stale+=("${newest_file[$name]}") echo "ERROR: multiple local builds found for $local_package_name" >&2
newest_version[$name]="$version" exit 1
newest_file[$name]="$pkgfile" fi
else local_package_file="${candidate##*/}"
stale+=("$pkgfile")
fi
done < <(find "$dir" -maxdepth 1 -type f -name '*.pkg.tar.*' ! -name '*.sig' -print | sort)
if (( ${#stale[@]} > 0 )); then
echo "Pruning stale package versions from offline mirror:"
for pkgfile in "${stale[@]}"; do
echo " $(basename "$pkgfile")"
rm -f "$pkgfile" "$pkgfile.sig"
done done
fi if [[ -z $local_package_file ]]; then
} echo "ERROR: local build not found for $local_package_name" >&2
exit 1
fi
required_package_files+=("$local_package_file")
done
fi
printf '%s\n' "${required_package_files[@]}" |
bash /builder/prune-offline-mirror.sh "$offline_mirror_dir"
# Rebuild the offline repo db from scratch so size/checksum/depends entries # Rebuild the offline repo db from scratch so size/checksum/depends entries
# always reflect the current package files. The persistent cache can contain # always reflect only the package files selected for this build.
# multiple versions of the same package; repo-add keeps the last one it sees,
# which is lexical glob order, not version order (e.g. pkgrel -9 can override
# -15). Prune to one newest version per package before indexing.
prune_stale_package_versions "$offline_mirror_dir"
rm -f "$offline_mirror_dir"/offline.db* "$offline_mirror_dir"/offline.files* rm -f "$offline_mirror_dir"/offline.db* "$offline_mirror_dir"/offline.files*
repo-add "$offline_mirror_dir/offline.db.tar.gz" "$offline_mirror_dir/"*.pkg.tar.zst repo-add "$offline_mirror_dir/offline.db.tar.gz" "$offline_mirror_dir/"*.pkg.tar.zst
@@ -247,6 +252,70 @@ repo-add "$offline_mirror_dir/offline.db.tar.gz" "$offline_mirror_dir/"*.pkg.tar
mkdir -p /var/cache/omarchy/mirror mkdir -p /var/cache/omarchy/mirror
ln -sf "$offline_mirror_dir" /var/cache/omarchy/mirror/offline ln -sf "$offline_mirror_dir" /var/cache/omarchy/mirror/offline
# Denominator for the install dashboard's progress bar. Resolving the mirror's
# own package lists against the mirror we just indexed, with an empty local db,
# is the question pacstrap asks at install time — same resolver, same repo, same
# lists — so no hand-kept constant can drift.
#
# It over-counts by ~1 in 925: archinstall.packages lists both amd-ucode and
# intel-ucode because the mirror must contain either. phases.py records expected
# and actual in the timing JSON, so growing drift shows up in acceptance runs.
# The early-bootstrap set is already inside this closure, so restating it would
# only add a second list to drift.
resolve_expected_packages() {
local resolve_root=/tmp/omarchy-expected-packages
local resolved
local -a targets
rm -rf "$resolve_root"
mkdir -p "$resolve_root/var/lib/pacman"
mapfile -t targets < <(
{
grep -hv '^#\|^$' /builder/archinstall.packages
# Read the shipped copy, which is what _runtime_package_list reads at
# install time, not the build-time source it came from.
grep -hv '^#\|^$' \
"$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-base.packages"
printf '%s\n' "$OMARCHY_RUNTIME_PACKAGE" "$OMARCHY_SETTINGS_PACKAGE" \
"$OMARCHY_NVIM_PACKAGE"
} | sort -u
)
pacman --config "$build_cache_dir/pacman-offline.conf" \
--root "$resolve_root" --dbpath "$resolve_root/var/lib/pacman" \
--noconfirm -Sy >/dev/null || return 1
# Capture before counting: no pipefail here, so a pacman failure inside a
# pipeline would become a plausible partial count, which never trips the
# dashboard's fallback.
resolved="$(pacman --config "$build_cache_dir/pacman-offline.conf" \
--root "$resolve_root" --dbpath "$resolve_root/var/lib/pacman" \
--noconfirm -S --print --print-format '%n' "${targets[@]}")" || return 1
printf '%s\n' "$resolved" | sort -u | grep -c .
}
# Worth failing the build over: -S --print only aborts when a target is missing
# from the offline repo, which would fail pacstrap the same way. A count that
# merely looks wrong is not — the dashboard falls back without the file.
if ! expected_packages="$(resolve_expected_packages)"; then
echo "ERROR: could not resolve the target package count from the offline mirror." >&2
echo " pacman -S --print aborts the whole transaction if any single target" >&2
echo " is missing, so this almost certainly means pacstrap would fail the" >&2
echo " same way at install time." >&2
exit 1
fi
if (( expected_packages < 600 || expected_packages > 2000 )); then
echo "WARNING: resolved target package count $expected_packages is outside the" >&2
echo " expected 600-2000 range; shipping no denominator so the install" >&2
echo " dashboard falls back to its time-based curve." >&2
else
printf '%s\n' "$expected_packages" \
>"$build_cache_dir/airootfs/usr/share/omarchy-iso/expected-packages"
echo "Target install resolves to $expected_packages packages."
fi
# Live ISO uses the same offline pacman.conf. # Live ISO uses the same offline pacman.conf.
cp "$build_cache_dir/pacman-offline.conf" "$build_cache_dir/airootfs/etc/pacman.conf" cp "$build_cache_dir/pacman-offline.conf" "$build_cache_dir/airootfs/etc/pacman.conf"
+67
View File
@@ -0,0 +1,67 @@
#!/bin/bash
set -euo pipefail
offline_mirror_dir="${1:-}"
if [[ -z $offline_mirror_dir ]]; then
echo "Usage: prune-offline-mirror.sh <offline-mirror-dir>" >&2
exit 1
fi
if [[ ! -d $offline_mirror_dir ]]; then
echo "ERROR: offline mirror directory not found: $offline_mirror_dir" >&2
exit 1
fi
# Read the exact package filenames selected by pacman on stdin. Validate the
# complete selection before deleting anything: an empty/partial resolution must
# never turn a warm-cache build into a broken mirror.
declare -A required=()
while IFS= read -r filename; do
[[ -n $filename ]] || continue
if [[ $filename == */* || $filename != *.pkg.tar.* || $filename == *.sig ]]; then
echo "ERROR: invalid required package filename: $filename" >&2
exit 1
fi
required["$filename"]=1
done
if (( ${#required[@]} == 0 )); then
echo "ERROR: refusing to prune the offline mirror with an empty package selection" >&2
exit 1
fi
missing=()
for filename in "${!required[@]}"; do
[[ -f "$offline_mirror_dir/$filename" ]] || missing+=("$filename")
done
if (( ${#missing[@]} > 0 )); then
echo "ERROR: refusing to prune; selected packages are missing from the mirror:" >&2
printf ' %s\n' "${missing[@]}" >&2
exit 1
fi
stale=()
while IFS= read -r -d '' package_file; do
filename="${package_file##*/}"
[[ -n ${required[$filename]+x} ]] || stale+=("$package_file")
done < <(
find "$offline_mirror_dir" -maxdepth 1 -type f \
-name '*.pkg.tar.*' ! -name '*.sig' -print0
)
if (( ${#stale[@]} > 0 )); then
echo "Pruning package archives not selected for the offline mirror:"
for package_file in "${stale[@]}"; do
echo " $(basename "$package_file")"
rm -f -- "$package_file" "$package_file.sig"
done
fi
# A failed or interrupted download can leave a signature without its archive.
# It cannot be indexed and only wastes space in the ISO.
while IFS= read -r -d '' signature_file; do
[[ -f ${signature_file%.sig} ]] || rm -f -- "$signature_file"
done < <(
find "$offline_mirror_dir" -maxdepth 1 -type f \
-name '*.pkg.tar.*.sig' -print0
)
+12 -2
View File
@@ -91,7 +91,15 @@ warm_pid=$!
trap 'kill "$warm_pid" 2>/dev/null' EXIT trap 'kill "$warm_pid" 2>/dev/null' EXIT
cd /root cd /root
./configurator # Autoinstall: a cidata drive carrying the configurator's own output files
# stands in for the wizard. omarchy-cidata-load copies them into /root and
# everything downstream runs the ordinary path against ordinary inputs.
if /usr/local/bin/omarchy-cidata-load; then
echo "Autoinstall configuration found on cidata drive; skipping the configurator."
export OMARCHY_UI_INTERACTIVE=no
else
./configurator
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
@@ -107,4 +115,6 @@ rm -f /run/omarchy-install/state.json
--creds /root/user_credentials.json \ --creds /root/user_credentials.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 \
--tailscale-authkey-file /root/tailscale_authkey
+5 -1
View File
@@ -145,7 +145,11 @@ user_form() {
username=$(gum input --placeholder "Alphanumeric without spaces (like dhh)" --prompt.foreground="#845DF9" --prompt "Username> ") || abort username=$(gum input --placeholder "Alphanumeric without spaces (like dhh)" --prompt.foreground="#845DF9" --prompt "Username> ") || abort
if [[ "$username" =~ ^[a-z_][a-z0-9_-]*[$]?$ ]]; then if [[ "$username" =~ ^[a-z_][a-z0-9_-]*[$]?$ ]]; then
break if [[ "$username" =~ ^(root|bin|daemon|mail|ftp|http|nobody|dbus|systemd-coredump|systemd-network|systemd-oom|systemd-journal-remote|systemd-resolve|systemd-timesync|tss|uuidd|alpm|git|avahi|cups|lp|_talkd|polkitd|rtkit|qemu|brltty|gluster|rpc|libvirt-qemu|pcscd|nvidia-persistenced|sddm)$ ]] ; then
notice "Username is reserved for system" 1
else
break
fi
else else
notice "Username must be alphanumeric with no spaces" 1 notice "Username must be alphanumeric with no spaces" 1
fi fi
+57
View File
@@ -0,0 +1,57 @@
#!/bin/bash
#
# Autoinstall: a drive labeled cidata (the cloud-init NoCloud label, so Proxmox,
# libvirt and Packer all know how to attach one) carrying the configurator's own
# output files stands in for the wizard. Copy those files into /root and
# everything downstream runs the ordinary path against ordinary inputs.
#
# Exits 0 when an autoinstall drive was loaded and the configurator can be
# skipped. The required pair is the same one the orchestrator treats as
# mandatory; the rest are optional there too, so don't be stricter here.
# Anything less than the pair means this isn't an autoinstall drive, and the
# caller falls back to the wizard.
#
# The optional argument prefixes every path the script touches; tests use it to
# run against a sandbox instead of the live system.
set -uo pipefail
root=${1:-}
by_label=$root/dev/disk/by-label
dest=$root/root
mnt=$root/run/cidata
# The by-label symlinks come from udev, and on a fast boot this can run before
# device enumeration has finished. Wait for the queue to drain instead of
# racing it and silently dropping a headless install into the wizard.
udevadm settle 2>/dev/null || true
device=""
for label in cidata CIDATA; do
[[ -e $by_label/$label ]] && { device=$by_label/$label; break; }
done
[[ -n $device ]] || exit 1
mkdir -p "$mnt"
mount -o ro "$device" "$mnt" || exit 1
loaded=1
if [[ -f $mnt/user_configuration.json && -f $mnt/user_credentials.json ]]; then
files=("$mnt"/user_configuration.json "$mnt"/user_credentials.json)
for file in user_full_name.txt user_email_address.txt user_encrypt_installation.txt authorized_keys tailscale_authkey; do
[[ -f $mnt/$file ]] && files+=("$mnt/$file")
done
if cp "${files[@]}" "$dest"/; then
loaded=0
else
# A partial copy must not leave files behind: the wizard cleans up only
# what it writes itself, so a half-copied authorized_keys would leak into
# the interactive install that follows.
for file in "${files[@]}"; do
rm -f "$dest/${file##*/}"
done
fi
fi
umount "$mnt"
exit $loaded
@@ -23,6 +23,12 @@ INSTALL_CMD=("$@")
TTY_PATH="${OMARCHY_DASHBOARD_TTY:-/dev/tty}" TTY_PATH="${OMARCHY_DASHBOARD_TTY:-/dev/tty}"
LOGO_PATH="${OMARCHY_PATH:-/usr/share/omarchy}/logo.txt" LOGO_PATH="${OMARCHY_PATH:-/usr/share/omarchy}/logo.txt"
# Autoinstall has no one at the keyboard, and every prompt this dashboard owns
# reads from the TTY, so each would hang forever. Suppress them here rather than
# in the scripts they live in: this process is the only owner of the visible UI.
interactive() { [[ ${OMARCHY_UI_INTERACTIVE:-} != "no" ]]; }
CSI=$'\033[' CSI=$'\033['
RESET="${CSI}0m" RESET="${CSI}0m"
DIM="${CSI}2m" DIM="${CSI}2m"
@@ -137,7 +143,63 @@ LOGO_HEIGHT="$(logo_height)"
(( LOGO_HEIGHT > 0 )) || LOGO_HEIGHT=1 (( LOGO_HEIGHT > 0 )) || LOGO_HEIGHT=1
CONTENT_WIDTH="$LOGO_WIDTH" CONTENT_WIDTH="$LOGO_WIDTH"
DYNAMIC_ROW=$((LOGO_HEIGHT + 3)) DYNAMIC_ROW=$((LOGO_HEIGHT + 3))
LAST_PCT=1 LAST_PM=10
# ── Install progress ─────────────────────────────────────────────────────────
#
# Position = max(floor, work), both non-decreasing, in per-mille of the bar.
#
# floor lo + (span-1) * t / (t+tau), on time since this process saw the
# phase. Asymptotic, so it approaches the band end without reaching it.
# tau is a shape constant, not a duration prediction.
# work packages installed / PKG_TOTAL, for the package phase only (~85% of
# the install). libalpm makes one directory per package under
# <target>/var/lib/pacman/local; the denominator comes from
# build-iso.sh, so there is no second package list to drift.
#
# Per-mille because a whole-percent asymptote over a 2-point band has two
# reachable values and stops moving. No give-up predicate: two discontinuous
# curves behind a monotonic clamp freeze. With no signal the floor alone still
# finishes the band.
EXPECTED_PACKAGES_FILE="${OMARCHY_EXPECTED_PACKAGES_FILE:-/usr/share/omarchy-iso/expected-packages}"
PKG_TOTAL=0
if [[ -r $EXPECTED_PACKAGES_FILE ]]; then
read -r PKG_TOTAL _ <"$EXPECTED_PACKAGES_FILE" 2>/dev/null || PKG_TOTAL=0
fi
[[ $PKG_TOTAL =~ ^[0-9]+$ ]] || PKG_TOTAL=0
# A wrong denominator is worse than none: it never triggers the fallback.
(( PKG_TOTAL < 300 || PKG_TOTAL > 5000 )) && PKG_TOTAL=0
PKG_BONUS_MAX_PM=100 # cap on how far the bar may run ahead of the count
PKG_TAIL_PM=40 # band held back so a stalled bar has somewhere to go
PKG_FLOOR_TAU=900 # with a work signal, time is only a backstop until the
# first package, so slow it until it cannot outrun data
POS=10 # position, per-mille, monotonic
PROGRESS_PM=10
NOW=0 # monotonic seconds, refreshed once per frame
DASH_T0=0
PHASE_NAME="" # last phase this process observed
PHASE_T0=0 # NOW when it was first observed
PHASE_LO=10 # POS on entry, for an unknown phase with no index
PKG_DB_DIR=""
PKG_COUNT=0
PKG_BASE=-1 # package count when the phase started
PKG_SEEN=0 # running max of packages since PKG_BASE
PKG_LAST_CHANGE=0
PKG_BONUS=0
# Monotonic clock: tzupdate and systemd-timesyncd can step the wall clock
# mid-install, which would jump every time-driven band to its ceiling.
set_now() {
local up rest
if read -r up rest </proc/uptime 2>/dev/null && [[ $up == [0-9]* ]]; then
NOW=${up%%.*}
else
NOW=$EPOCHSECONDS
fi
return 0
}
left_padding() { left_padding() {
local width="${1:-$CONTENT_WIDTH}" cols pad local width="${1:-$CONTENT_WIDTH}" cols pad
@@ -183,72 +245,146 @@ render_logo() {
} }
progress_bar() { progress_bar() {
local pct="$1" width="${2:-34}" filled empty local pm="$1" width="${2:-34}" filled empty
(( pct < 0 )) && pct=0 (( pm < 0 )) && pm=0
(( pct > 100 )) && pct=100 (( pm > 1000 )) && pm=1000
filled=$(( pct * width / 100 )) filled=$(( pm * width / 1000 ))
empty=$(( width - filled )) empty=$(( width - filled ))
printf '%s%s%s%s%s' "$WHITE" "$(repeat █ "$filled")" "$DARK" "$(repeat ░ "$empty")" "$RESET" printf '%s%s%s%s%s' "$WHITE" "$(repeat █ "$filled")" "$DARK" "$(repeat ░ "$empty")" "$RESET"
} }
# Packages libalpm has finished installing. local/ holds one directory per
# package and nothing else in it is a directory, so */ is exact. One glob, no
# forks: this runs twice a second while the disk is saturated.
count_packages() {
local -a entries
PKG_COUNT=0
[[ -n $PKG_DB_DIR ]] || return 0
entries=("$PKG_DB_DIR"/*/)
# Unmatched globs expand to the pattern; nullglob stays off script-wide.
if (( ${#entries[@]} == 1 )) && [[ ! -d ${entries[0]} ]]; then
return 0
fi
PKG_COUNT=${#entries[@]}
return 0
}
# Sets PROGRESS_PM. Never call in a command substitution: POS, the phase timer
# and the package counters must survive between frames. Print nothing — output
# here would land on the TTY between the cursor escape and the bar.
install_progress() { install_progress() {
local phase finished phase_started elapsed span active pct local phase finished index total target
local start=1 end=99 expected=90 local lo hi tau span t pos work gap n pkg_signal=0
local -a state local -a state
[[ -f $STATE_FILE ]] || { printf '1'; return; } PROGRESS_PM=$POS
[[ -f $STATE_FILE ]] || return 0
mapfile -t state < <(jq -r ' mapfile -t state < <(jq -r '
(.current_phase // "Starting installation"), (.current_phase // "Starting installation"),
((.finished_at // 0) | tonumber? // 0 | floor), ((.finished_at // 0) | tonumber? // 0 | floor),
((.phase_started_at // .started_at // 0) | tonumber? // 0 | floor) ((.current_index // -1) | tonumber? // -1 | floor),
((.total_phases // 0) | tonumber? // 0 | floor),
(.target // "/mnt")
' "$STATE_FILE" 2>/dev/null) ' "$STATE_FILE" 2>/dev/null)
(( ${#state[@]} == 3 )) || { printf '1'; return; } (( ${#state[@]} == 5 )) || return 0
phase="${state[0]}" finished="${state[1]}" phase_started="${state[2]}" phase="${state[0]}" finished="${state[1]}"
index="${state[2]}" total="${state[3]}" target="${state[4]}"
[[ -n $target ]] || target="/mnt"
if (( finished > 0 )) || [[ $phase == "Installation complete" ]]; then if (( finished > 0 )) || [[ $phase == "Installation complete" ]]; then
printf '100' POS=1000
return PROGRESS_PM=1000
return 0
fi fi
# Phase weights: start%, end%, expected seconds in phase. # Transitions are observed here rather than read from the state file, which
# keeps wall clock out of the dashboard entirely.
if [[ $phase != "$PHASE_NAME" ]]; then
PHASE_NAME="$phase"
PHASE_T0=$NOW
PHASE_LO=$POS
PKG_BASE=-1
PKG_SEEN=0
PKG_BONUS=0
PKG_LAST_CHANGE=$NOW
fi
# Bands in per-mille, plus tau. The tail bands are wide relative to what they
# cost on NVMe: run_system_finalizer can DKMS-build a driver and
# finalize_limine_boot rebuilds every UKI, so they vary most on real hardware.
# Over-allotting costs a step at a boundary; under-allotting costs a stall.
case "$phase" in case "$phase" in
"Starting installation") start=1 end=2 expected=2 ;; "Starting installation") lo=10 hi=25 tau=5 ;;
"Preparing live environment") start=2 end=6 expected=5 ;; "Preparing live environment") lo=25 hi=50 tau=25 ;;
"Verifying protected mounts") start=6 end=8 expected=3 ;; "Preparing install target") lo=50 hi=65 tau=20 ;;
"Installing Arch + Omarchy") start=8 end=86 expected=95 ;; "Installing Arch + Omarchy") lo=65 hi=700 tau=150 pkg_signal=1 ;;
"Installing base system") start=8 end=84 expected=85 ;; "Configuring hibernation") lo=700 hi=720 tau=10 ;;
"Configuring protected boot") start=84 end=86 expected=8 ;; "Configuring system") lo=720 hi=850 tau=120 ;;
"Configuring hibernation") start=86 end=88 expected=5 ;; "Finalizing Limine boot") lo=850 hi=950 tau=90 ;;
"Configuring system") start=88 end=93 expected=10 ;; "Finalizing user") lo=950 hi=978 tau=30 ;;
"Finalizing Limine boot") start=93 end=96 expected=8 ;; "Configuring login") lo=978 hi=988 tau=5 ;;
"Finalizing user") start=96 end=98 expected=10 ;; "Configuring DNS resolver") lo=988 hi=994 tau=5 ;;
"Finalizing in chroot") start=88 end=98 expected=10 ;; "Validating boot setup") lo=994 hi=1000 tau=5 ;;
"Configuring login") start=98 end=99 expected=3 ;; *)
"Configuring DNS resolver") start=99 end=100 expected=2 ;; # Unknown names used to inherit a 1->99 sweep. Derive an even band from
"Validating protected boot setup") start=99 end=100 expected=5 ;; # the published index instead; with no index, a bounded slice, so a
"Validating boot setup") start=99 end=100 expected=5 ;; # renamed phase degrades to one cell rather than a runaway.
if (( total >= 2 && index >= 0 && index < total )); then
lo=$(( 10 + 990 * index / total ))
hi=$(( 10 + 990 * (index + 1) / total ))
else
lo=$PHASE_LO
hi=$(( PHASE_LO + 30 ))
fi
tau=60
;;
esac esac
(( phase_started > 0 )) || phase_started=$EPOCHSECONDS (( pkg_signal && PKG_TOTAL > 0 )) || pkg_signal=0
elapsed=$(( EPOCHSECONDS - phase_started )) (( pkg_signal )) && tau=$PKG_FLOOR_TAU
(( elapsed < 0 )) && elapsed=0
span=$(( end - start )) t=$(( NOW - PHASE_T0 ))
(( span < 0 )) && span=0 (( t < 0 )) && t=0
(( expected < 1 )) && expected=1 span=$(( hi - lo ))
active=$(( elapsed * span / expected )) (( span < 2 )) && span=2
(( active > span - 1 )) && active=$(( span - 1 )) (( tau < 1 )) && tau=1
(( active < 0 )) && active=0
pct=$(( start + active )) pos=$(( lo + (span - 1) * t / (t + tau) ))
(( pct > 100 )) && pct=100
printf '%s' "$pct" if (( pkg_signal )); then
PKG_DB_DIR="$target/var/lib/pacman/local"
count_packages
# Baseline on first sight: a target that already holds packages would
# otherwise pin the bar at the band ceiling for the whole phase.
(( PKG_BASE < 0 )) && PKG_BASE=$PKG_COUNT
n=$(( PKG_COUNT - PKG_BASE ))
(( n < 0 )) && n=0
# Running max: a reinstalled package briefly drops its version-stamped dir.
if (( n > PKG_SEEN )); then
PKG_SEEN=$n
PKG_LAST_CHANGE=$NOW
fi
gap=$(( NOW - PKG_LAST_CHANGE ))
(( gap < 0 )) && gap=0
(( gap > PKG_BONUS_MAX_PM )) && gap=$PKG_BONUS_MAX_PM
(( gap > PKG_BONUS )) && PKG_BONUS=$gap
work=$(( lo + (span - 1 - PKG_TAIL_PM) * PKG_SEEN / PKG_TOTAL + PKG_BONUS ))
(( work > pos )) && pos=$work
fi
(( pos > hi - 1 )) && pos=$(( hi - 1 ))
(( pos > POS )) && POS=$pos
(( POS > 1000 )) && POS=1000
PROGRESS_PM=$POS
return 0
} }
# One tip per ~8 seconds (frames tick every 0.5s), in order, so each stays # One tip per 8 seconds, in order. On elapsed time, not a frame counter: a frame
# readable and none are skipped when progress jumps. # is 0.5s of sleep plus a render, which stretches under pacstrap I/O — on the
# machine where the tips, not the bar, are carrying liveness.
current_tip() { current_tip() {
local frame="$1" printf '%s' "${tips[$(( (NOW - DASH_T0) / 8 % ${#tips[@]} ))]}"
printf '%s' "${tips[$(( frame / 16 % ${#tips[@]} ))]}"
} }
render_static() { render_static() {
@@ -259,21 +395,24 @@ render_static() {
} }
render_dynamic() { render_dynamic() {
local frame="$1" pct local pm
pct="$(install_progress)" set_now
[[ $pct =~ ^[0-9]+$ ]] || pct=$LAST_PCT install_progress
(( pct > 100 )) && pct=100 pm=$PROGRESS_PM
(( pct < LAST_PCT )) && pct=$LAST_PCT [[ $pm =~ ^[0-9]+$ ]] || pm=$LAST_PM
LAST_PCT=$pct (( pm > 1000 )) && pm=1000
# POS is monotone by construction; this is a guard, not the mechanism.
(( pm < LAST_PM )) && pm=$LAST_PM
LAST_PM=$pm
printf '%s%d;1H' "$CSI" "$DYNAMIC_ROW" printf '%s%d;1H' "$CSI" "$DYNAMIC_ROW"
center "Installing Omarchy" "$CONTENT_WIDTH" center "Installing Omarchy" "$CONTENT_WIDTH"
blank_line blank_line
line_at "$CONTENT_WIDTH" $(( (CONTENT_WIDTH - 34) / 2 )) "" line_at "$CONTENT_WIDTH" $(( (CONTENT_WIDTH - 34) / 2 )) ""
progress_bar "$pct" 34 progress_bar "$pm" 34
printf '\n' printf '\n'
blank_line blank_line
center "${DIM}Tip:${RESET} ${GREEN}$(current_tip "$frame")${RESET}" "$CONTENT_WIDTH" center "${DIM}Tip:${RESET} ${GREEN}$(current_tip)${RESET}" "$CONTENT_WIDTH"
printf '%s' "$CLEAR_TO_END" printf '%s' "$CLEAR_TO_END"
} }
@@ -341,6 +480,10 @@ render_finish() {
reboot_prompt() { reboot_prompt() {
local prompt_pad local prompt_pad
# Nobody to confirm; the OMARCHY_UI_AUTO_REBOOT check at the call site still
# applies, so setting both stops on the finish screen without a keyboard.
interactive || return 0
# Position the cursor one blank line below the title so the button sits right there. # Position the cursor one blank line below the title so the button sits right there.
printf '%s%d;1H%s' "$CSI" "$((LOGO_HEIGHT + 5))" "$CLEAR_LINE" >"$TTY_PATH" printf '%s%d;1H%s' "$CSI" "$((LOGO_HEIGHT + 5))" "$CLEAR_LINE" >"$TTY_PATH"
@@ -465,6 +608,9 @@ failure_menu() {
local choice uploader local choice uploader
[[ ${OMARCHY_UI_FAILURE_ACTION:-} == "exit" ]] && return 0 [[ ${OMARCHY_UI_FAILURE_ACTION:-} == "exit" ]] && return 0
# The failure screen and log tail have already rendered; exit with the
# installer's status instead of blocking on a menu nobody can answer.
interactive || return 0
while true; do while true; do
uploader="" uploader=""
@@ -541,12 +687,14 @@ launch_child() {
[[ -e $TTY_PATH ]] || exit 2 [[ -e $TTY_PATH ]] || exit 2
printf '%s' "$HIDE_CURSOR" >"$TTY_PATH" printf '%s' "$HIDE_CURSOR" >"$TTY_PATH"
render_static >"$TTY_PATH" render_static >"$TTY_PATH"
set_now
DASH_T0=$NOW
PHASE_T0=$NOW
PKG_LAST_CHANGE=$NOW
launch_child launch_child
frame=0
while jobs -pr | grep -qx "$child_pid"; do while jobs -pr | grep -qx "$child_pid"; do
render_dynamic "$frame" >"$TTY_PATH" 2>/dev/null || true render_dynamic >"$TTY_PATH" 2>/dev/null || true
frame=$((frame + 1))
sleep 0.5 sleep 0.5
done done
@@ -559,6 +707,13 @@ child_pgid=""
dashboard_log "installer child exited with status $status" dashboard_log "installer child exited with status $status"
if (( status == 0 )); then
# finished_at lands microseconds before the child exits, so the 0.5s poll
# rarely renders a full bar before the finish screen replaces it.
render_dynamic >"$TTY_PATH" 2>/dev/null || true
sleep 0.4
fi
if (( status != 0 )); then if (( status != 0 )); then
failure_status="$status" failure_status="$status"
failure_summary="" failure_summary=""
@@ -11,12 +11,14 @@ set -eEo pipefail
# doesn't choke on flags it doesn't recognize. # doesn't choke on flags it doesn't recognize.
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case $1 in case $1 in
--config) export OMARCHY_INSTALL_CONFIG="$2"; shift 2 ;; --config) export OMARCHY_INSTALL_CONFIG="$2"; shift 2 ;;
--creds) export OMARCHY_INSTALL_CREDS="$2"; shift 2 ;; --creds) export OMARCHY_INSTALL_CREDS="$2"; shift 2 ;;
--full-name-file) export OMARCHY_INSTALL_FULL_NAME_FILE="$2"; shift 2 ;; --full-name-file) export OMARCHY_INSTALL_FULL_NAME_FILE="$2"; shift 2 ;;
--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 ;;
*) echo "omarchy-iso-install: unknown arg $1" >&2; exit 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 ;;
*) echo "omarchy-iso-install: unknown arg $1" >&2; exit 2 ;;
esac esac
done done
@@ -18,6 +18,8 @@ class InstallContext:
full_name: str full_name: str
email: str email: str
encrypt: bool encrypt: bool
authorized_keys_path: Path | None
tailscale_authkey_path: Path | None
user_configuration: dict user_configuration: dict
user_credentials: dict user_credentials: dict
@@ -59,6 +61,8 @@ class InstallContext:
full_name=_read_text(os.environ.get("OMARCHY_INSTALL_FULL_NAME_FILE")), full_name=_read_text(os.environ.get("OMARCHY_INSTALL_FULL_NAME_FILE")),
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")),
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=json.loads(creds_path.read_text()),
arch_config_path=arch_config_path, arch_config_path=arch_config_path,
@@ -103,6 +107,16 @@ def _default_omarchy_install(user_configuration: dict) -> dict[str, Any]:
} }
def _optional_path(path: str | None) -> Path | None:
"""A path the caller always passes but that need not exist. The live ISO
passes --authorized-keys-file on every install; only autoinstall drives put
a file there."""
if not path:
return None
p = Path(path)
return p if p.exists() else None
def _read_text(path: str | None) -> str: def _read_text(path: str | None) -> str:
if not path: if not path:
return "" return ""
@@ -0,0 +1,62 @@
"""Offline target keyboard configuration.
archinstall's set_keyboard_language boots the installed system in a
systemd-nspawn container just to run localectl. systemd-firstboot --root
produces the part of that output Omarchy actually consumes — KEYMAP for the
console plus the XKB* settings in vconsole.conf that omarchy's
detect-keyboard-layout.sh copies into Hyprland's kb_layout — without booting
anything. The Xorg 00-keyboard.conf that localectl also writes is not
generated: nothing on an Omarchy system reads it.
"""
from __future__ import annotations
import subprocess
from pathlib import Path
def configure_keyboard(target: Path, language: str) -> bool:
"""Write the console keymap into a mounted target without booting it.
Returns False for layouts localectl doesn't know (the configurator offers
two, ba and khmer), matching archinstall: warn and keep the default.
"""
if not language.strip():
return True
result = subprocess.run(
["localectl", "--no-pager", "list-keymaps"],
check=False,
text=True,
capture_output=True,
)
if result.returncode != 0:
detail = result.stderr.strip() or "localectl returned an error"
raise RuntimeError(f"Unable to list keyboard layouts: {detail}")
if language.lower() not in {layout.lower() for layout in result.stdout.splitlines()}:
return False
# systemd-firstboot --force rewrites vconsole.conf wholesale, dropping the
# FONT= line archinstall's set_vconsole wrote earlier.
vconsole_path = target / "etc" / "vconsole.conf"
font = None
if vconsole_path.exists():
font = next(
(line for line in vconsole_path.read_text().splitlines() if line.startswith("FONT=")),
None,
)
result = subprocess.run(
["systemd-firstboot", f"--root={target}", f"--keymap={language}", "--force"],
check=False,
text=True,
capture_output=True,
)
if result.returncode != 0:
detail = result.stderr.strip() or "systemd-firstboot returned an error"
raise RuntimeError(f"Unable to configure keyboard layout {language}: {detail}")
if font:
vconsole_path.write_text(vconsole_path.read_text() + font + "\n")
return True
@@ -38,6 +38,8 @@ def build_phases(ctx: InstallContext):
run_chroot_finalizer, run_chroot_finalizer,
configure_dns_resolver, configure_dns_resolver,
configure_login, configure_login,
configure_ssh_access,
configure_tailscale,
validate_boot, validate_boot,
) )
@@ -50,6 +52,8 @@ def build_phases(ctx: InstallContext):
("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 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),
] ]
@@ -65,11 +69,15 @@ def main() -> int:
info(f"Installing Omarchy for {ctx.username} → {ctx.target}") info(f"Installing Omarchy for {ctx.username} → {ctx.target}")
from .phases_impl import ( from .phases_impl import (
boost_cpu_governor,
cleanup_bind_mounts, cleanup_bind_mounts,
cleanup_protected_state, cleanup_protected_state,
cleanup_target_hook_masks, cleanup_target_hook_masks,
restore_cpu_governors,
) )
governors = boost_cpu_governor()
success = False success = False
try: try:
try: try:
@@ -85,6 +93,7 @@ def main() -> int:
info("Installation complete.") info("Installation complete.")
return 0 return 0
finally: finally:
restore_cpu_governors(governors)
cleanup_bind_mounts(ctx) cleanup_bind_mounts(ctx)
cleanup_target_hook_masks(ctx) cleanup_target_hook_masks(ctx)
if not success: if not success:
@@ -4,6 +4,7 @@ the InstallContext and either return cleanly or raise to abort the install."""
from __future__ import annotations from __future__ import annotations
import json import json
import os
import time import time
import traceback import traceback
from collections.abc import Callable from collections.abc import Callable
@@ -25,6 +26,9 @@ def run(ctx: InstallContext, phases: list[tuple[str, PhaseFn]]) -> None:
state_path = ctx.state_dir / "state.json" state_path = ctx.state_dir / "state.json"
state = { state = {
"started_at": time.time(), "started_at": time.time(),
# The dashboard counts packages under <target>/var/lib/pacman/local;
# publish the path rather than have the UI assume /mnt.
"target": str(ctx.target),
"total_phases": len(phases), "total_phases": len(phases),
"current_index": 0, "current_index": 0,
"current_phase": "Starting installation", "current_phase": "Starting installation",
@@ -63,6 +67,10 @@ def run(ctx: InstallContext, phases: list[tuple[str, PhaseFn]]) -> None:
state["current_index"] = max(len(phases) - 1, 0) state["current_index"] = max(len(phases) - 1, 0)
state["current_phase"] = "Installation complete" state["current_phase"] = "Installation complete"
state["finished_at"] = time.time() state["finished_at"] = time.time()
# Expected vs actual for the bar's denominator, so drift is visible in
# acceptance runs rather than only by watching a bar creep.
state["installed_packages"] = _installed_package_count(ctx.target)
state["expected_packages"] = _expected_package_count()
_write_state(state_path, state) _write_state(state_path, state)
timing_path = ctx.target / "var" / "log" / "omarchy-install-timing.json" timing_path = ctx.target / "var" / "log" / "omarchy-install-timing.json"
@@ -70,6 +78,25 @@ def run(ctx: InstallContext, phases: list[tuple[str, PhaseFn]]) -> None:
_write_state(timing_path, state) _write_state(timing_path, state)
def _installed_package_count(target: Path) -> int:
"""Packages libalpm installed into the target — one directory each under
local/, which is what the dashboard counts live."""
local_db = target / "var" / "lib" / "pacman" / "local"
try:
with os.scandir(local_db) as entries:
return sum(1 for entry in entries if entry.is_dir())
except OSError:
return 0
def _expected_package_count() -> int:
path = Path("/usr/share/omarchy-iso/expected-packages")
try:
return int(path.read_text().split()[0])
except (OSError, ValueError, IndexError):
return 0
def _write_state(path: Path, state: dict) -> None: def _write_state(path: Path, state: dict) -> None:
# Dashboard polls this file while phases update it. Write atomically so the # Dashboard polls this file while phases update it. Write atomically so the
# reader never observes a truncated/partial JSON document and resets UI. # reader never observes a truncated/partial JSON document and resets UI.
@@ -15,6 +15,8 @@ Phase ordering (full-disk and protected/pre-mounted):
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-finalize-user
configure_login → sddm state + encrypted-install autologin configure_login → sddm state + encrypted-install autologin
configure_ssh_access → authorized_keys for autoinstall; no-op otherwise
configure_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
""" """
@@ -27,11 +29,13 @@ import shutil
import subprocess import subprocess
import textwrap import textwrap
import time import time
from dataclasses import replace
from pathlib import Path from pathlib import Path
from . import archinstall_adapter as arch from . import archinstall_adapter as arch
from .context import InstallContext from .context import InstallContext
from .ui import info from .keyboard import configure_keyboard
from .ui import error, info
# Package targets are written by builder/build-iso.sh. Stable ISOs use the # Package targets are written by builder/build-iso.sh. Stable ISOs use the
@@ -246,6 +250,10 @@ def arch_install_system(ctx: InstallContext) -> None:
_mask_mkinitcpio_pacman_hooks(ctx) _mask_mkinitcpio_pacman_hooks(ctx)
try: try:
info("› installing base system (mkinitcpio deferred to final Limine UKI build)") info("› installing base system (mkinitcpio deferred to final Limine UKI build)")
# An empty kb_layout makes archinstall's set_keyboard_language skip
# booting the target in a container just to run localectl; the
# keymap is configured offline right after instead.
kb_layout = config.locale_config.kb_layout if config.locale_config else ""
installer.minimal_installation( installer.minimal_installation(
optional_repositories=( optional_repositories=(
config.mirror_config.optional_repositories config.mirror_config.optional_repositories
@@ -253,10 +261,16 @@ def arch_install_system(ctx: InstallContext) -> None:
), ),
mkinitcpio=False, mkinitcpio=False,
hostname=config.hostname, hostname=config.hostname,
locale_config=config.locale_config, locale_config=(
replace(config.locale_config, kb_layout="")
if config.locale_config else None
),
pacman_config=config.pacman_config, pacman_config=config.pacman_config,
) )
if not configure_keyboard(installer.target, kb_layout):
error(f"Invalid keyboard language specified: {kb_layout}")
if config.mirror_config: if config.mirror_config:
installer.set_mirrors(mirror_handler, config.mirror_config, on_target=True) installer.set_mirrors(mirror_handler, config.mirror_config, on_target=True)
@@ -277,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)
@@ -437,7 +459,13 @@ def _register_limine_efi_entry(
raise RuntimeError("efibootmgr --create reported success but no Limine entry found") raise RuntimeError("efibootmgr --create reported success but no Limine entry found")
limine_num = new_limine[0] limine_num = new_limine[0]
keep = [num for num in pre_state["order"] if num not in stale_limine and num != limine_num] keep = [
num
for num in pre_state["order"]
if num not in stale_limine
and num != limine_num
and num in pre_state["entries"]
]
subprocess.run( subprocess.run(
["efibootmgr", "--bootorder", ",".join([limine_num, *keep])], ["efibootmgr", "--bootorder", ",".join([limine_num, *keep])],
check=True, capture_output=True, check=True, capture_output=True,
@@ -1286,6 +1314,189 @@ def configure_login(ctx: InstallContext) -> None:
) )
# ─────────────────────────────────────────────────────────────────────────────
# configure_ssh_access: make the installed machine reachable over SSH with the
# keys an autoinstall drive supplied. A stock Omarchy install ships openssh but
# leaves sshd disabled, and its firewall.sh opens only LocalSend and docker DNS,
# so all three pieces -- keys, service, firewall -- have to be done here.
# ─────────────────────────────────────────────────────────────────────────────
def configure_ssh_access(ctx: InstallContext) -> None:
if ctx.authorized_keys_path is None:
return
keys = _authorized_keys(ctx.authorized_keys_path)
info(f"› installing {len(keys)} SSH key(s) for {ctx.username}")
ssh_dir = ctx.target / "home" / ctx.username / ".ssh"
ssh_dir.mkdir(parents=True, exist_ok=True)
ssh_dir.chmod(0o700)
authorized_keys = ssh_dir / "authorized_keys"
authorized_keys.write_text("".join(f"{key}\n" for key in keys))
authorized_keys.chmod(0o600)
# Ask the target for the uid rather than assuming the first user is 1000.
# Uncaptured so a failure shows up in the install log, and checked because
# a root-owned authorized_keys is one sshd refuses to read.
subprocess.run(
["arch-chroot", str(ctx.target), "chown", "-R",
f"{ctx.username}:{ctx.username}", f"/home/{ctx.username}/.ssh"],
check=True,
)
info("› enabling sshd")
subprocess.run(
["arch-chroot", str(ctx.target), "systemctl", "enable", "sshd.service"],
check=True,
)
# Open port 22 in the target's ufw, which runs default-deny incoming, so an
# enabled sshd is still unreachable -- connections time out rather than
# being refused.
#
# ufw cannot reach netfilter from inside the chroot and exits non-zero
# saying so, but it writes the rule to user.rules first, and that file is
# what ufw.service loads on first boot. So the exit status is the wrong
# thing to check here; the rule landing in the file is the thing that
# matters.
info("› allowing SSH through ufw")
subprocess.run(["arch-chroot", str(ctx.target), "ufw", "allow", "ssh"], check=False)
rules = ctx.target / "etc" / "ufw" / "user.rules"
text = rules.read_text() if rules.exists() else ""
if "--dport 22 -j ACCEPT" not in text:
raise RuntimeError(f"ufw did not record an allow rule for port 22 in {rules}")
def _authorized_keys(path: Path) -> list[str]:
"""Read the autoinstall authorized_keys: sshd's own format, one public key
per line, with blank lines and # comments dropped.
Raise rather than skip on anything unusable. An install that "succeeds"
into a machine nobody can log into 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 SSH 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
@@ -1396,6 +1607,40 @@ 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']}")
CPU_SYSFS = Path("/sys/devices/system/cpu")
def boost_cpu_governor() -> dict[Path, str]:
"""Run the live CPUs flat out for the install.
Package extraction and the UKI build are both CPU-bound, and archiso boots
on whatever governor the kernel defaults to. Writing an unsupported
governor just fails, so nothing needs probing first, and hosts without
cpufreq (most VMs) have no paths at all. Returns the prior governors.
"""
saved: dict[Path, str] = {}
for path in sorted(CPU_SYSFS.glob("cpu*/cpufreq/scaling_governor")):
try:
saved[path] = path.read_text().strip()
path.write_text("performance\n")
except OSError:
saved.pop(path, None)
if saved:
info(f"› CPU governor set to performance ({len(saved)} CPUs)")
return saved
def restore_cpu_governors(saved: dict[Path, str]) -> None:
"""Only matters when an install fails and the user keeps using the live
environment — a successful one reboots out of it."""
for path, governor in saved.items():
try:
path.write_text(f"{governor}\n")
except OSError:
continue
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# cleanup_bind_mounts: invoked from main()'s finally so bind mounts get # cleanup_bind_mounts: invoked from main()'s finally so bind mounts get
# unwound on success, failure, or interrupt. Idempotent. # unwound on success, failure, or interrupt. Idempotent.
+1
View File
@@ -36,6 +36,7 @@ file_permissions=(
["/root/.gnupg"]="0:0:700" ["/root/.gnupg"]="0:0:700"
["/root/configurator"]="0:0:755" ["/root/configurator"]="0:0:755"
["/usr/local/bin/choose-mirror"]="0:0:755" ["/usr/local/bin/choose-mirror"]="0:0:755"
["/usr/local/bin/omarchy-cidata-load"]="0:0:755"
["/usr/local/bin/omarchy-iso-cleanup-disk"]="0:0:755" ["/usr/local/bin/omarchy-iso-cleanup-disk"]="0:0:755"
["/usr/local/bin/omarchy-install-dashboard"]="0:0:755" ["/usr/local/bin/omarchy-install-dashboard"]="0:0:755"
["/usr/local/bin/omarchy-iso-install"]="0:0:755" ["/usr/local/bin/omarchy-iso-install"]="0:0:755"
+2 -25
View File
@@ -3763,7 +3763,6 @@
"kservice", "kservice",
"ktextwidgets", "ktextwidgets",
"kvantum", "kvantum",
"kvantum-qt5",
"kwallet", "kwallet",
"kwidgetsaddons", "kwidgetsaddons",
"kwindowsystem", "kwindowsystem",
@@ -4212,12 +4211,6 @@
"qpdf", "qpdf",
"qqc2-desktop-style", "qqc2-desktop-style",
"qrcodegencpp-cmake", "qrcodegencpp-cmake",
"qt5-base",
"qt5-declarative",
"qt5-svg",
"qt5-translations",
"qt5-wayland",
"qt5-x11extras",
"qt6-5compat", "qt6-5compat",
"qt6-base", "qt6-base",
"qt6-declarative", "qt6-declarative",
@@ -4485,7 +4478,7 @@
"jq", "jq",
"kdenlive", "kdenlive",
"kernel-modules-hook", "kernel-modules-hook",
"kvantum-qt5", "kvantum",
"lazydocker", "lazydocker",
"lazygit", "lazygit",
"less", "less",
@@ -4531,7 +4524,6 @@
"python-poetry-core", "python-poetry-core",
"python-terminaltexteffects", "python-terminaltexteffects",
"qemu-user-static-binfmt", "qemu-user-static-binfmt",
"qt5-wayland",
"quickshell", "quickshell",
"ripgrep", "ripgrep",
"ruby", "ruby",
@@ -4639,7 +4631,7 @@
"jq", "jq",
"kdenlive", "kdenlive",
"kernel-modules-hook", "kernel-modules-hook",
"kvantum-qt5", "kvantum",
"lazydocker", "lazydocker",
"lazygit", "lazygit",
"less", "less",
@@ -4685,7 +4677,6 @@
"python-poetry-core", "python-poetry-core",
"python-terminaltexteffects", "python-terminaltexteffects",
"qemu-user-static-binfmt", "qemu-user-static-binfmt",
"qt5-wayland",
"quickshell", "quickshell",
"ripgrep", "ripgrep",
"ruby", "ruby",
@@ -5026,7 +5017,6 @@
"kservice", "kservice",
"ktextwidgets", "ktextwidgets",
"kvantum", "kvantum",
"kvantum-qt5",
"kwallet", "kwallet",
"kwidgetsaddons", "kwidgetsaddons",
"kwindowsystem", "kwindowsystem",
@@ -5475,12 +5465,6 @@
"qpdf", "qpdf",
"qqc2-desktop-style", "qqc2-desktop-style",
"qrcodegencpp-cmake", "qrcodegencpp-cmake",
"qt5-base",
"qt5-declarative",
"qt5-svg",
"qt5-translations",
"qt5-wayland",
"qt5-x11extras",
"qt6-5compat", "qt6-5compat",
"qt6-base", "qt6-base",
"qt6-declarative", "qt6-declarative",
@@ -5976,7 +5960,6 @@
"kservice": "6.26.0-1", "kservice": "6.26.0-1",
"ktextwidgets": "6.26.0-1", "ktextwidgets": "6.26.0-1",
"kvantum": "1.1.7-1", "kvantum": "1.1.7-1",
"kvantum-qt5": "1.1.7-1",
"kwallet": "6.26.0-1", "kwallet": "6.26.0-1",
"kwidgetsaddons": "6.26.0-1", "kwidgetsaddons": "6.26.0-1",
"kwindowsystem": "6.26.0-1", "kwindowsystem": "6.26.0-1",
@@ -6426,12 +6409,6 @@
"qpdf": "12.3.2-2", "qpdf": "12.3.2-2",
"qqc2-desktop-style": "6.26.0-1", "qqc2-desktop-style": "6.26.0-1",
"qrcodegencpp-cmake": "1.8.0-4", "qrcodegencpp-cmake": "1.8.0-4",
"qt5-base": "5.15.19+kde+r96-1",
"qt5-declarative": "5.15.19+kde+r23-1",
"qt5-svg": "5.15.19+kde+r5-1",
"qt5-translations": "5.15.19-1",
"qt5-wayland": "5.15.19+kde+r55-1",
"qt5-x11extras": "5.15.19-1",
"qt6-5compat": "6.11.1-1", "qt6-5compat": "6.11.1-1",
"qt6-base": "6.11.1-1", "qt6-base": "6.11.1-1",
"qt6-declarative": "6.11.1-2", "qt6-declarative": "6.11.1-2",
+2 -25
View File
@@ -3016,7 +3016,6 @@
"kservice", "kservice",
"ktextwidgets", "ktextwidgets",
"kvantum", "kvantum",
"kvantum-qt5",
"kwallet", "kwallet",
"kwidgetsaddons", "kwidgetsaddons",
"kwindowsystem", "kwindowsystem",
@@ -3465,12 +3464,6 @@
"qpdf", "qpdf",
"qqc2-desktop-style", "qqc2-desktop-style",
"qrcodegencpp-cmake", "qrcodegencpp-cmake",
"qt5-base",
"qt5-declarative",
"qt5-svg",
"qt5-translations",
"qt5-wayland",
"qt5-x11extras",
"qt6-5compat", "qt6-5compat",
"qt6-base", "qt6-base",
"qt6-declarative", "qt6-declarative",
@@ -3738,7 +3731,7 @@
"jq", "jq",
"kdenlive", "kdenlive",
"kernel-modules-hook", "kernel-modules-hook",
"kvantum-qt5", "kvantum",
"lazydocker", "lazydocker",
"lazygit", "lazygit",
"less", "less",
@@ -3784,7 +3777,6 @@
"python-poetry-core", "python-poetry-core",
"python-terminaltexteffects", "python-terminaltexteffects",
"qemu-user-static-binfmt", "qemu-user-static-binfmt",
"qt5-wayland",
"quickshell", "quickshell",
"ripgrep", "ripgrep",
"ruby", "ruby",
@@ -3892,7 +3884,7 @@
"jq", "jq",
"kdenlive", "kdenlive",
"kernel-modules-hook", "kernel-modules-hook",
"kvantum-qt5", "kvantum",
"lazydocker", "lazydocker",
"lazygit", "lazygit",
"less", "less",
@@ -3938,7 +3930,6 @@
"python-poetry-core", "python-poetry-core",
"python-terminaltexteffects", "python-terminaltexteffects",
"qemu-user-static-binfmt", "qemu-user-static-binfmt",
"qt5-wayland",
"quickshell", "quickshell",
"ripgrep", "ripgrep",
"ruby", "ruby",
@@ -4279,7 +4270,6 @@
"kservice", "kservice",
"ktextwidgets", "ktextwidgets",
"kvantum", "kvantum",
"kvantum-qt5",
"kwallet", "kwallet",
"kwidgetsaddons", "kwidgetsaddons",
"kwindowsystem", "kwindowsystem",
@@ -4728,12 +4718,6 @@
"qpdf", "qpdf",
"qqc2-desktop-style", "qqc2-desktop-style",
"qrcodegencpp-cmake", "qrcodegencpp-cmake",
"qt5-base",
"qt5-declarative",
"qt5-svg",
"qt5-translations",
"qt5-wayland",
"qt5-x11extras",
"qt6-5compat", "qt6-5compat",
"qt6-base", "qt6-base",
"qt6-declarative", "qt6-declarative",
@@ -5229,7 +5213,6 @@
"kservice": "6.26.0-1", "kservice": "6.26.0-1",
"ktextwidgets": "6.26.0-1", "ktextwidgets": "6.26.0-1",
"kvantum": "1.1.7-1", "kvantum": "1.1.7-1",
"kvantum-qt5": "1.1.7-1",
"kwallet": "6.26.0-1", "kwallet": "6.26.0-1",
"kwidgetsaddons": "6.26.0-1", "kwidgetsaddons": "6.26.0-1",
"kwindowsystem": "6.26.0-1", "kwindowsystem": "6.26.0-1",
@@ -5679,12 +5662,6 @@
"qpdf": "12.3.2-2", "qpdf": "12.3.2-2",
"qqc2-desktop-style": "6.26.0-1", "qqc2-desktop-style": "6.26.0-1",
"qrcodegencpp-cmake": "1.8.0-4", "qrcodegencpp-cmake": "1.8.0-4",
"qt5-base": "5.15.19+kde+r96-1",
"qt5-declarative": "5.15.19+kde+r23-1",
"qt5-svg": "5.15.19+kde+r5-1",
"qt5-translations": "5.15.19-1",
"qt5-wayland": "5.15.19+kde+r55-1",
"qt5-x11extras": "5.15.19-1",
"qt6-5compat": "6.11.1-1", "qt6-5compat": "6.11.1-1",
"qt6-base": "6.11.1-1", "qt6-base": "6.11.1-1",
"qt6-declarative": "6.11.1-2", "qt6-declarative": "6.11.1-2",
+187
View File
@@ -0,0 +1,187 @@
# Autoinstall Plan
## Goal
Let the standard Omarchy ISO install itself with no keyboard present, driven by config files handed to the VM on a second drive. This makes Omarchy usable as a Packer/Proxmox base image for disposable dev environments: create VM, boot, walk away, SSH in.
## Product Requirements
- No ISO rebuild. The shipped ISO autoinstalls when config is present and runs the configurator when it is not.
- No new menu entry, no new boot parameter, no separate ISO flavor.
- Config format is exactly what the configurator already writes — no second schema to maintain.
- The installed machine is reachable over SSH with a key supplied at install time.
- Interactive installs behave identically to today.
---
## Chosen Architecture
### Trigger: config presence, not a mode flag
The installer looks for a drive labeled `cidata` at startup. If it is there and carries the configurator's output files, those files are copied into `/root` and `./configurator` is skipped — the rest of the install runs the ordinary path against ordinary inputs. If it is not there, nothing changes.
This is Ryan Hughes' suggestion from PR #72 and it is what keeps the diff small: the install pipeline can be rewritten freely as long as it keeps reading its five files out of `/root`, and autoinstall follows along for free.
`cidata` is the cloud-init `NoCloud` label, so Proxmox, libvirt, and Packer all already know how to attach one.
### What the ISO already does for us
One thing that would otherwise need building is already true on a stock install: `NetworkManager.service` is enabled and active with DHCP by default, so autoinstall needs no `systemd-networkd` unit — one would contend with NetworkManager.
Remote access needs three things, all verified against a real install rather than inferred:
- `authorized_keys` for the user.
- `systemctl enable sshd.service`. Omarchy ships `openssh` but leaves the service disabled.
- `ufw allow ssh`. `install/config/firewall.sh` opens LocalSend (53317) and docker DNS and nothing else, and ufw runs default-deny incoming.
Still no loosening of `PasswordAuthentication` — key auth only.
Do not take `manifests/fresh-4-semantic.json` as the authority on any of this. It claims `sshd.service: enabled` and carries an `allow tcp 22` ufw rule; a real install of this ISO has neither. It is a snapshot of one machine's state, not a contract about stock behavior, and trusting it cost two test cycles.
### Non-interactivity belongs to the dashboard
`omarchy-install-dashboard` owns every prompt that survives to the end of an install: the reboot confirm and, on failure, the action menu. Both are `gum` calls reading from the TTY, and both hang forever on a headless VM. Autoinstall therefore sets one env var that the dashboard reads, rather than patching prompts out of scripts after the fact — which is what PR #72 did to `finished.sh`, and what everyone correctly disliked.
---
## Non-Goals
- No config generator, validator, or schema doc. The configurator is the source of truth for the format; users copy its output.
- No secret management. Whoever builds the `cidata` drive owns the password hash and the public key on it.
- No PXE, no network-fetched config, no cloud-init compatibility beyond reusing the drive label.
- No changes to disk selection, encryption, or any install phase behavior.
---
## Repo Implementation Plan
## 1) Detect the cidata drive and skip the configurator
### Files
- `configs/airootfs/usr/local/bin/omarchy-cidata-load`
- `configs/airootfs/root/.automated_script.sh`
- `configs/profiledef.sh`
### Changes
- Add `omarchy-cidata-load` alongside the other install commands in `/usr/local/bin` (with the matching `file_permissions` entry in `profiledef.sh`). It exits 0 when an autoinstall drive was loaded:
- `udevadm settle` first: the by-label symlinks come from udev, and probing before enumeration finishes would silently drop a headless install into the wizard.
- Look for `/dev/disk/by-label/cidata` then `/dev/disk/by-label/CIDATA`; exit non-zero if neither exists.
- Mount read-only on a temp mountpoint; exit non-zero if the mount fails.
- Require both `user_configuration.json` and `user_credentials.json` on the drive — this is the same pair `InstallContext.from_env` treats as mandatory. Missing either means the drive is not an autoinstall drive; unmount and exit non-zero.
- Copy the required pair plus, when present, `user_full_name.txt`, `user_email_address.txt`, `user_encrypt_installation.txt`, and `authorized_keys` into `/root` with a single `cp`, and exit non-zero if the copy fails — a "loaded" drive with missing inputs would fail the install later with a worse error.
- Unmount before exiting. Leave nothing mounted for the install to trip over.
- The script takes an optional path prefix so the unit tests can point it at a sandbox; production callers pass nothing.
- Replace the bare `./configurator` call in `.automated_script.sh` with: run `omarchy-cidata-load`; on success export `OMARCHY_UI_INTERACTIVE=no`, on failure run `./configurator`.
- Pass `--authorized-keys-file /root/authorized_keys` to `omarchy-iso-install` in the dashboard invocation, alongside the existing five flags. Always pass it; the orchestrator no-ops when the file is absent, which keeps the interactive path on the identical command line.
### Notes
- The three text files are optional by construction: `_read_text` in `context.py` returns `""` for a missing path and `encrypt` falls back to false. Do not add stricter checks here than the orchestrator itself applies.
- `omarchy-cidata-load` must not run before `warm_offline_mirror` is backgrounded — the mirror warm should keep its head start.
### Acceptance criteria
- ISO with no `cidata` drive attached: configurator runs, byte-identical UX to today.
- ISO with a `cidata` drive carrying the five files: configurator never appears; install starts within seconds of boot.
- ISO with a drive labeled `cidata` that is empty or carries only one of the two required files: falls back to the configurator rather than failing the boot.
- Nothing remains mounted from `/dev/disk/by-label/cidata` once the install begins.
---
## 2) Non-interactive dashboard
### File
- `configs/airootfs/usr/local/bin/omarchy-install-dashboard`
### Changes
- Read `OMARCHY_UI_INTERACTIVE` once near the other env reads at the top.
- In `reboot_prompt()` (`:474`): when non-interactive, return 0 immediately instead of calling `gum confirm`. The existing `OMARCHY_UI_AUTO_REBOOT=no` check at `:737` stays in front of the actual `reboot`, so a debug run can set both and stop on the finish screen without a keyboard.
- In `failure_menu()` (`:597`): treat non-interactive as equivalent to the existing `OMARCHY_UI_FAILURE_ACTION=exit` early return (`:600`), so a failed autoinstall exits with the installer's status instead of blocking on `gum choose` forever. The failure screen and log tail still render, and the serial console still captures them.
### Why here and not in the caller
The dashboard is the only process that owns the visible UI. Suppressing its prompts anywhere else means editing scripts it invokes — the `finished.sh` `sed` from PR #72 — which breaks the moment those scripts move.
### Acceptance criteria
- Autoinstall run reaches the finish screen and reboots with no input.
- Autoinstall run whose install fails renders the failure screen, exits non-zero, and does not hang.
- Interactive run still prompts for reboot and still offers the failure menu.
- `OMARCHY_UI_INTERACTIVE=no` plus `OMARCHY_UI_AUTO_REBOOT=no` stops at the finish screen without prompting.
---
## 3) SSH keys as an orchestrator phase
### Files
- `configs/airootfs/usr/local/bin/omarchy-iso-install`
- `configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py`
- `configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py`
- `configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py`
### Changes
- `omarchy-iso-install`: add `--authorized-keys-file` to the arg loop, exporting `OMARCHY_INSTALL_AUTHORIZED_KEYS_FILE`. Same shape as the five existing flags, ahead of the `unknown arg` catch-all (`:19`).
- `context.py`: add an `authorized_keys_path: Path | None` field populated from that variable in `from_env`, `None` when unset or when the file does not exist.
- `phases_impl.py`: add `configure_ssh_access(ctx)`:
- Return immediately when `ctx.authorized_keys_path` is `None`.
- Read the file as sshd's own authorized_keys format, dropping blank lines and # comments; write the keys one per line to `<target>/home/<user>/.ssh/authorized_keys`.
- `.ssh` at `0700`, `authorized_keys` at `0600`, both owned by the user via `arch-chroot <target> chown -R <user>:<user>`. Do not hardcode uid 1000 as PR #72 did — ask the target for it.
- `arch-chroot <target> systemctl enable sshd.service`. Works cleanly in the chroot.
- `arch-chroot <target> ufw allow ssh`. This one exits **non-zero** in a chroot (`ERROR: problem running`) because ufw cannot reach netfilter, but it writes the rule to `/etc/ufw/user.rules` before failing, and that file is what `ufw.service` loads on first boot. So ignore the exit status and assert the rule landed in the file instead. No ufw-presence check: ufw is part of Omarchy's default package set, and the rule assertion already fails loudly if it is somehow missing.
- An `authorized_keys` with no usable keys must fail the phase loudly. A machine that installs "successfully" and is then unreachable is worse than one that stops with an error on screen.
- `main.py`: register `("Configuring SSH access", configure_ssh_access)` in `build_phases` after `configure_login` (`:52`) and before `validate_boot`.
### Model to follow
`configure_login` (`phases_impl.py:1251`) — direct writes against `ctx.target`, `arch-chroot` reserved for ownership and `systemctl`. Match its structure.
### Acceptance criteria
- Install with `authorized_keys`: `ssh <user>@<vm>` works with the corresponding private key on first boot, no password.
- Install without `authorized_keys`: phase runs, does nothing, install unaffected; `~/.ssh` is not created.
- `authorized_keys` is `0600` and owned by the install user, not root.
- Port 22 is open in ufw on the installed system. Test this by connecting, not by reading the phase log — the rule is written from a chroot that cannot verify it.
- Password SSH auth is left at the distro default — autoinstall does not enable it.
- Interactive installs are unaffected: the flag is passed but the file never exists.
---
## 4) Documentation
### File
- `README.md`
### Changes
- New "Autoinstall" section covering: what the `cidata` drive is, the file table, how to produce the drive, and a Proxmox `qm create` example with disk-first boot order so the empty disk falls through to the ISO on the first boot and boots the installed system afterward.
- Show `openssl passwd -6` for the credential hash and the `authorized_keys` format.
- State plainly that the config files are the configurator's own output — the documented way to get a starting `user_configuration.json` is to run one interactive install and copy what it wrote.
- Do not repeat PR #72's networking claims. NetworkManager comes enabled with DHCP on a stock install; sshd ships disabled, which is why the SSH phase exists.
---
## 5) Validation
### Unit tests
`./test/all` runs everything under `test/`: `cidata-load-test.sh` exercises `omarchy-cidata-load` against a sandbox with `mount`/`umount`/`udevadm` stubbed, and `test_configure_ssh_access.py` runs the orchestrator phase against a temp target with `subprocess.run` recorded and ufw's write-the-rule-then-fail chroot behavior simulated. The Python tests run through `unittest` discovery alongside the repo's existing `test_*.py` suites.
### Manual matrix
1. Build the ISO from this branch.
2. Interactive control run: boot with no `cidata` drive, install normally, confirm nothing changed.
3. Autoinstall run on Proxmox: attach ISO + `cidata` drive, boot, no input; confirm the configurator never renders, the install completes, the machine reboots on its own, and SSH with the key works against the booted system.
4. Encrypted autoinstall: `user_encrypt_installation.txt` set true; confirm the LUKS prompt appears on boot (this is the one autoinstall case that still needs a human at first boot — document it).
5. Full Packer cycle: build, destroy, rebuild from the same `cidata` drive; confirm reproducibility.
### Negative tests
- Drive labeled `cidata` with no files → configurator runs.
- Drive with `user_configuration.json` only → configurator runs.
- An `authorized_keys` with no usable keys → install stops in the remote-access phase with a visible error, does not reboot into an unreachable machine.
- Install failure under autoinstall (e.g. a disk target that does not exist) → failure screen renders, process exits non-zero, no hang.
### Regression surface
The interactive path shares every line of this change except the `omarchy-cidata-load` branch, so run the standard acceptance harness (`./bin/omarchy-iso-test`) on the built ISO before proposing the PR.
---
## Relationship to PR #72
PR #72 targeted the pre-`quattro` tree and cannot be rebased: `use_omarchy_helpers`, `run_configurator`, `install_arch`, `install_omarchy`, and `chroot_bash` no longer exist. This plan reimplements the same feature against the current tree and drops three things that PR deliberately flagged as questionable:
- The `finished.sh` `sed` — replaced by section 2. The dashboard renders the reboot prompt now.
- The `systemd-networkd` DHCP unit — unnecessary; NetworkManager is already enabled and would contend with it.
- The `PasswordAuthentication yes` rewrite — unnecessary and a security downgrade.
`load_cidata` is the one piece that carries over close to intact. PR #72's `ufw allow ssh` also carries over: it was dropped from the first draft of this plan on the reasoning that an enabled sshd is a reachable sshd, and a test install proved otherwise. That PR was right to include it.
+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.
View File
Whitespace-only changes.
Executable
+13
View File
@@ -0,0 +1,13 @@
#!/bin/bash
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
for test in "$ROOT"/test/*-test.sh; do
printf '==> %s\n' "${test#$ROOT/}"
bash "$test"
done
printf '==> test/test_*.py\n'
python3 -m unittest discover -t "$ROOT" -s "$ROOT/test"
+172
View File
@@ -0,0 +1,172 @@
#!/bin/bash
#
# Unit tests for omarchy-cidata-load. The script takes a path prefix, so every
# case runs against a throwaway sandbox with mount/umount/udevadm stubbed out:
# "mounting" copies the fake drive's contents into the mountpoint, and every
# stub logs its invocation so the cases can assert what was (not) called.
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
CIDATA_LOAD="$ROOT/configs/airootfs/usr/local/bin/omarchy-cidata-load"
pass() {
printf 'ok - %s\n' "$1"
}
fail() {
local description="$1"
local detail="${2:-}"
[[ -n $detail ]] && printf '%s\n' "$detail" >&2
printf 'not ok - %s\n' "$description" >&2
exit 1
}
work=$(mktemp -d)
trap 'chmod -R u+w "$work"; rm -rf "$work"' EXIT
stub_dir="$work/stubs"
mkdir -p "$stub_dir"
cat >"$stub_dir/udevadm" <<'STUB'
#!/bin/bash
printf 'udevadm %s\n' "$*" >>"$TEST_LOG"
STUB
cat >"$stub_dir/mount" <<'STUB'
#!/bin/bash
printf 'mount %s\n' "$*" >>"$TEST_LOG"
[[ ${MOUNT_FAIL:-} == 1 ]] && exit 32
device=$3 mountpoint=$4
cp -a "$(readlink -f "$device")"/. "$mountpoint"/
STUB
cat >"$stub_dir/umount" <<'STUB'
#!/bin/bash
printf 'umount %s\n' "$*" >>"$TEST_LOG"
STUB
chmod +x "$stub_dir"/*
new_sandbox() {
sandbox=$(mktemp -d "$work/sandbox.XXXXXX")
mkdir -p "$sandbox/dev/disk/by-label" "$sandbox/root" "$sandbox/media"
export TEST_LOG="$sandbox/calls.log"
: >"$TEST_LOG"
}
attach_drive() {
ln -s "$sandbox/media" "$sandbox/dev/disk/by-label/$1"
}
run_load() {
PATH="$stub_dir:$PATH" "$CIDATA_LOAD" "$sandbox"
}
write_required_pair() {
echo '{"disk_config": {}}' >"$sandbox/media/user_configuration.json"
echo '{"users": []}' >"$sandbox/media/user_credentials.json"
}
# No cidata drive attached: fall back to the wizard without mounting anything.
new_sandbox
! run_load || fail "no drive exits non-zero"
! grep -q '^mount ' "$TEST_LOG" || fail "no drive mounts nothing"
pass "no drive falls back to the wizard"
# The probe must wait for udev to finish enumerating before concluding there
# is no drive.
grep -q '^udevadm settle$' "$TEST_LOG" || fail "probe settles udev first"
pass "probe settles udev first"
# A drive with the full file set: everything lands in /root and the drive is
# unmounted afterwards.
new_sandbox
attach_drive cidata
write_required_pair
echo "Jeff" >"$sandbox/media/user_full_name.txt"
echo "jeff@example.com" >"$sandbox/media/user_email_address.txt"
echo "false" >"$sandbox/media/user_encrypt_installation.txt"
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"
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"
done
grep -q '^umount ' "$TEST_LOG" || fail "full file set unmounts the drive"
pass "full file set loads, copies everything, and unmounts"
# The uppercase label variant some tools produce works too.
new_sandbox
attach_drive CIDATA
write_required_pair
run_load || fail "uppercase CIDATA label loads"
pass "uppercase CIDATA label loads"
# The required pair alone is a valid autoinstall drive; the optional files
# stay optional.
new_sandbox
attach_drive cidata
write_required_pair
run_load || fail "required pair alone loads"
[[ ! -e $sandbox/root/authorized_keys ]] || fail "required pair alone copies no optional files"
pass "required pair alone loads without optional files"
# Optional files are copied individually when present.
new_sandbox
attach_drive cidata
write_required_pair
echo 'ssh-ed25519 AAAA jeff@host' >"$sandbox/media/authorized_keys"
run_load || fail "required pair plus authorized_keys loads"
[[ -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/tailscale_authkey ]] || fail "absent tailscale_authkey is not copied"
pass "present optional files are copied, absent ones skipped"
# Half the required pair is not an autoinstall drive: unmount and fall back.
new_sandbox
attach_drive cidata
echo '{"disk_config": {}}' >"$sandbox/media/user_configuration.json"
! run_load || fail "half the required pair exits non-zero"
[[ ! -e $sandbox/root/user_configuration.json ]] || fail "half the required pair copies nothing"
grep -q '^umount ' "$TEST_LOG" || fail "half the required pair still unmounts"
pass "half the required pair falls back and unmounts"
# An empty drive labeled cidata is not an autoinstall drive either.
new_sandbox
attach_drive cidata
! run_load || fail "empty drive exits non-zero"
grep -q '^umount ' "$TEST_LOG" || fail "empty drive still unmounts"
pass "empty drive falls back and unmounts"
# A drive that will not mount falls back rather than failing the boot.
new_sandbox
attach_drive cidata
write_required_pair
! MOUNT_FAIL=1 run_load || fail "mount failure exits non-zero"
! grep -q '^umount ' "$TEST_LOG" || fail "mount failure has nothing to unmount"
pass "mount failure falls back to the wizard"
# A copy failure must not report a loaded drive: the install would start with
# missing inputs. Fall back and let the wizard produce them instead.
new_sandbox
attach_drive cidata
write_required_pair
chmod 555 "$sandbox/root"
! run_load 2>/dev/null || fail "copy failure exits non-zero"
grep -q '^umount ' "$TEST_LOG" || fail "copy failure still unmounts"
pass "copy failure falls back and unmounts"
# A partial copy must clean up after itself: the wizard removes only what it
# writes, so anything the loader left behind would leak into the interactive
# install that follows.
new_sandbox
attach_drive cidata
write_required_pair
echo 'ssh-ed25519 AAAA jeff@host' >"$sandbox/media/authorized_keys"
mkdir "$sandbox/root/user_credentials.json"
! run_load 2>/dev/null || fail "partial copy exits non-zero"
[[ ! -e $sandbox/root/user_configuration.json ]] || fail "partial copy removes what it copied"
[[ ! -e $sandbox/root/authorized_keys ]] || fail "partial copy leaves no authorized_keys behind"
pass "partial copy cleans up what it copied"
+153
View File
@@ -0,0 +1,153 @@
"""Unit tests for the orchestrator's configure_ssh_access phase.
The phase touches the target only through the filesystem and arch-chroot, so
the tests run it 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_ssh_access
# 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 -p tcp --dport 22 -j ACCEPT\n"
class ConfigureSshAccessTest(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, authorized_keys=None):
authorized_keys_path = None
if authorized_keys is not None:
authorized_keys_path = self.target / "authorized_keys"
authorized_keys_path.write_text(authorized_keys)
return types.SimpleNamespace(target=self.target, username="jeff", authorized_keys_path=authorized_keys_path)
def configure(self, **kwargs):
phases_impl.configure_ssh_access(self.ctx(**kwargs))
def authorized_keys(self):
return self.target / "home" / "jeff" / ".ssh" / "authorized_keys"
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_authorized_keys_is_a_no_op(self):
self.configure()
self.assertEqual(self.calls, [])
self.assertFalse((self.target / "home" / "jeff" / ".ssh").exists())
def test_installs_keys_one_per_line(self):
self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\nssh-rsa BBBB jeff@work\n")
self.assertEqual(self.authorized_keys().read_text(), "ssh-ed25519 AAAA jeff@host\nssh-rsa BBBB jeff@work\n")
def test_drops_blank_lines_and_comments(self):
self.configure(authorized_keys="# work laptop\n\n ssh-ed25519 AAAA jeff@host \n")
self.assertEqual(self.authorized_keys().read_text(), "ssh-ed25519 AAAA jeff@host\n")
def test_keys_with_options_pass_through(self):
key = 'command="/usr/bin/true",no-pty ssh-ed25519 AAAA jeff@host'
self.configure(authorized_keys=f"{key}\n")
self.assertEqual(self.authorized_keys().read_text(), f"{key}\n")
def test_ssh_dir_is_private(self):
self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n")
mode = stat.S_IMODE((self.target / "home" / "jeff" / ".ssh").stat().st_mode)
self.assertEqual(mode, 0o700)
def test_authorized_keys_is_private(self):
self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n")
self.assertEqual(stat.S_IMODE(self.authorized_keys().stat().st_mode), 0o600)
def test_chowns_ssh_dir_to_the_user(self):
self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n")
self.assertEqual(self.chrooted("chown"), [
["arch-chroot", str(self.target), "chown", "-R", "jeff:jeff", "/home/jeff/.ssh"],
])
def test_enables_sshd(self):
self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n")
self.assertEqual(self.chrooted("systemctl"), [
["arch-chroot", str(self.target), "systemctl", "enable", "sshd.service"],
])
def test_allows_ssh_through_ufw_despite_chroot_exit_status(self):
self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n")
self.assertEqual(self.chrooted("ufw"), [
["arch-chroot", str(self.target), "ufw", "allow", "ssh"],
])
def test_fails_when_ufw_does_not_record_the_rule(self):
self.ufw_writes_rule = False
with self.assertRaisesRegex(RuntimeError, "allow rule for port 22"):
self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n")
def test_empty_file_fails_the_phase(self):
with self.assertRaisesRegex(RuntimeError, "contains no SSH keys"):
self.configure(authorized_keys="")
def test_blank_only_file_fails_the_phase(self):
with self.assertRaisesRegex(RuntimeError, "contains no SSH keys"):
self.configure(authorized_keys="\n \n")
def test_comment_only_file_fails_the_phase(self):
with self.assertRaisesRegex(RuntimeError, "contains no SSH keys"):
self.configure(authorized_keys="# no keys here\n")
class OptionalPathTest(unittest.TestCase):
def test_unset_and_missing_paths_are_none(self):
from orchestrator.context import _optional_path
self.assertIsNone(_optional_path(None))
self.assertIsNone(_optional_path(""))
self.assertIsNone(_optional_path("/does/not/exist/authorized_keys"))
def test_existing_path_is_returned(self):
from orchestrator.context import _optional_path
with tempfile.NamedTemporaryFile() as f:
self.assertEqual(_optional_path(f.name), Path(f.name))
if __name__ == "__main__":
unittest.main()
+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()
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/python
import importlib.util
import re
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MODULE_PATH = ROOT / "configs/airootfs/usr/share/omarchy-iso/orchestrator/keyboard.py"
SPEC = importlib.util.spec_from_file_location("installer_keyboard", MODULE_PATH)
KEYBOARD = importlib.util.module_from_spec(SPEC)
assert SPEC.loader
SPEC.loader.exec_module(KEYBOARD)
CONFIGURATOR = (ROOT / "configs/airootfs/root/configurator").read_text()
KEYBOARD_BLOCK = re.search(r"keyboards=\$'(.*?)'\n choice=", CONFIGURATOR, re.DOTALL)
assert KEYBOARD_BLOCK
SUPPORTED_KEYMAPS = [
line.split("|", 1)[1]
for line in KEYBOARD_BLOCK.group(1).splitlines()
]
UNSUPPORTED_KEYMAPS = {"ba", "khmer"}
class KeyboardConfigurationTest(unittest.TestCase):
def target(self, directory: str) -> Path:
target = Path(directory)
(target / "etc").mkdir()
(target / "etc/vconsole.conf").write_text(
"KEYMAP=us\nFONT=default8x16\n"
)
return target
def test_writes_keymap_and_xkb_settings_and_preserves_font(self):
# XKBLAYOUT is load-bearing: omarchy's detect-keyboard-layout.sh copies
# it into Hyprland's kb_layout on the installed system.
with tempfile.TemporaryDirectory() as directory:
target = self.target(directory)
self.assertTrue(KEYBOARD.configure_keyboard(target, "us"))
lines = (target / "etc/vconsole.conf").read_text().splitlines()
self.assertIn("KEYMAP=us", lines)
self.assertIn("XKBLAYOUT=us", lines)
self.assertEqual(lines.count("FONT=default8x16"), 1)
def test_all_configurator_keymaps_are_known_to_localectl(self):
for keymap in SUPPORTED_KEYMAPS:
with self.subTest(keymap=keymap), tempfile.TemporaryDirectory() as directory:
target = self.target(directory)
configured = KEYBOARD.configure_keyboard(target, keymap)
self.assertEqual(configured, keymap not in UNSUPPORTED_KEYMAPS)
if configured:
lines = (target / "etc/vconsole.conf").read_text().splitlines()
self.assertIn(f"KEYMAP={keymap}", lines)
self.assertEqual(lines.count("FONT=default8x16"), 1)
def test_unknown_and_empty_keymaps_match_archinstall_behavior(self):
for keymap, expected in (("definitely-not-a-keymap", False), ("", True)):
with self.subTest(keymap=keymap), tempfile.TemporaryDirectory() as directory:
target = self.target(directory)
self.assertEqual(KEYBOARD.configure_keyboard(target, keymap), expected)
self.assertEqual(
(target / "etc/vconsole.conf").read_text(),
"KEYMAP=us\nFONT=default8x16\n",
)
if __name__ == "__main__":
unittest.main()
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/python
import subprocess
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
PRUNER = ROOT / "builder/prune-offline-mirror.sh"
class OfflineMirrorPruningTest(unittest.TestCase):
def run_pruner(self, mirror: Path, required: list[str]):
return subprocess.run(
["bash", str(PRUNER), str(mirror)],
input="".join(f"{filename}\n" for filename in required),
capture_output=True,
text=True,
)
def add_file(self, mirror: Path, filename: str):
(mirror / filename).write_bytes(b"package")
def test_keeps_only_exactly_selected_package_archives(self):
with tempfile.TemporaryDirectory() as directory:
mirror = Path(directory)
keep = "keep-2.0-1-x86_64.pkg.tar.zst"
other = "other-1.0-1-any.pkg.tar.zst"
stale_version = "keep-1.0-1-x86_64.pkg.tar.zst"
removed_package = "removed-1.0-1-x86_64.pkg.tar.zst"
orphan_signature = "interrupted-1.0-1-x86_64.pkg.tar.zst.sig"
for filename in (keep, other, stale_version, removed_package):
self.add_file(mirror, filename)
for filename in (keep, stale_version, removed_package):
self.add_file(mirror, f"{filename}.sig")
self.add_file(mirror, orphan_signature)
self.add_file(mirror, "offline.db.tar.gz")
result = self.run_pruner(mirror, [keep, other])
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(
{path.name for path in mirror.iterdir()},
{keep, f"{keep}.sig", other, "offline.db.tar.gz"},
)
self.assertIn(stale_version, result.stdout)
self.assertIn(removed_package, result.stdout)
def test_missing_selection_aborts_before_deleting_anything(self):
with tempfile.TemporaryDirectory() as directory:
mirror = Path(directory)
cached = "cached-1.0-1-x86_64.pkg.tar.zst"
self.add_file(mirror, cached)
result = self.run_pruner(
mirror, ["missing-1.0-1-x86_64.pkg.tar.zst"]
)
self.assertNotEqual(result.returncode, 0)
self.assertTrue((mirror / cached).exists())
self.assertIn("selected packages are missing", result.stderr)
def test_empty_selection_aborts_before_deleting_anything(self):
with tempfile.TemporaryDirectory() as directory:
mirror = Path(directory)
cached = "cached-1.0-1-x86_64.pkg.tar.zst"
self.add_file(mirror, cached)
result = self.run_pruner(mirror, [])
self.assertNotEqual(result.returncode, 0)
self.assertTrue((mirror / cached).exists())
self.assertIn("empty package selection", result.stderr)
if __name__ == "__main__":
unittest.main()