Install with archinstall-bash: moving towards systemd as the orchestrator - #132
Install with archinstall-bash: moving towards systemd as the orchestrator#132hegjon wants to merge 177 commits into
Conversation
The target install is the same ~940 packages for every machine; only the kernel, CPU microcode, audio firmware and Tailscale differ. pacman extracts that set single-threaded at ~40 packages/s, about 34s on any machine with four or more cores, and no amount of parallelism reaches it. So build-root-image.sh pacstraps the invariant set once at ISO build time into a btrfs subvolume mounted compress=zstd:3 (the level the installer mounts the target with) and ships it as a `btrfs send --compressed-data` stream. The orchestrator receives it at the target filesystem's top level right after archinstall mounts the layout, snapshots it writable in place of the empty @ subvolume, replays the mount table, and then lets archinstall finish with the per-machine delta (install_base_delta mirrors minimal_installation minus the bulk pacstrap), users and fstab. The application installers strap only what the target lacks, since the image carries their package sets and the mirror no longer does. Measured in the same 16-vCPU VM: the package phase drops from 35.7s to 24.5s and the whole install from 41.8s to 30.6s, with an identical set of 942 packages installed; the installed system boots. The receive itself is ~17s and independent of CPU count. The offline mirror keeps only what is still pacstrapped at install time: the live ISO's own packages, the per-machine packages, and the omarchy-other.packages extras omarchy-apply-system may pull in, resolved against the offline repo itself so the keep-set can only name files the mirror holds. The ISO grows from 6.2GB to 9.1GB, the live closure and the extras now sitting beside the 5.2GB image. Build details: the container needs loop device nodes made by hand (Docker fills /dev once, at start), pacman-key's gpg-agent must be stopped before the image unmounts, and stale copies of locally rebuilt omarchy packages are evicted from the shared pacman cache so mkarchiso's pacstrap does not hit a checksum mismatch. The dashboard gets a phase_progress signal from the unpack so the bar moves while the local pacman db is still empty, and the live ISO prefetches the leading bytes of the stream during the wizard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P9b7oP8j2GA6sZZ9e8aJYv
btrfs's incompressibility heuristic skips a lot of data in this tree that zstd compresses fine, and the level only costs build time: btrfs receive stores the extents as they arrive. Against plain compress=zstd:3 the send stream goes from 5.2GB to 3.3GB and the installed root from 5.4GB to 4.0GB, at the same ~17s receive time; level 15 adds under two minutes to the ISO build. btrfs clamps anything above 15. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P9b7oP8j2GA6sZZ9e8aJYv
--free-space installs into the unallocated tail of a synthetic Windows-style disk: a FAT32 ESP and an ext4 data partition with a marker file, then ~76GiB of free space. The fixture is built without root (parted on the raw file, mkfs at the partition offsets), so unlike omarchy-iso-test-windows-disk it carries no EFI/Microsoft directory; the configurator's free-space mechanics are the same either way. The wizard is driven through the mode picker and the free-space confirm, and once the installed system is up the harness checks from inside it that both pre-existing partitions and the marker survived. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P9b7oP8j2GA6sZZ9e8aJYv
mkarchiso pacstraps the live root from the complete mirror at build time, but at install time only packages the root image lacks can ever be downloaded from it. The live root's customize_airootfs.sh removes every package file the image already holds at the same version from its copy of the mirror, keeping the repo db complete so `pacman -S --needed` over the hardware scripts' mixed package lists still resolves every name. The build cache goes back to keeping the whole download closure, so a rebuild downloads nothing; the shipped selection is decided per build from the image's local db. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P9b7oP8j2GA6sZZ9e8aJYv
Non-interactive guided install from archinstall's --config/--creds JSON, scoped to what omarchy-iso's orchestrator uses: sfdisk partitioning, LUKS2, btrfs subvolumes, pacstrap, users, locale, zram swap, network, PipeWire/Bluetooth, Limine. Mirrors upstream ede2bd3. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G1eQiTQoDh5Jp6Cq7xmTyU
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G1eQiTQoDh5Jp6Cq7xmTyU
Drop bootloader installation (Limine/UKI/Plymouth — the orchestrator does it), key files for non-root LUKS partitions, snapper/timeshift, append-mode partitioning, the NTP/reflector/WKD waits, nm/iwd network types, PulseAudio and the extra minimal_installation options. Configurations that ask for them now fail with a clear message. 2781 → 2327 lines. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G1eQiTQoDh5Jp6Cq7xmTyU
Keeps the parsed configuration and installer state in a 0600 file between invocations so a driver in another language can run the install as the sequence of calls archinstall's Installer exposes; `query` prints the partitions, flags and kernel cmdline as JSON. installer_finish now prints upstream's three-line completion banner. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G1eQiTQoDh5Jp6Cq7xmTyU
The orchestrator now drives archinstall-bash (new submodule, shipped at /usr/share/archinstall-bash) one archinstall-step process at a time; archinstall_adapter.py is the only module that knows the command lines, and `query` hands the Limine setup the partition nodes, PARTUUIDs and kernel cmdline that used to come from Installer internals. The Python archinstall package leaves the live ISO (python and kbd, which it pulled in, are listed explicitly). keyboard.py goes: the library writes the keymap the same way, with systemd-firstboot, inside minimal-installation. The NTP/reflector/WKD sanity waits were always skipped here and the port has none. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G1eQiTQoDh5Jp6Cq7xmTyU
mkarchiso copies the airootfs without preserving modes and applies profiledef's file_permissions afterwards; the submodule copy in build-iso.sh bypasses the lint that catches this for configs/airootfs, so the step runner landed 0644 and the first phase died with EACCES. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G1eQiTQoDh5Jp6Cq7xmTyU
Comments and docstrings that still described it, and the archinstall-language key the configurator wrote into every user_configuration.json — it only ever picked the Python TUI's language. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G1eQiTQoDh5Jp6Cq7xmTyU
bash does not set $! for >(…), so the step runner (set -euo pipefail) died with 'unbound variable' right after a successful pacstrap. Stream through a plain pipe with a function-local pipefail instead; covered by tests/test_step.sh. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G1eQiTQoDh5Jp6Cq7xmTyU
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G1eQiTQoDh5Jp6Cq7xmTyU
main.sh runs the same fourteen phases in one shell, sourcing archinstall-bash directly instead of spawning archinstall-step; the modules map to the Python ones (context, phases, install, limine, target_setup, provisioning, lifecycle, ui). Failures — fail(), the library's die(), or any failing command under errexit — land in state.json for the dashboard through the EXIT trap, which also does the cleanup Python's finally did. The unit tests move to bash (test/unit/orchestrator-*-test.sh, sharing a harness that shadows arch-chroot/mount/findmnt/btrfs) and cover the context, the phase runner's failure paths, provisioning, login, SSH, Tailscale and the factory snapshot. python leaves the ISO package list. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G1eQiTQoDh5Jp6Cq7xmTyU
They were the remaining reasons Python (plus ~20 python-* libraries, ~95 MiB uncompressed) was on the ISO after archinstall left: the cidata autoinstall drive is read by omarchy-cidata-load, and reflector's service was already removed for the offline install. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G1eQiTQoDh5Jp6Cq7xmTyU
After cloud-init and reflector left, the rebuilt ISO still carried python 3.14 — clonezilla (releng default) depends on it directly. Nothing on the ISO clones disks; the partition tools it pulls in are releng entries of their own and remain. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G1eQiTQoDh5Jp6Cq7xmTyU
They let a driver in another language run the install one process at a time; Omarchy's orchestrator is bash now and sources the library, and no other driver exists. The sys_cmd_peek regression test moves to tests/test_config.sh. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G1eQiTQoDh5Jp6Cq7xmTyU
Redirections apply left to right, so an unprivileged caller saw 'Permission denied' from the open before 2>/dev/null took effect. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G1eQiTQoDh5Jp6Cq7xmTyU
Both define info, error and list_contains; the library was sourced last (from arch_load_library inside main), so from that point the orchestrator's progress lines lost the indented shape the dashboard log is written around. main.sh now sources the library first and ui.sh wins; the dashboard's banner filter tolerates the indentation the library's own lines pick up. The archinstall-step symlink goes with the removed step runner (submodule bump). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G1eQiTQoDh5Jp6Cq7xmTyU
Restores the ordering coverage the Python suite had (a recording stub of the library; full-disk, pre-mounted, optional steps, tailscale, a failing step) and adds tests for the limine-entry-tool config parsers, the efibootmgr snapshot/boot-order handling, /etc/default/limine generation, the EFI file install and the package target / runtime package list helpers. OMARCHY_ISO_SHARE makes the ISO's share directory overridable for the latter. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G1eQiTQoDh5Jp6Cq7xmTyU
arch_install_system partitions, formats and encrypts as its first step, and only then did _install_root_image check that the stream exists and that the layout puts the root on a btrfs @ subvolume, with the LVM guard later still. Any of those failing left a wiped disk (encrypted, on that path) with no system on it. All three are predicates on the ISO and the configurator JSON, so run them in prepare_install_target, the phase before anything destructive. The protected path checks the real mounts, which exist already. Existence does not cover a truncated stream, which on a badly flashed USB is the likelier failure: btrfs receive's per-command checksums catch that too, but after the disk is gone. build-iso.sh now writes a sha256 next to the stream and the same pre-flight verifies it, which also warms the page cache for the unpack. Unit tests cover the pre-flight checks and the subvolume swap in _install_root_image, asserted on the subprocess sequence the way create_factory_snapshot already is. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XdQdQ7YZmBdbvaZNQ2xjKN
The root image was pacstrapped from a repo db built over the unpruned cache. That cache persists across builds and can hold several versions of the same package; repo-add keeps whichever file it processes last (warning on downgrade, hidden by -q) and the glob orders by name, so foo-1.9 beats foo-1.10. The mirror db was rebuilt correctly after the prune, leaving an image that could carry an older package than the mirror beside it advertises. The resolve/prune/repo-add block does not depend on the image, so run it first and drop the early repo-add: the image now resolves against exactly the files this build ships. image.packages joins the download list so the pruned mirror always holds the image's own packages. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XdQdQ7YZmBdbvaZNQ2xjKN
0cefda3 hardcoded -smp 16 while benchmarking the image receive and never mentioned it; it oversubscribes the VM on anything with fewer cores. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XdQdQ7YZmBdbvaZNQ2xjKN
The resolved count is the image's package count plus the kernel closure, so since the image replaced the full pacstrap it is the one build-time signal that would catch a short root image; a WARNING that ships no denominator and lets the build continue is not enough for that. The shipped-mirror selection right above already exits on a bad count. Also let the zero case of that selection reach its error message: grep -c exits 1 on no match, which under set -e killed the script before the "looks wrong: 0 of N" line could print. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XdQdQ7YZmBdbvaZNQ2xjKN
The hook masking moved whatever was at the hook path to .omarchy-backup and put a /dev/null symlink in its place, with no check for a mask left behind by a run that died before its cleanup. A second run would then move the symlink over the real backup and mask the host's hook for good. Inert for the ISO build (fresh container every time), but the script documents itself as runnable standalone; skip such hooks the way the orchestrator's _is_devnull_symlink does. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XdQdQ7YZmBdbvaZNQ2xjKN
Seed the send stream into mkarchiso's work/iso tree next to airootfs.sfs instead of the live squashfs: mkarchiso packs that directory as is, with the boot records intact, and the live system reads the stream straight off the boot medium at /run/archiso/bootmnt/arch/x86_64/omarchy-root.btrfs. Measured against the squashfs location, same build cache, back-to-back: the ISO is byte-for-byte the same size, mkarchiso is 5s quicker (no 3GB copy into the squashfs), and the install's package phase drops from 18.7s to 16.2s — reading through squashfs costs a copy per 1MiB block even with no decompression. airootfs.sfs shrinks to the live root, and the image can be pulled out of the ISO with any ISO9660 tool. The orchestrator and the wizard-time prefetch look at the ISO path first and fall back to the squashfs path, so mixed old/new pieces still work. Builds before this left the stream in the persistent build cache, where it would ship a second time; the build removes it. The build also logs timestamps around the image step and mkarchiso. The pre-flight checksum follows the stream: build-iso.sh writes the sha256 next to it in the ISO tree, and the orchestrator derives the checksum path from whichever stream location it finds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XdQdQ7YZmBdbvaZNQ2xjKN
The image was pacstrapped with -K, so it carried /etc/pacman.d/gnupg with a master key (secring.gpg) that every install would share. A signing key must never be distributed; pacstrap the image with -G and remove anything a scriptlet might have seeded regardless, as omacom#108 does. On a target pacstrapped directly, as on quattro, pacstrap -K initialised a per-machine keyring and the keyring packages' scriptlets populated it in the same run. Here those packages come from the image, where their scriptlets ran with no keyring to populate, so the orchestrator does it: after the last pacstrap (each one runs its own pacman-key --init on the target), pacman-key --init, idempotent for the key the delta pacstrap already generated, then --populate archlinux omarchy from the target's own keyring files. Chroot-free via --gpgdir and --populate-from, and the gpg daemons are killed on every path so the target can be unmounted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XdQdQ7YZmBdbvaZNQ2xjKN
pacman-key --init/--populate took the critical path synchronously and left a gpg-agent and dirmngr to be killed by name afterwards. Start it with systemd-run --wait --pipe right after the last pacstrap instead, and join it in create_factory_snapshot: nothing in between reads the keyring (the offline repo is SigLevel = Never) or writes it, so the Limine, user and finalizer phases hide its few seconds, and the snapshot waits so @factory never captures it half-written. A unit rather than a detached child: systemd kills the gpg daemons with the rest of the cgroup the moment pacman-key exits, so no sockets under the target's gnupg dir survive to block the unmount; the dashboard's process-group kill does not reach it while systemctl stop still does, which main() runs on every exit path; and its output lands in the journal whatever happens to the orchestrator. --wait --pipe give a child to join with the unit's exit status and output; --collect releases the name after a failure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XdQdQ7YZmBdbvaZNQ2xjKN
The pre-flight sha256 of the root image ran inline in the orchestrator's prepare_install_target phase: 1.6s on an NVMe-backed VM, tens of seconds from a USB stick, all of it after the user had pressed Install. The medium sits idle while the user works through the configurator, so move the read there: omarchy-root-image-verify.service runs `sha256sum -c` on the ISO copy of the stream as a oneshot at boot, niced and at idle I/O class, with RemainAfterExit so the verdict persists. prepare_install_target now only collects it: done → go on, failed → the corrupt-medium error with the unit's journal tail, still running → wait with progress read from the hasher's /proc/PID/io, never started → start it and wait. Measured in the install harness, the phase drops from 1.6s to 0.0s. The unit is the only verifier: the inline hashlib loop goes, and with it the squashfs fallback location for the stream, which only existed so a live root and an orchestrator from either side of the move to the plain ISO file could still pair up. Every ISO now ships the stream and its checksum at /run/archiso/bootmnt/arch/x86_64, which both conditions of the unit require; an orchestrator that finds no unit to ask fails the install instead of hashing quietly. The wizard-time prefetch in .automated_script.sh waits for the unit before reading the image: two sequential readers on one USB stick seek against each other, and the unit's pass is the warm-up anyway. Its head read afterwards is a cache hit where the image fit, and re-warms the leading bytes where a small budget let the kernel drop them behind the hash. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DvydT2sYFgBvji2nk9QbDp
A corrupt install medium failed the install with the right error and the
disk untouched, but the dashboard centres each line of the failure in ~80
columns and clipped the one sentence that mattered: "root image stream is
corrupt: omarchy-ro…" — the advice to re-flash never reached the screen,
and four of the five "last log lines" were systemd's exit/failed/consumed
boilerplate, which had pushed sha256sum's own "FAILED" line out of the
tail. Seen on a throttled-cdrom run with one digit of the recorded sha256
flipped.
Lead with the action on its own short line ("install medium is corrupt:
re-flash it"), put the detail on the next, and append only what sha256sum
wrote (journalctl -u <unit> _COMM=sha256sum) instead of the last five
journal lines. The dashboard folds the failed phase's lines at word
boundaries rather than truncating them.
The corrupt-image integration scenario keeps it that way: copy the ISO,
flip one hex digit of the checksum in place (ISO9660 has no per-file
integrity data; reflink makes the copy free), autoinstall from it, and
assert the verify unit failed, the install halted in the pre-flight phase
with the re-flash advice and sha256sum's verdict, nothing later ran, the
target disk still has no partition table, and both the stop screen and the
advice are visible. It boots the ISO itself rather than the installed base
and reaches the live root over SSH through a tty3 console login, now a
base-test.sh helper (bootstrap_live_root_ssh / ssh_live_root).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DvydT2sYFgBvji2nk9QbDp
…pkill
Writing over a session with an install in flight tore the install's
units out from under it: the gate blamed the medium ("install medium is
corrupt") for a stop we caused, and the failure screen painted over the
console notice. The prepare now SIGSTOPs the dashboard and orchestrator
first -- a stopped process observes nothing, so nothing misleading lands
anywhere, and the session was sacrificial to begin with.
The first attempt froze itself: the whole prepare script rides in the
remote shell's cmdline, so pkill -f matched -- and stopped -- the shell
running it, mid-line (pkill spares itself, not its parent). Bracketing
one character of each pattern breaks the self-match, the same trick the
wait-loop pgreps already use.
Field-proven: a write fired into a running install left the console
showing only the notice, and the write streamed on undisturbed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YVPbW91RD1JkdAK5dXxJab
|
The install graph now fans out. After the provisioning phase, two branches run concurrently — the Limine phase (the UKI build, the longest tail phase) beside a user branch (user → login → ssh → tailscale → dns) — joining at validate-boot. ssh and tailscale stay ordered within their branch because both drive ufw; hibernation, system and provisioning keep their edges into Limine because they feed the initramfs. Measured on the X200: the two branches open in the same second, the full user branch (~16 s) completes inside Limine's ~23 s window, and the join fires the moment Limine latches — the tail now costs what Limine alone costs, where the serial graph spent ~39 s. The groundwork that made the flip itself a four-line unit edit:
Validated: the unit suite (23 tests), the QEMU scenario set (install, corrupt-image, slow-medium, stalled-medium, firmware-boot), repeated unattended X200 hardware installs driven by the new I will continue cleaning this up and making it more systemd idiomatic, and I want to explore whether more of the units can run in parallel. |
Round 9 caught the fixture's comment claiming more than the scenario proved: a build that hashed the wrong or a stale file would mismatch on the pristine ISO and on the corrupted copy alike, and every assertion would still pass -- and in --reuse-base and standalone runs no install ever boots the untouched ISO to catch it implicitly. The fixture now proves the claim instead of retracting it: before flipping anything it extracts the recorded digest and recomputes sha256 over the stream's extent host-side, refusing with both digests named on mismatch. Also makes the extent-failure diagnostic reachable: under set -e the read off an empty pipe killed the scenario before the guard could say what went wrong (round 9's second find, verified in a scratch shell on both sides of the fix). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YVPbW91RD1JkdAK5dXxJab
Round 9's completeness note: a KeyboardInterrupt aimed at the orchestrator's PID alone (the process-group kill and a terminal Ctrl-C both reach zstd directly) could land inside the second Popen, and `except Exception` would skip the half-built-pipeline cleanup on its way out. The block re-raises, so BaseException swallows nothing. The new test raises KeyboardInterrupt from the receive's spawn and asserts the decompressor was reaped; reverting the one word fails exactly that test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YVPbW91RD1JkdAK5dXxJab
Emits the phase graph as Mermaid, derived with the dashboard roster's own parse -- display names from ExecStart, Kahn order over the Requires= edges -- so the drawing cannot drift from the graph systemd walks, and a future edge change refreshes the picture with one command. Paste the output into a GitHub comment inside a mermaid fence and it renders inline. Three edge kinds, all derived: solid Requires=+After= phase edges, dashed After=-only ordering (the keyring before the factory snapshot), dotted RequiresMountsFor= paths. The boot-started world rides along as its own cluster (everything WantedBy=multi-user.target: the verifies, the prefetch, the CPU boost) with its After= ordering -- and the pre-flight gate's dependency on the verifies, which is no unit edge at all, is drawn from the orchestrator's own *_VERIFY_UNIT constants as a labeled "latched verdict read in-process" arrow. Unit names render in full, split over three lines so the boxes stay narrow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YVPbW91RD1JkdAK5dXxJab
|
The install graph as it stands, generated from the unit files by the new %% omarchy install graph at 3ac59e9 -- generated by bin/omarchy-systemd-graph
flowchart LR
classDef phase fill:#e3eee6,stroke:#2f7d4f,color:#20242a,font-family:monospace;
classDef aux fill:#eceff3,stroke:#8b94a1,color:#20242a,font-family:monospace,stroke-dasharray:4 3;
classDef boot fill:#fdf3e3,stroke:#a8722c,color:#20242a,font-family:monospace;
subgraph boot["boot-started (WantedBy=multi-user.target)"]
cpu_boost["omarchy-install-<br/>cpu-boost<br/>.service"]:::boot
omarchy_mirror_verify["omarchy-<br/>mirror-verify<br/>.service"]:::boot
omarchy_pacman_sync["omarchy-<br/>pacman-sync<br/>.service"]:::boot
omarchy_prefetch["omarchy-<br/>prefetch<br/>.service"]:::boot
omarchy_root_image_verify["omarchy-<br/>root-image-verify<br/>.service"]:::boot
end
omarchy_mirror_verify -. After= .-> omarchy_pacman_sync
omarchy_mirror_verify -. After= .-> omarchy_prefetch
omarchy_pacman_sync -. After= .-> omarchy_prefetch
omarchy_root_image_verify -. After= .-> omarchy_prefetch
omarchy_mirror_verify -. After= .-> omarchy_root_image_verify
omarchy_pacman_sync -. After= .-> omarchy_root_image_verify
prepare_live["omarchy-install-<br/>prepare-live<br/>.service"]:::phase
prepare_target["omarchy-install-<br/>prepare-target<br/>.service"]:::phase
disk["omarchy-install-<br/>disk<br/>.service"]:::phase
image["omarchy-install-<br/>image<br/>.service"]:::phase
strap["omarchy-install-<br/>strap<br/>.service"]:::phase
base["omarchy-install-<br/>base<br/>.service"]:::phase
hibernation["omarchy-install-<br/>hibernation<br/>.service"]:::phase
system["omarchy-install-<br/>system<br/>.service"]:::phase
provisioning["omarchy-install-<br/>provisioning<br/>.service"]:::phase
user["omarchy-install-<br/>user<br/>.service"]:::phase
limine["omarchy-install-<br/>limine<br/>.service"]:::phase
login["omarchy-install-<br/>login<br/>.service"]:::phase
ssh["omarchy-install-<br/>ssh<br/>.service"]:::phase
tailscale["omarchy-install-<br/>tailscale<br/>.service"]:::phase
dns["omarchy-install-<br/>dns<br/>.service"]:::phase
validate_boot["omarchy-install-<br/>validate-boot<br/>.service"]:::phase
factory_snapshot["omarchy-install-<br/>factory-snapshot<br/>.service"]:::phase
omarchy_mirror_verify -. "latched verdict read in-process" .-> prepare_target
omarchy_root_image_verify -. "latched verdict read in-process" .-> prepare_target
prepare_live --> prepare_target
prepare_target --> disk
disk --> image
image --> strap
mnt_mnt_var_cache_pacman_pkg["/mnt/var/cache/pacman/pkg"]:::aux
mnt_mnt_var_cache_pacman_pkg -. RequiresMountsFor .-> strap
strap --> base
base --> hibernation
hibernation --> system
system --> provisioning
provisioning --> user
provisioning --> limine
user --> login
login --> ssh
ssh --> tailscale
tailscale --> dns
dns --> validate_boot
limine --> validate_boot
validate_boot --> factory_snapshot
omarchy_target_keyring["omarchy-<br/>target-keyring<br/>.service"]:::aux
omarchy_target_keyring -. "After= only" .-> factory_snapshot
Reading it: solid arrows are |
Two moves of imperative orchestration into unit declarations, the last two candidates from the idiomatic sweep: The target's deferred-boot-hook mask around omarchy-apply-system was bash bracketing with the orchestrator's exit trap as the failure net. It is now the system unit's own ExecStartPre=/ExecStopPost= pair: systemd runs the unmask on every exit path -- failure, group abort, SIGKILL of the phase included -- which no trap can promise. The trap's unmask call goes; the limine phase's repair-and-assert before handover stays as the belt over these braces. The Pre/Post lines host their functions through run-phase but stay out of every roster: the dashboard, the timing record and the graph tool all anchor on ^ExecStart=. The live-tree binds (mirror, /opt/packages) were systemctl starts inside prepare_target_setup; the system and user units now declare them as RequiresMountsFor=, the same shape the strap's package-cache mount already has. The helper shrinks to the idempotent pacman.conf copy and the cross-process memo variable disappears. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YVPbW91RD1JkdAK5dXxJab
The audit found no dead code -- every variable, guard and marker has a consumer -- but the comments carried the migration's history alongside its conclusions. Kept: the measured rationales (the attribution race behind the handover prefix, the loud-failure-not-Condition= boundary, signals-are-teardown, the verified library-state edges). Dropped: the narration around them. 133 lines to 97, behavior untouched, the test extraction anchors intact. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YVPbW91RD1JkdAK5dXxJab
The RequiresMountsFor= migration carried a hidden hazard: Requires= from a phase unit pulls the mount's start job in at transaction dispatch, and with no ordering the binds mounted onto the live /mnt minutes before the disk phase put the target there. Ordering after disk is not enough either -- the image phase unmounts the whole target tree for the subvolume swap and replays only the layout, sweeping any earlier bind with it. The old prepare_target_setup start ran in the system phase, after the swap, which is exactly the ordering that was load-bearing. Found the loud way, twice: the user phase's bundled-Node lookup hit an empty /opt/packages on both the dispatch-time and the after-disk builds. One After=omarchy-install-image.service on each mount unit lands the binds on the target's final tree. The strap's package-cache bind gets the same edge: it has ridden the dispatch-time hazard since its own migration -- swept by the swap, pacstrap silently lost the I/O dedupe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YVPbW91RD1JkdAK5dXxJab
The filtered tee in run-phase duplicated every phase's output into the flat install log, stripped of the timestamps the journal had all along -- X200 phase timing once had to be recovered from btrfs subvolume otimes because of it. The tee goes: a phase unit's stdout is the journal's, timestamped and per-unit queryable. What each consumer keeps: the dashboard now authors the session log's phase markers itself (one line per phase transition it observes, so the support file keeps its human-readable spine and the autoinstall follow keeps working); the failure screen's tail and every scenario assertion already read dashboard- or chroot-authored lines, which stay in the file; the chroot-side OMARCHY_INSTALL_LOG_FILE contract is untouched; and phases_finalize exports the full timestamped journal onto the installed system, so the target ends up with more than it had. omarchy-iso-remote log fetches both halves. Reference logs from a green pre-migration install are banked in test-runs/log-reference/ for the completeness comparison. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YVPbW91RD1JkdAK5dXxJab
The last two mask duties leave the orchestrator's exit trap. The live system's boot-image hooks were bracketed around the pacstraps in bash with cleanup_live_hook_masks as the failure net; they are now the strap unit's own ExecStartPre=/ExecStopPost= pair, the same shape the system unit's target-side masks got -- systemd runs the unmask on every exit path, SIGKILL included. stop_target_keyring_init goes too: the keyring unit is PartOf=omarchy-install.target, so the group stop one line above it already took it down. The trap is left with the group stop and the protected-target release, which is all a trap should own. Hygiene from an earlier sweep: omarchy-prefetch gains TimeoutStartSec= 1800 -- nothing waits on it, but a reader hung on a dying medium must not linger unbounded. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YVPbW91RD1JkdAK5dXxJab
Since the phases log to the journal alone, a failure left the session log with the headline and nothing of the failing phase's own output: the pacstrap rejection, the receive's error, systemd's Failed line. That is the log "Upload log for support" (omarchy-upload-log, from the live ISO's omarchy-settings) sends and the media diagnosis greps, so both had gone quiet. The finalize export is now a function, export_install_journal, and the exit trap appends the same section to the live session log on every failure path, after the group stop has flushed the phases' last words. The END milestone moves ahead of the finalize so the journal copy exported onto the installed system carries both ends of the span. The media-diagnosis test's journalctl stub matched the query the handover no longer makes; it answers the identifier query in the line-prefixed form now, so the failed phase's words reach the screen under test as they do in a real failure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YVPbW91RD1JkdAK5dXxJab
The verifies, the prefetch, the pacman sync and the keyring unit are the install's supporting cast, and a failure often starts in one of them -- a medium the verify rejects is the first thing the export should show. The unit glob widens to omarchy-*, in the finalize/failure export and in omarchy-iso-remote's fetch of the same. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YVPbW91RD1JkdAK5dXxJab
*omarchy* rather than omarchy-*: the mirror mount units carry the name in the middle, and a mount that failed to come up is as much a part of the failure story as the verify that gated on it. Same glob in omarchy-iso-remote's fetch. A JSON companion export for field-level debugging is noted as a later stage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YVPbW91RD1JkdAK5dXxJab
The install mounts and unmounts constantly -- the stage binds, the subvolume swap, the chroots' namespaces -- and systemd's record of the mount units is where "target is busy" gets its explanation. A second -u glob, *.mount, joins *omarchy* in the export and in omarchy-iso-remote's fetch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YVPbW91RD1JkdAK5dXxJab
Catch-up with the branch this one grew out of: fifteen commits that came from quattro (the tagline, the OCR/VNC test fixes, the release tooling, the rc package channel, omarchy-iso-test-stop, the docker-through-sudo fallback) and five of its own rounds on the receive path. Three conflicts, resolved as follows. phases_impl.py and test_root_image.py stay deleted: the orchestrator is bash on this branch. Their two commits were about the receive pipeline -- reaping a decompressor whose receive never started, and running the same cleanup when a KeyboardInterrupt rather than an Exception leaves the block. Neither hazard exists here: both children are started and waited on unconditionally, a failed exec still closes its end of the fifo, and an interrupt is answered by systemd tearing down the phase unit's whole cgroup. The third intent -- naming both stages when both of them failed -- is a real gap and follows in its own commit. corrupt-image-test.sh takes the other branch's fixture. Flipping a character of the recorded checksum, which is what this branch did, proves the verify compares two strings; flipping a byte a megabyte deep inside the stream extent proves it reads the medium, which is the failure the scenario exists for. The extent is located through the ISO9660 directory records, and the pristine bytes are checked against the recorded digest before anything is damaged, so a build that hashed a stale file fails the fixture instead of passing the test. The assertions around it stay this branch's: the failing phase's words come from the journal under the handover identifier, keyed by unit prefix. test/integration gains libisoburn, which that fixture needs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YVPbW91RD1JkdAK5dXxJab
Ported from the branch this one just merged, where the same fix landed on the Python receive. A corrupt outer layer kills the decompressor and starves the receive; a receive that dies first EPIPEs the decompressor. Either way both children exit nonzero, and the exit codes do not say which one broke the pipe -- so the old headline, which named btrfs receive whenever it had failed, sent the reader after the wrong stage half the time. It names both now, and the detail the two children share disambiguates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YVPbW91RD1JkdAK5dXxJab
cryptsetup picks the encryption sector from the drive: 4096 only on 4Kn and 512e disks, 512 on anything reporting a 512-byte physical sector (SATA SSDs, most NVMe at their factory LBA format, virtio). btrfs is always at a 4096-byte sectorsize, so on those drives every filesystem block was encrypted as eight separately tweaked XTS sectors. Ask for 4096 explicitly on both call sites: the configurator's direct luksFormat (protected/free-space installs) and archinstall-bash's luks_format (full-disk installs). Partitions are MiB-aligned, so cryptsetup never has to revert to 512. Measured 2026-09-02 while working on PR omacom#132, with its encrypted unattended-install rig (same commit with and without the flag): QEMU 3+3 runs 51.8±1.4 s vs 51.0±0.9 s; X200 (Core 2 Duo, no AES-NI) 1+1 runs 359.2 s vs 355.9 s, root-image unpack through dm-crypt 112.8 s vs 111.5 s. No performance case; an alignment and consistency change kept out of omacom#132 for its own discussion. Caveat: a fresh install then shows sector 4096 in luksDump where existing systems show 512. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NJaUDuCBipUhuP1YYfEyuP
--encrypt wrote the configurator's flag file and the password into the credentials, but never a disk_config.disk_encryption block in the archinstall JSON. The disk phase reads only the JSON, so it formatted plain btrfs, while the boot validation reads the flag and expected LUKS: every remote encrypted autoinstall halted at validate-boot with "Encrypted install but /mnt/boot/limine.conf has no cryptdevice=" (X200, 2026-09-02). The validator was right; the tool was describing two different installs. Write the same block the configurator writes for a direct encrypted install (luks, iter_time 2000, the root partition's obj_id, the password), and nothing when --encrypt is not given. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NJaUDuCBipUhuP1YYfEyuP
Format LUKS2 with a 4096-byte encryption sector on both call sites, per the measurements in the merged commit and the discussion in omacom#147. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NJaUDuCBipUhuP1YYfEyuP
dfd4989 untracked configs/airootfs/root/.ssh/authorized_keys, the public key omarchy-iso-remote bakes into local test ISOs so the live system answers root SSH. Keep it out of the tree for good: a recreated key showed up as untracked, one `git add -A` away from being committed again, and an ISO built from a tree carrying it answers root SSH to that key on every machine that boots it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NJaUDuCBipUhuP1YYfEyuP
Three comments still pointed at phases_impl.py and test_root_image.py, which this branch removed with the Python orchestrator. Name what does the work now (the target-keyring unit) or say "the former Python orchestrator", so a grep for those files finds nothing dangling. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NJaUDuCBipUhuP1YYfEyuP
Five simplifications found reviewing the port on top of PR omacom#113, no behavior change, full unit suite green: - boot_intent_rel() replaces four `boot_intent … | sed 's|^/||'` sites (install.sh, limine.sh) that forked a sed to drop one leading slash; the value is stripped with ${v#/} for joining onto $CTX_TARGET, while the four sites that want the absolute mountpoint keep plain boot_intent. - root_image.sh replay_target_mounts: one `jq … @tsv` into a read loop instead of 1 + 4×N jq calls per mount row. - root_image.sh root_image_target_mounts: the four .[0] fields read in a single jq pass instead of four (empty-array case still yields an empty target and the same failure). - omarchy-iso-remote write: stream the ISO with `< "$iso"` instead of `cat "$iso" |`, one fewer process on the 6.4 GiB path. - omarchy-iso-remote check-key: capture the xorriso+unsquashfs key extraction once instead of running it twice. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NJaUDuCBipUhuP1YYfEyuP
This PR is here for early feedback, not for merging yet: it waits on the conclusion of #113 (whose content it carries),
(hegjon:archinstall-bash 1→ quattro, ~95 commits, 107 files, +10,249/−3,893)
This branch replaces Python archinstall with archinstall-bash, a bash port of the archinstall surface the Omarchy installer actually uses, and ports the orchestrator itself to bash. The live ISO ships no Python at all — python, kbd, clonezilla, cloud-init and reflector are gone from the airootfs. The library started as its own repo and is now vendored into this tree with its history (subtree merge): one repo, one reviewable diff, no submodule pointing at a personal remote.
The installer's source code shrinks about fivefold, from 25,564 lines of Python to 4,957 lines of bash. The archinstall 4.4 package alone is 22,827 lines, plus an orchestrator on top; this branch ships the vendored library at 2,291 lines and the orchestrator with its context files at 2,666. Raw line counts, comments included, split out in the table below. Not the same functionality in a fifth of the code: the port covers the surface Omarchy actually calls, instead of carrying every archinstall feature onto every install medium.
It carries #113. The root-image install (btrfs send/receive of a prebuilt system, verified before the disk is formatted) was ported function-for-function into the bash orchestrator and has tracked every #113 review round since, including round 7's test fixes. If #113 merges first, this branch's shared content resolves at merge; the choice between the two is the same #108-vs-#113-vs-this discussion.
What changes beyond #113
configs/airootfs/usr/share/omarchy-iso/orchestrator/*.sh) driving the vendored library one step at a time; the Python orchestrator and its tests are gone. Port fidelity was validated by comparing an installed system against a Python-archinstall install:limine.confbyte-identical modulo machine-id/hashes/PARTUUIDs, same package set.arch/x86_64/mirror/), outside the squashfs, bind-mounted read-only onto the unchanged/var/cache/omarchy/mirror/offline. The mirror is now verified before the installer touches the disk (omarchy-verify-mirrorchecks every package the medium carries against the repo database's%SHA256SUM%— the same number pacman validates on install — restricted by the build's shipped-package list, since the database deliberately describes packages the root image already provides), which it never was inside the squashfs. ISO size unchanged by the move, byte for byte. The move itself shaved install time — pacstrap reads packages straight off the ISO9660 tree instead of through squashfs decompression — and the whole-mirror verification spent that saving again: net install time is a wash, with the difference converted from speed into a guarantee, the medium proven good before anything is formatted.omarchy-target-keyring.service,RemainAfterExit=yesso unit state alone answers the orchestrator's join; the lastsystemd-runleft the tree) fed by a sharedcontext.envthat both systemd units and bash source.mountpointplus stream existence before formatting, with the actionable copytoram message;RequiresMountsFor=ordering on the verify units.installer_setup_swapno longer writes a conf the vendor drop-in outranks; strapping only what the target lacks is the only mode.This failure class is not hypothetical. omacom/omarchy#7704 (open) is a 4.0.0 install that consistently dies mid-pacstrap on an offline-mirror problem with the disk already formatted, and omacom/omarchy#6985 was a Quattro free-space install — alongside Windows — halting twice in "Configuring system" on missing mirror files, leaving the user to hand-patch scripts inside a chroot. Those two are missing-package bugs (a build-side gap the verification here narrows but cannot fully close); the corrupt-medium half of the class — the bad flash, the dying stick — is exactly what the pre-format verify gates turn from a half-wiped machine into a "re-flash it" message while the old system is still intact.
Where this is heading: systemd as the orchestrator
The endpoint this branch is walking toward is an
omarchy-install.targetwhere the install's phases are oneshot units and the ordering lives inAfter=/Requires=edges instead of a bash loop — the phase runner dissolves into a dependency graph, the pre-format gates become plain unit dependencies, cleanup becomes lifecycle (PartOf=,BindsTo=,ExecStopPost=) rather than trap code, and every phase gets journal logging, timing and aResultfor free. A failed phase could even be fixed and resumed, which neither archinstall ever offered.The first step is in the branch already:
omarchy-install.targetexists as the umbrella — phase units and the keyring init declarePartOf=it, so stopping the target is a cgroup-complete group abort — and the root-image unpack is the first migrated phase:omarchy-install-image.servicehosts the existing phase function in its own process via a smallrun-phaseentrypoint, receives its context through layeredEnvironmentFile=(defaults, this run's resolved values, and the raw configurator inputs the context code now persists), and is bounded by a build-time timeout drop-in sized to the stream, like the verify units. The orchestrator's loop starts the unit where it used to call the function; a oneshot's blocking start is the join. Phases migrate one at a time behind that pattern, each dragging its ordering into the target as it moves.Measured (KVM rig, identical VM shape, all KVM cells re-measured 2026-08-29 in one sitting; X200 = real-hardware A/B from #113)
² The root image stream now ships behind an outer whole-stream zstd pass (level 15, 128 MiB long-range window): the per-extent compression inside the send stream cannot reach its framing or cross-extent redundancy, and the one pass reclaims 11% of the stream — 398 MiB of ISO. The layer landed on #113's branch after its column was measured; the same delta applies there.
³ Two opposing moves inside this number, both measured on the same rig the same day. The zstd layer made the medium read smaller: the installer VM's kernel clock at power-down went from 21.0 s to 18.4 s when the layer landed. The phases moving into their own systemd units (13 oneshot units, each a fresh process that reloads the library and rebuilds context) added ~5 s over the in-process loop — visible only on KVM, where the whole install is seconds; on the X200 the same overhead vanishes inside a read-bound install a hundred times longer. A shared context daemon or thinner per-phase reload could claw it back if it ever matters.
¹ The two image legs differ in cache state, not in orchestrator: the #113 run pressed install while the boot-time hash was still reading the stream, so the receive ran page-cache-warm; this branch's run began 46 minutes after boot with a cold cache, reading at the stick's raw ~16 MB/s — still 2.1× over pacstrap. Total wall for the run: 538 s / 323 s / 289 s. The pre-format verify gates cost 0.17 s on this run — the hashing hid entirely inside the time the user spent in the wizard.
The +0.56 GiB over quattro is the root-image stream's intrinsic cost (zstd:15 extents pass through
btrfs receiveuntouched — no decompress/recompress, which is what buys the 3× install phase on slow hardware), already shaved by the outer stream pass; ~71 MB comes back from dropping Python. Installed-system boot is unchanged across all three; more than half of it is the loader delay common to every variant.Verification
./test/all: 16 shell suites + the vendored library's own config tests, green.🤖 Generated with Claude Code