Skip to main content

max / alloy

Carry the RPMs in the image, which removes the copy step and the wedge both GO d866e125 subtask 7. Max chose 2026-08-14 that the components travel on the ISO rather than over the network. This measures the cheapest realization of that and takes it: the image carries our RPMs as FILES, in a file:// repo under /usr/share/alloy/rpm, and never installs them. Nothing has to be copied at install time. The ISO is the image, so the ISO carries the repo; the installed machine is the image, so it carries it too. That deletes the installer change, the /var state and the "did the copy happen" failure mode that the copy-off-the-medium sketch would all have needed. Carrying the RPMs is not carrying the component, and that distinction is the whole design. What cannot be replaced client-side is an installed package or a file at the path being layered over. An uninstalled .rpm in /usr/share is neither, so the layer goes down cleanly over both. Measured with two new harness variants, c1 carrying 0.0.2 and c2 carrying 0.0.3: - Layering works with the network repo disabled. Requested under the bare name, because that is what was typed, which is the safe side of the NEVRA trap the earlier drain run found. - A base move does NOT wedge. c1 to c2, base carrying 0.0.3 against a layer at 0.0.2, no drain, plain upgrade: it succeeds. @System never holds the component, so the conflict that permanently wedges the n4 case has nothing to arise between. This is the reason to prefer this shape over a base RPM. - But the layer does NOT follow the image. After that upgrade the machine runs 0.0.2 while the base beside it carries 0.0.3. rpm-ostree does not re-resolve a satisfied request because a repo behind it moved, so shipping a new console in a new image reaches nobody on its own. That is the opposite of what it looks like, and it is the result worth knowing. - A drop and reinstall in one boot advances it: 0.0.2 -> 0.0.3. So the drain rule survives for a better reason than the one it was invented for. It is not an escape from a wedge here, it is the only thing that makes a component track the image it shipped with, and `alloy update` owns it. Also confirmed: `bootc upgrade` refuses permanently rather than transiently, because an Alloy machine always carries a layer. Owning `rpm-ostree upgrade` is a permanent condition, not a migration step. Adds the first-boot unit and its preset entry. Both are inert until the image stops shipping the binaries: the unit requires the carried repo to exist and /usr/bin/alloy not to, and today the second is false. Conditions rather than a stamp file, so it answers correctly after a rollback, after a deliberate uninstall, and on a machine imaged from another one.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-14 23:59 UTC
Signed with PGP, not checked
Commit: 0e9d9c5407eb2d26b07172a07fe6a7d0f2ed5b09
Parent: 145b0fa
6 files changed, +207 insertions, -6 deletions
@@ -13,6 +13,14 @@
13 13 # This is what Alloy does today (Containerfile:2067 and :2072).
14 14 # none the image does not carry the component at all, so a hotfix is a
15 15 # plain layered package rather than a replacement.
16 + # carry the image carries the component's RPM as a FILE, in a file:// repo
17 + # under /usr/share, and does not install it. The component is still
18 + # absent from @System, so it is layered exactly as in `none`, but the
19 + # package it layers from travels with the image and needs no network.
20 + # This is the shape Alloy takes: it makes an offline install work, and
21 + # because @System never holds the component there is no version of it
22 + # for a layer to conflict with, which is the wedge that `rpm` and the
23 + # n4 case run into.
16 24 #
17 25 # The build context is state/, which holds the RPMs and the ssh key that
18 26 # build.sh puts there. Nothing in the context is tracked.
@@ -26,17 +34,26 @@
26 34 COPY registries.conf /etc/containers/registries.conf.d/010-alloy-demo.conf
27 35
28 36 COPY rpms /tmp/rpms
37 + # Per-version repos, each holding one RPM and its metadata. `carry` takes the
38 + # one matching DEMO_VERSION, so moving the base from one carry variant to
39 + # another is what "the image shipped a newer component" looks like.
40 + COPY repos /tmp/repos
29 41 RUN case "$SHAPE" in \
30 42 rpm) \
31 43 dnf -y install "/tmp/rpms/alloy-demo-${DEMO_VERSION}-1.fc43.x86_64.rpm" ;; \
32 44 loose) \
33 45 printf '#!/bin/sh\necho "alloy-demo %s"\n' "$DEMO_VERSION" > /usr/bin/alloy-demo \
34 46 && chmod 0755 /usr/bin/alloy-demo ;; \
47 + carry) \
48 + mkdir -p /usr/share/alloy-demo \
49 + && cp -r "/tmp/repos/$DEMO_VERSION" /usr/share/alloy-demo/rpm \
50 + && printf '[alloy-demo-local]\nname=carried on the medium\nbaseurl=file:///usr/share/alloy-demo/rpm\nenabled=1\ngpgcheck=0\n' \
51 + > /etc/yum.repos.d/alloy-demo-local.repo ;; \
35 52 none) \
36 53 : ;; \
37 54 *) echo "unknown SHAPE: $SHAPE" >&2; exit 1 ;; \
38 55 esac \
39 - && rm -rf /tmp/rpms \
56 + && rm -rf /tmp/rpms /tmp/repos \
40 57 && dnf clean all
41 58
42 59 # Which base is running, readable from the guest without trusting a tag. The
@@ -110,13 +110,66 @@
110 110 would work here. Reset drops every layered package including ones the user
111 111 layered themselves, and those are not ours to remove.
112 112
113 + ## The carry shape, measured 2026-08-14, and it is the one Alloy takes
114 +
115 + The image carries our RPMs **as files**, in a `file://` repo under
116 + `/usr/share/alloy/rpm`, and does not install them. The component is still
117 + absent from `@System`, so it layers exactly as in the `none` cases, but the
118 + package it layers from travels inside the image. Variants `c1` (carries 0.0.2)
119 + and `c2` (carries 0.0.3).
120 +
121 + Nothing has to be copied anywhere at install time. The ISO is the image, so the
122 + ISO carries the repo; the installed machine is the image, so it carries the
123 + repo too. That is the whole delivery mechanism.
124 +
125 + **Layering works with no network.** With the network repo `enabled=0`, from a
126 + `c1` machine:
127 +
128 + # rpm-ostree install -y --idempotent alloy-demo
129 + Added: alloy-demo-0.0.2-1.fc43.x86_64
130 +
131 + Requested under the **bare name**, because that is what was typed. Worth
132 + noticing next to the NEVRA trap recorded below: install by name and the drain
133 + can uninstall by name.
134 +
135 + **A base move does not wedge it.** `c1` to `c2`, base carrying 0.0.3 against a
136 + layer at 0.0.2, no drain, plain `rpm-ostree upgrade`: it succeeds. This is the
137 + whole reason to prefer this shape over shipping the component as a base RPM.
138 + `@System` never holds the component, so the conflict that permanently wedges
139 + the `n4` case has nothing to arise between.
140 +
141 + **But the layer does not follow the image.** After that upgrade the machine is
142 + on base `c2` and still running `alloy-demo 0.0.2`, while the base beside it
143 + carries 0.0.3. rpm-ostree does not re-resolve a satisfied request just because
144 + a repo behind it moved. Shipping a new console inside a new image therefore
145 + reaches nobody on its own, which is the opposite of what it looks like.
146 +
147 + **What advances it is a drop and reinstall**, in one boot:
148 +
149 + # rpm-ostree uninstall alloy-demo && rpm-ostree install -y alloy-demo
150 + Upgraded: alloy-demo 0.0.2-1.fc43 -> 0.0.3-1.fc43
151 +
152 + So the drain rule survives, for a better reason than the one it was invented
153 + for. Here it is not an escape from a wedge, it is the only thing that makes a
154 + component track the image it shipped with. `alloy update` owns it.
155 +
156 + **`bootc upgrade` refuses permanently, not transiently.** An Alloy machine
157 + always carries a layer, so it always has "local rpm-ostree modifications".
158 + `alloy update` owns `rpm-ostree upgrade` for the life of the machine, and the
159 + earlier note about moving users off `bootc upgrade` is a permanent condition
160 + rather than a step in a migration.
161 +
113 162 ## How an installed machine gets the components, 2026-08-14
114 163
115 - Decided by Max: **they travel on the ISO**, not over the network. The installer
116 - copies our repo from the medium onto the target, and the machine layers from
117 - `file:///var/lib/alloy/rpm`. That is the only shape that survives an offline
118 - install, and it keeps the no-phone-home position intact for a machine whose
119 - owner has not consented to anything yet.
164 + Decided by Max: **they travel on the ISO**, not over the network. That is the
165 + only shape that survives an offline install, and it keeps the no-phone-home
166 + position intact for a machine whose owner has not consented to anything yet.
167 +
168 + The realization is the carry shape above, which is cheaper than the copy-off-
169 + the-medium version first sketched: the repo rides inside the image, so the ISO
170 + carries it because the ISO is the image, and the installed machine has it for
171 + the same reason. No copy step in the installer, and no `/var` state to go
172 + missing.
120 173
121 174 **Layering happens at first boot, not during the install.** `installtime.sh`
122 175 measured the alternative and it is unproven: `rpm-ostree install` is a D-Bus
@@ -37,6 +37,17 @@
37 37 cp /state/rpmbuild/RPMS/*/*.rpm /state/repo/
38 38 cp /state/rpmbuild/RPMS/*/*.rpm /state/rpms/
39 39 createrepo_c /state/repo >/dev/null
40 +
41 + # And one repo per version, for the `carry` shape. A carried repo holding
42 + # every version would let a machine resolve a version its image never
43 + # shipped, so moving the base from one carry variant to the next would
44 + # prove nothing.
45 + rm -rf /state/repos && mkdir -p /state/repos
46 + for v in 0.0.1 0.0.2 0.0.3; do
47 + mkdir -p "/state/repos/$v"
48 + cp "/state/rpmbuild/RPMS"/*/alloy-demo-$v-*.rpm "/state/repos/$v/"
49 + createrepo_c "/state/repos/$v" >/dev/null
50 + done
40 51 ' >/dev/null
41 52
42 53 # ------------------------------------------------------- the build context
@@ -26,6 +26,11 @@
26 26 # r1 and l1 are not part of that sequence. They exist to reproduce the two dead
27 27 # ends: a component that is a base RPM cannot be replaced, and a component that
28 28 # is an unowned file cannot be layered over.
29 + #
30 + # c1 and c2 are the shape Alloy actually takes: the image carries the RPM as a
31 + # file in a file:// repo and does not install it. c1 carries 0.0.2 and c2
32 + # carries 0.0.3, so c1 -> c2 is "the image shipped a newer component" and the
33 + # question is whether a machine's layer follows it without the n4 conflict.
29 34 VARIANTS=(
30 35 "n1:none:"
31 36 "n2:none:"
@@ -33,6 +38,8 @@
33 38 "n4:rpm:0.0.3"
34 39 "r1:rpm:0.0.1"
35 40 "l1:loose:0.0.1"
41 + "c1:carry:0.0.2"
42 + "c2:carry:0.0.3"
36 43 )
37 44
38 45 die() { printf 'error: %s\n' "$*" >&2; exit 1; }
@@ -36,6 +36,19 @@
36 36 # but a line that read `enable` on a unit missing its condition would be the
37 37 # other direction, which is why the condition lives in the unit and not here.
38 38 enable alloy-installer-ssh.service
39 + # Lays the console and shop down as layered packages on a freshly installed
40 + # machine, from the repo the installer copied off the medium. They cannot ship
41 + # inside the image: a component the base carries can never be replaced
42 + # client-side, so an image carrying them is an image whose console can never be
43 + # fixed. See alloy-layer-components.service and build/layertest.
44 + #
45 + # Enabled on every install, and gated in the unit rather than here. Both of its
46 + # conditions have to hold — the repo present, and the console absent — so on a
47 + # machine that already has a console it is a no-op, and on one installed from
48 + # an older medium it is not its business. Reading "already ran" off the
49 + # filesystem rather than a stamp is what makes it answer correctly after a
50 + # rollback or a deliberate uninstall.
51 + enable alloy-layer-components.service
39 52 # alloy-debug-shell@ is deliberately not listed here. It is a template, and
40 53 # `systemctl preset-all` cannot instantiate one from a preset line: a line
41 54 # naming `alloy-debug-shell@tty9.service` matches no unit file and is ignored
@@ -1,0 +1,100 @@
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 + # By bare name, not by NEVRA, and it matters later rather than here. rpm-ostree
75 + # records a request under the string it was given, so a package layered by full
76 + # name-version-release cannot afterwards be removed by its bare name — it
77 + # answers "not currently requested" and leaves the package in place, which
78 + # reads as nothing-to-do rather than as a failure. `alloy update` has to drop
79 + # and re-add these on every upgrade (it is the only thing that makes a layered
80 + # component follow the image it shipped with), so installing them by name here
81 + # is what keeps that safe.
82 + #
83 + # -y because rpm-ostree prompts on a tty it does not have, and a unit blocked
84 + # forever on a prompt nobody can see is indistinguishable from a hang.
85 + # --idempotent so a re-run after a partial failure is not itself an error.
86 + ExecStart=/usr/bin/rpm-ostree install --idempotent -y alloy shop
87 + ExecStart=/usr/bin/systemctl reboot
88 +
89 + # A failure here leaves a machine with no console, which the user cannot fix
90 + # from the session they cannot start. Failing loudly is the only honest option:
91 + # the journal names the unit, and the next boot retries because the conditions
92 + # above are both still true.
93 + #
94 + # No Restart=, though. A unit whose second ExecStart is `reboot` and that
95 + # restarts on failure is a boot loop, and a boot loop on the one machine class
96 + # this project claims to support is worse than a machine that boots to a
97 + # console and says what went wrong.
98 +
99 + [Install]
100 + WantedBy=multi-user.target