| 1 |
# Lay down Alloy's own components on a machine that has just been installed. |
| 2 |
# |
| 3 |
# The console and shop cannot ship inside the image. A component the base |
| 4 |
# carries cannot be replaced client-side — `rpm-ostree install` refuses to |
| 5 |
# depsolve against it, and `override replace` records a request that never |
| 6 |
# activates — so an image that carried them would be an image whose console |
| 7 |
# could never be fixed without rebuilding an ISO and writing a drive. They are |
| 8 |
# layered packages instead, and stay layered. Measured in build/layertest. |
| 9 |
# |
| 10 |
# The repo they come from rides inside the image, at /usr/share/alloy/rpm, as |
| 11 |
# RPM files that are never installed at build time. So this needs no network, |
| 12 |
# and it needs nothing copied off the medium either: the ISO carries the repo |
| 13 |
# because the ISO is the image, and this machine has it for the same reason. |
| 14 |
# Deliberate twice over: an offline install has to produce a working machine, |
| 15 |
# and a machine whose owner has not consented to anything yet must not reach |
| 16 |
# out. See docs/STACK.md, "Updates" and "Hotfixes". |
| 17 |
# |
| 18 |
# Carrying the RPMs is not the same as carrying the component, and the |
| 19 |
# difference is the whole design. What cannot be replaced client-side is an |
| 20 |
# installed package or a file at the path being layered over. An uninstalled |
| 21 |
# .rpm sitting in /usr/share is neither: @System does not hold the component, |
| 22 |
# /usr/bin/alloy does not exist, and the layer goes down cleanly over both. |
| 23 |
# Measured, `c1`/`c2` in build/layertest. |
| 24 |
# |
| 25 |
# ## Why this reboots, and why that is not a regression |
| 26 |
# |
| 27 |
# /usr is a read-only ostree tree, so a layered package arrives as a new |
| 28 |
# deployment and a new deployment needs a boot. There is no version of this |
| 29 |
# that ends with the console usable in the session it ran from. |
| 30 |
# |
| 31 |
# Layering during the install instead would avoid it, and was measured rather |
| 32 |
# than assumed: `rpm-ostree install --sysroot` against an unbooted target is a |
| 33 |
# D-Bus client with no daemon it can reach, and did not work. Not proven |
| 34 |
# impossible, and build/layertest/installtime.sh records exactly where to pick |
| 35 |
# that up. Until then, one reboot. |
| 36 |
# |
| 37 |
# ## Ordering |
| 38 |
# |
| 39 |
# Before greetd, because the session wrapper it launches calls `alloy theme |
| 40 |
# apply` and the greeter is the first thing a person sees. A boot that reached |
| 41 |
# the greeter first would show a login that leads to a session that cannot |
| 42 |
# start, which is a worse failure than a visible one-time setup step. |
| 43 |
# |
| 44 |
# After network-online is deliberately NOT set. Nothing here uses the network, |
| 45 |
# and ordering after something that may never arrive is how a machine with no |
| 46 |
# link sits at a blank console for ninety seconds. |
| 47 |
|
| 48 |
[Unit] |
| 49 |
Description=Install Alloy's own components from the installer medium |
| 50 |
Documentation=https://makenot.work/git/max/alloy |
| 51 |
# Default dependencies deliberately left ON. The early-boot idiom |
| 52 |
# (DefaultDependencies=no, Conflicts=shutdown.target) is wrong here: rpm-ostree |
| 53 |
# is a D-Bus client and rpm-ostreed is D-Bus activated, so a unit that ordered |
| 54 |
# itself before the bus would fail on a machine where everything is fine. |
| 55 |
After=dbus.service |
| 56 |
Wants=dbus.service |
| 57 |
Before=greetd.service getty.target |
| 58 |
|
| 59 |
# The gate, and it is two conditions rather than a stamp file on purpose. |
| 60 |
# |
| 61 |
# The repo has to be there: an image built before this existed, or one somebody |
| 62 |
# stripped, has nothing to install from and this unit is not its business. |
| 63 |
ConditionPathExists=/usr/share/alloy/rpm/repodata/repomd.xml |
| 64 |
# And the console has to be absent. This is the "already ran" test, and reading |
| 65 |
# it off the filesystem rather than off a stamp means it answers correctly |
| 66 |
# after a rollback, after a user uninstalls the layer deliberately, and on a |
| 67 |
# machine somebody imaged from another one. A stamp file would say "done" in |
| 68 |
# all three cases and leave the machine without a console. |
| 69 |
ConditionPathExists=!/usr/bin/alloy |
| 70 |
|
| 71 |
[Service] |
| 72 |
Type=oneshot |
| 73 |
RemainAfterExit=yes |
| 74 |
# The notice comes first, because the install is the long part and the blank |
| 75 |
# screen is what it is explaining. `quiet loglevel=3` on the cmdline plus no |
| 76 |
# plymouth means this boot shows nothing at all otherwise, including this |
| 77 |
# unit's own Description. See usr/bin/alloy-layer-notice. |
| 78 |
# |
| 79 |
# `-` on both notice lines: a prefix that makes systemd ignore the exit |
| 80 |
# status. Telling the user is worth less than installing the console, so a |
| 81 |
# broken notice must not be what stops the machine getting one. The script |
| 82 |
# takes the same care internally; this is the belt outside it. |
| 83 |
ExecStartPre=-/usr/bin/alloy-layer-notice start |
| 84 |
|
| 85 |
# Then the repo fence, and it is what makes an offline first boot work at all. |
| 86 |
# rpm-ostree refreshes every enabled repo before it depsolves, so with Fedora's |
| 87 |
# four enabled this unit needed name resolution to install packages sitting on |
| 88 |
# the disk, and a machine that could not resolve came up with no console. |
| 89 |
# Measured 2026-08-25; usr/bin/alloy-layer-repos carries the detail and the two |
| 90 |
# flags that cannot do this instead. |
| 91 |
# |
| 92 |
# `on` before `off` so a first boot interrupted between them heals here rather |
| 93 |
# than leaving the repos quietly disabled, and `on` again in ExecStopPost on |
| 94 |
# every path. No `-` on the `off` line: if the fence cannot be raised, the |
| 95 |
# install that follows is the one that leaves the machine without a console, |
| 96 |
# and failing here retries on the next boot with /etc intact. |
| 97 |
ExecStartPre=-/usr/bin/alloy-layer-repos on |
| 98 |
ExecStartPre=/usr/bin/alloy-layer-repos off |
| 99 |
|
| 100 |
# By bare name, not by NEVRA, and it matters later rather than here. rpm-ostree |
| 101 |
# records a request under the string it was given, so a package layered by full |
| 102 |
# name-version-release cannot afterwards be removed by its bare name — it |
| 103 |
# answers "not currently requested" and leaves the package in place, which |
| 104 |
# reads as nothing-to-do rather than as a failure. `alloy update` has to drop |
| 105 |
# and re-add these on every upgrade (it is the only thing that makes a layered |
| 106 |
# component follow the image it shipped with), so installing them by name here |
| 107 |
# is what keeps that safe. |
| 108 |
# |
| 109 |
# -y because rpm-ostree prompts on a tty it does not have, and a unit blocked |
| 110 |
# forever on a prompt nobody can see is indistinguishable from a hang. |
| 111 |
# --idempotent so a re-run after a partial failure is not itself an error. |
| 112 |
# |
| 113 |
# The package list is read from the image rather than written here. One unit |
| 114 |
# file serves both profiles, and `server` has no compositor, so laying a Wayland |
| 115 |
# terminal down there would install something that cannot run. The image knows |
| 116 |
# which profile it is and writes the list at build time; this unit installs what |
| 117 |
# it is told. A missing or empty list leaves rpm-ostree with no arguments, which |
| 118 |
# fails loudly — the right outcome, since the alternative is a machine that |
| 119 |
# quietly comes up with no console. |
| 120 |
ExecStart=/bin/sh -c 'exec /usr/bin/rpm-ostree install --idempotent -y $(cat /usr/share/alloy/components)' |
| 121 |
ExecStart=/usr/bin/systemctl reboot |
| 122 |
# Only on failure, which is the state the user cannot get out of on their own: |
| 123 |
# no console and no terminal, and the session they would fix it from is the one |
| 124 |
# that will not start. $SERVICE_RESULT is systemd's, and it is `success` on the |
| 125 |
# ordinary path where the reboot above is already under way. |
| 126 |
ExecStopPost=-/bin/sh -c '[ "$SERVICE_RESULT" = success ] || /usr/bin/alloy-layer-notice fail' |
| 127 |
# And /etc back, on every path including the successful one, before the reboot |
| 128 |
# above takes effect. The staged deployment does not inherit the disabled files |
| 129 |
# because ostree merges /etc as it is left here, which is measured rather than |
| 130 |
# assumed: rebooting into the result shows the ordinary enabled set. |
| 131 |
ExecStopPost=-/usr/bin/alloy-layer-repos on |
| 132 |
|
| 133 |
# A failure here leaves a machine with no console, which the user cannot fix |
| 134 |
# from the session they cannot start. Failing loudly is the only honest option: |
| 135 |
# the journal names the unit, the console says so through the notice above, and |
| 136 |
# the next boot retries because the conditions at the top are both still true. |
| 137 |
# |
| 138 |
# No Restart=, though. A unit whose second ExecStart is `reboot` and that |
| 139 |
# restarts on failure is a boot loop, and a boot loop on the one machine class |
| 140 |
# this project claims to support is worse than a machine that boots to a |
| 141 |
# console and says what went wrong. |
| 142 |
|
| 143 |
[Install] |
| 144 |
WantedBy=multi-user.target |
| 145 |
|