Skip to main content

max / alloy

Fence the first boot's layering to the carried repo An offline install came up with no console. alloy-layer-components.service installs from /usr/share/alloy/rpm, a file:// repo carried on the medium so that an install with no network produces a working machine, and rpm-ostree refreshes metadata for every enabled repo before it depsolves. With Fedora's four enabled, installing packages that were already on the disk needed name resolution: error: Updating rpm-md repo 'fedora-cisco-openh264': Cannot prepare internal mirrorlist: Curl error (6): Could not resolve hostname for https://mirrors.fedoraproject.org/metalink?repo=fedora-cisco-openh264-43 Measured 2026-08-25 on an installed server-profile machine in qemu, and reproduced by hand, so it is not a boot-ordering race. /usr/bin/alloy was absent afterwards and the unit had failed. Neither flag can do this. `--disablerepo` is refused outside a container build exactly as `--enablerepo` is, both measured on the same machine, and `--cache-only` refreshes nothing and so cannot see the carried repo at all on a machine that has never refreshed: `error: Packages not found: alloy`, with the package sitting in /usr/share/alloy/rpm. So usr/bin/alloy-layer-repos moves the only lever there is, the enabled set, for the length of one transaction. Measured with the interface down: fenced, the install succeeds and the machine reboots into a console. It restores /etc from /usr/etc rather than from a backup, because a machine that loses power mid-transaction comes back with the backup gone; the unit restores before it fences as well as after, so an interrupted boot heals on the next one. The staged deployment does not inherit the disabled files. Measured by rebooting into it: alloy-local plus Fedora's four, as on any other machine. What an installed machine leaves enabled is unchanged, deliberately. `alloy pkg` layers Fedora packages, and a machine that shipped those repos disabled would quietly find nothing, which is the cost of fixing this in the image instead. GO alloy 542a3e20.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-25 23:57 UTC
Signed with PGP, not checked
Commit: aa0dacde7877dfb834f60cde038d6b47413a2536
Parent: d8cd315
5 files changed, +214 insertions, -1 deletion
M Containerfile +7 -1
@@ -3955,7 +3955,13 @@
3955 3955 test -x /usr/bin/alloy-layer-notice \
3956 3956 || { echo "alloy-layer-notice is missing or not executable; the layering boot is a blank screen again" >&2; exit 1; }; \
3957 3957 sh -n /usr/bin/alloy-layer-notice \
3958 - || { echo "alloy-layer-notice does not parse; the layering boot is a blank screen again" >&2; exit 1; }
3958 + || { echo "alloy-layer-notice does not parse; the layering boot is a blank screen again" >&2; exit 1; }; \
3959 + test -x /usr/bin/alloy-layer-repos \
3960 + || { echo "alloy-layer-repos is missing or not executable; the first boot would need name resolution to install packages that are on the disk" >&2; exit 1; }; \
3961 + sh -n /usr/bin/alloy-layer-repos \
3962 + || { echo "alloy-layer-repos does not parse; an offline first boot would come up with no console" >&2; exit 1; }; \
3963 + grep -q '^enabled=1$' /etc/yum.repos.d/alloy-local.repo \
3964 + || { echo "the carried repo is not enabled; the fenced transaction would have no source at all" >&2; exit 1; }
3959 3965
3960 3966 # The themes the console refuses to run without: with no theme file on any
3961 3967 # search path it does not fall back, it exits.
@@ -48,6 +48,8 @@
48 48
49 49 That repo is enabled, and it is forced rather than chosen: `rpm-ostree install` on a booted system supports `--enablerepo` only in a container build, so a repo shipped disabled is one the first-boot unit could not turn on for its own transaction. It reaches no network, which is why this does not touch the position below.
50 50
51 + **And the first boot is fenced to it, or the carried repo buys nothing.** rpm-ostree refreshes metadata for every *enabled* repo before it depsolves, so with Fedora's four enabled the unit needed name resolution to install packages that were already on the disk: measured 2026-08-25 on an installed server-profile machine, where the first boot failed on `Could not resolve hostname for mirrors.fedoraproject.org` and the machine came up with no console. That is the offline install this design exists to make work. Neither flag can fix it, since `--disablerepo` is refused outside a container build exactly as `--enablerepo` is, and `--cache-only` refreshes nothing at all and cannot see the carried repo on a machine that has never refreshed. So `usr/bin/alloy-layer-repos` disables every other repo for the length of that one transaction and puts `/etc` back afterwards from the image's own copy under `/usr/etc`. The deployment it stages does not inherit the disabled files, measured by rebooting into it. Which repos an installed machine leaves enabled is deliberately unchanged: `alloy pkg` layers Fedora packages, and a machine that shipped them disabled would quietly find nothing.
52 +
51 53 **The network repo ships disabled, and that is chosen rather than forced.** The carried `file://` repo above had to be enabled; this one did not, and it is `enabled=0` on every installed machine. A fresh Alloy has no line pointing at makenot.work that its owner did not add. Turning it on is one command and needs no extra package:
52 54
53 55 sudo sed -i 's/^enabled=0$/enabled=1/' /etc/yum.repos.d/alloy-hotfix.repo
@@ -33,6 +33,10 @@
33 33 repo("usr/bin/alloy-layer-notice")
34 34 }
35 35
36 + fn fence() -> String {
37 + repo("usr/bin/alloy-layer-repos")
38 + }
39 +
36 40 fn directives(prefix: &str) -> Vec<String> {
37 41 unit()
38 42 .lines()
@@ -170,3 +174,99 @@
170 174 assert!(!line.contains('—'), "no em dash in shipped copy: {line}");
171 175 }
172 176 }
177 +
178 + // ---- the repo fence ----
179 + //
180 + // The other way this unit leaves a machine with no console, and it shipped:
181 + // rpm-ostree refreshes every enabled repo before it depsolves, so installing
182 + // packages that are already on the disk needed name resolution, and a first
183 + // boot without it failed. Measured 2026-08-25 on an installed server-profile
184 + // machine. usr/bin/alloy-layer-repos fences the transaction to the carried
185 + // repo; these keep the wiring that makes it work.
186 +
187 + #[test]
188 + fn the_fence_is_a_posix_shell_script() {
189 + assert!(fence().starts_with("#!/bin/sh\n"), "{}", fence());
190 + }
191 +
192 + // Raised before the install, or the install is the one that reaches for the
193 + // network again.
194 + #[test]
195 + fn the_fence_goes_up_before_the_install() {
196 + let pre = directives("ExecStartPre=");
197 + let off = pre
198 + .iter()
199 + .position(|line| line.contains("alloy-layer-repos off"))
200 + .expect("the unit fences the transaction");
201 + let install = unit()
202 + .lines()
203 + .map(str::trim)
204 + .position(|line| line.starts_with("ExecStart=") && line.contains("rpm-ostree install"))
205 + .expect("the unit installs the components");
206 + let off_line = unit()
207 + .lines()
208 + .map(str::trim)
209 + .position(|line| {
210 + line.starts_with("ExecStartPre=") && line.contains("alloy-layer-repos off")
211 + })
212 + .expect("the fence is a directive");
213 +
214 + assert!(off_line < install, "the fence must precede the install");
215 + assert!(off < pre.len(), "{pre:?}");
216 + }
217 +
218 + // Lowered on every path, the successful one included. Without this the reboot
219 + // carries the disabled repos into the deployment ostree merges /etc from.
220 + #[test]
221 + fn the_fence_comes_down_unconditionally() {
222 + let post = directives("ExecStopPost=");
223 + let line = post
224 + .iter()
225 + .find(|line| line.contains("alloy-layer-repos on"))
226 + .expect("the unit restores /etc after itself");
227 +
228 + assert!(
229 + !line.contains("SERVICE_RESULT"),
230 + "restoring /etc is not a failure path: {line}",
231 + );
232 + }
233 +
234 + // And again before it is raised, so a boot interrupted between the two heals
235 + // on the next one instead of leaving the repos quietly off.
236 + #[test]
237 + fn an_interrupted_boot_restores_before_it_fences_again() {
238 + let pre = directives("ExecStartPre=");
239 + let on = pre
240 + .iter()
241 + .position(|line| line.contains("alloy-layer-repos on"))
242 + .expect("the unit restores before it fences");
243 + let off = pre
244 + .iter()
245 + .position(|line| line.contains("alloy-layer-repos off"))
246 + .expect("the unit fences");
247 +
248 + assert!(on < off, "restore has to come first: {pre:?}");
249 + }
250 +
251 + // The carried repo is the one thing the fence may not touch: disabling it
252 + // leaves the transaction with no source for the packages at all.
253 + #[test]
254 + fn the_fence_never_disables_the_carried_repo() {
255 + assert!(
256 + fence().contains("alloy-local.repo"),
257 + "the fence must except the carried repo: {}",
258 + fence(),
259 + );
260 + }
261 +
262 + // /usr/etc is the image's own /etc and is always there. A backup written at
263 + // `off` time is not, which is the case that matters: a machine that loses power
264 + // mid-transaction comes back with the repos disabled and the backup gone.
265 + #[test]
266 + fn the_fence_restores_from_the_images_own_etc() {
267 + assert!(
268 + fence().contains("/usr/etc/yum.repos.d"),
269 + "the restore must read the image's copy: {}",
270 + fence(),
271 + );
272 + }
@@ -82,6 +82,21 @@
82 82 # takes the same care internally; this is the belt outside it.
83 83 ExecStartPre=-/usr/bin/alloy-layer-notice start
84 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 +
85 100 # By bare name, not by NEVRA, and it matters later rather than here. rpm-ostree
86 101 # records a request under the string it was given, so a package layered by full
87 102 # name-version-release cannot afterwards be removed by its bare name — it
@@ -109,6 +124,11 @@
109 124 # that will not start. $SERVICE_RESULT is systemd's, and it is `success` on the
110 125 # ordinary path where the reboot above is already under way.
111 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
112 132
113 133 # A failure here leaves a machine with no console, which the user cannot fix
114 134 # from the session they cannot start. Failing loudly is the only honest option:
@@ -1,0 +1,85 @@
1 + #!/bin/sh
2 + # alloy-layer-repos — fence the first boot's layering to the carried repo.
3 + #
4 + # alloy-layer-components.service installs the console and terminal from
5 + # /usr/share/alloy/rpm, a file:// repo that ships on the medium precisely so an
6 + # offline install produces a working machine (docs/STACK.md, Hotfixes). It did
7 + # not. rpm-ostree refreshes metadata for every ENABLED repo before it
8 + # depsolves, and the image leaves Fedora's own four enabled, so a first boot
9 + # with no name resolution failed like this and left the machine with no
10 + # console:
11 + #
12 + # error: Updating rpm-md repo 'fedora-cisco-openh264': Cannot prepare
13 + # internal mirrorlist: Curl error (6): Could not resolve hostname for
14 + # https://mirrors.fedoraproject.org/metalink?repo=fedora-cisco-openh264-43
15 + #
16 + # Measured 2026-08-25 on an installed server-profile machine in qemu, and
17 + # reproduced by hand afterwards, so it is not a boot-ordering race.
18 + #
19 + # WHY THE FLAGS ARE NOT THE ANSWER. Both levers rpm-ostree offers are refused
20 + # outside a container build, which docs/STACK.md already records for the first
21 + # of them:
22 + #
23 + # # rpm-ostree install --enablerepo=alloy-local alloy
24 + # error: --enablerepo currently only works in a container build
25 + # # rpm-ostree install --disablerepo="*" alloy
26 + # error: --disablerepo currently only works in a container build
27 + #
28 + # `--cache-only` is not the answer either: it reads cached metadata and
29 + # refreshes none, so on a machine that has never refreshed anything it answers
30 + # `error: Packages not found: alloy` with the packages sitting on the disk.
31 + #
32 + # So the repo set the daemon reads is the only lever, and this moves it for the
33 + # length of one transaction. `off` disables everything but the carried repo;
34 + # `on` puts /etc back. Both are idempotent.
35 + #
36 + # WHY `on` COPIES FROM /usr/etc RATHER THAN FROM A BACKUP. /usr/etc is the
37 + # image's own /etc, so it is authoritative and it is always there. A backup in
38 + # /run is not: a machine that loses power between `off` and `on` comes back
39 + # with the repos disabled and the backup gone. The unit runs `on` before `off`
40 + # for that reason as well as after, so an interrupted first boot heals itself
41 + # on the next one rather than leaving a machine whose repos are quietly off.
42 + #
43 + # WHAT IT DOES NOT LEAK. The deployment the transaction stages does not inherit
44 + # the disabled files: measured by rebooting into it, where the enabled set is
45 + # alloy-local plus Fedora's four, as on any other machine. ostree's /etc merge
46 + # reads the state /etc is left in, and `on` runs before the reboot.
47 + #
48 + # NOT A USER-FACING REPO POLICY. Which repos an installed Alloy leaves enabled
49 + # is unchanged by this, deliberately: `alloy pkg` layers Fedora packages, and a
50 + # machine that ships them disabled is a machine where that quietly finds
51 + # nothing. The fence exists for one transaction on one boot.
52 + #
53 + # Deliberately dependency-free, and on the boot path in front of the only thing
54 + # that gives the machine a console: sh, sed, cp, a glob. Same argument as
55 + # alloy-layer-notice's.
56 +
57 + set -eu
58 +
59 + REPOS=/etc/yum.repos.d
60 + SHIPPED=/usr/etc/yum.repos.d
61 + CARRIED=alloy-local.repo
62 +
63 + case "${1:-}" in
64 + off)
65 + [ -d "$REPOS" ] || exit 0
66 + for repo in "$REPOS"/*.repo; do
67 + [ -e "$repo" ] || continue
68 + [ "${repo##*/}" != "$CARRIED" ] || continue
69 + sed -i 's/^enabled[[:space:]]*=[[:space:]]*1$/enabled=0/' "$repo"
70 + done
71 + ;;
72 + on)
73 + # A missing /usr/etc means this is not an ostree deployment at all,
74 + # which is a dev box running the script by hand. Nothing to put back.
75 + [ -d "$SHIPPED" ] || exit 0
76 + for repo in "$SHIPPED"/*.repo; do
77 + [ -e "$repo" ] || continue
78 + cp -f "$repo" "$REPOS/${repo##*/}"
79 + done
80 + ;;
81 + *)
82 + echo "usage: ${0##*/} off|on" >&2
83 + exit 2
84 + ;;
85 + esac