Skip to main content

max / alloy

dev-push, and the base trim it made worth measuring Max's work in progress, committed alongside the component flip because the two touched one file and the tree could not be split cleanly. build/dev-push.sh hands a freshly built image to a machine already running Alloy without building an ISO, through a registry on the build box. The ISO is how a machine is installed and the wrong artifact for "did my sway config land": a config-only change is fifteen seconds of podman build followed by an ISO assembly costing sixteen times that, to produce installer media nobody is going to install from. An installed machine adopts a local image with the same A/B staged switch and the same rollback a registry-fed update uses. crates/alloy/tests/base_trim.rs holds the line the trim must not cross. Size in the image is build time in every step after it, which makes the removal list a thing people will want to extend, and the largest candidate a naive extension finds is nvidia-gpu-firmware with the rest of the firmware behind it. Taking any of it would build clean, write clean, and fail as a machine that does not boot on hardware nobody was asked about at build time.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-15 01:10 UTC
Signed with PGP, not checked
Commit: 774e29a64943af9fe5b89be1ba7cedbc8effd781
Parent: 5bcba0b
9 files changed, +470 insertions, -43 deletions
M .gitignore +4
@@ -19,6 +19,10 @@
19 19 # a multi-gigabyte ISO, which is how a 2.3G blob reached this repo once.
20 20 /output.prev/
21 21
22 + # The exported install source, kept across ISO builds so an unchanged image is
23 + # not re-exported. Several GB of blobs that rebuild from the image itself.
24 + /.iso-cache/
25 +
22 26 # Environment
23 27 .env
24 28 .env.*
@@ -108,7 +108,9 @@
108 108 :
109 109 elif [ "$SKIP_BUILD" -eq 0 ]; then
110 110 echo "==> Building $IMAGE (rootful)"
111 - priv podman build "${BUILD_ARGS[@]}" -t "$IMAGE" "$REPO_ROOT"
111 + # --jobs 2 to overlap the two stages; see the same call in build/build-iso.sh
112 + # for why two and not more.
113 + priv podman build --jobs 2 "${BUILD_ARGS[@]}" -t "$IMAGE" "$REPO_ROOT"
112 114 else
113 115 echo "==> Skipping image build; reusing $IMAGE"
114 116 privc podman image exists "$IMAGE" || die "$IMAGE not in the root store; drop --skip-build"
M build/build-iso.sh +65 -19
@@ -46,6 +46,15 @@
46 46 BUILDER="localhost/alloy-iso-builder:local"
47 47 OUTPUT="$REPO_ROOT/output"
48 48 WORKDIR="$REPO_ROOT/output/.iso-work"
49 + # The install source, kept outside output/ so the rotation does not take it.
50 + #
51 + # It used to live at $WORKDIR/source, which output/ rotates away on every run,
52 + # so every build re-exported the whole image. Measured on this box: 22s of the
53 + # ISO's four minutes, paid again for an image that had not changed since the
54 + # last run. Out here it survives, and the stamp beside it says which image it
55 + # holds.
56 + SOURCE_CACHE="$REPO_ROOT/.iso-cache/source"
57 + SOURCE_STAMP="$REPO_ROOT/.iso-cache/source.image-id"
49 58
50 59 SKIP_BUILD=0
51 60 SKIP_SOURCE=0
@@ -76,10 +85,11 @@
76 85 --build-arg) BUILD_ARGS+=(--build-arg "${2:?--build-arg needs KEY=VALUE}"); shift 2 ;;
77 86 --skip-source) SKIP_SOURCE=1; shift ;;
78 87 # Iteration mode: reuse the image and compress cheaply, but still carry
79 - # the install source. Level 19 costs about ten minutes of saturated CPU
80 - # and the export costs about two, so dropping compression is nearly all
81 - # of the win and dropping the source would cost the ability to install
82 - # at all. Pair with --skip-source when only the boot chain is in
88 + # the install source. Measured on fw13, 2026-08-15: level 19 costs 192s
89 + # against level 3's 8s, and the export costs 22s and is now skipped
90 + # entirely when the image has not changed. So this flag is worth about
91 + # three minutes, and dropping the source would cost the ability to
92 + # install at all. Pair with --skip-source when only the boot chain is in
83 93 # question.
84 94 --fast) SKIP_BUILD=1; FAST=1; shift ;;
85 95 # Bakes `alloy.update-target=` into the ISO's GRUB entries, so machines
@@ -126,7 +136,15 @@
126 136 # 1. The Alloy image.
127 137 if [ "$SKIP_BUILD" -eq 0 ]; then
128 138 say "building $IMAGE"
129 - priv podman build "${BUILD_ARGS[@]}" -t "$IMAGE" "$REPO_ROOT"
139 + # --jobs 2 because the Containerfile is two stages that meet only at a COPY.
140 + # The rust-build stage compiles the console and the terminal (about four
141 + # minutes cold) while the runtime stage is still installing packages (about
142 + # the same), and podman runs stages serially unless told otherwise, so the
143 + # two costs used to add. Not higher than 2: there are two stages, and a
144 + # number above that buys nothing while making the interleaved log harder to
145 + # read. The rust stage caps its own rustc jobs from RAM, which is what keeps
146 + # the overlap from turning into paging — see the Containerfile.
147 + priv podman build --jobs 2 "${BUILD_ARGS[@]}" -t "$IMAGE" "$REPO_ROOT"
130 148 else
131 149 privc podman image exists "$IMAGE" || die "$IMAGE not in the root store; drop --skip-build"
132 150 say "reusing $IMAGE"
@@ -146,7 +164,7 @@
146 164 privc rm -rf "${OUTPUT:?}.prev"
147 165 privc mv "$OUTPUT" "${OUTPUT}.prev"
148 166 fi
149 - privc mkdir -p "$OUTPUT" "$WORKDIR/source"
167 + privc mkdir -p "$OUTPUT" "$WORKDIR"
150 168
151 169 # 4. The image to install, as an OCI layout. skopeo rather than `podman
152 170 # save` because bootc reads skopeo transports, and this is the exact
@@ -161,21 +179,49 @@
161 179 # worst possible moment. Reproduced in a VM at 4 GB and again at 8 GB, so
162 180 # it is not about the machine being small. A layout is a directory and is
163 181 # read where it lies. Same bytes on the ISO, no temp space.
182 + #
183 + # Re-exported only when the image changed. The stamp holds the image ID the
184 + # cache was built from, so an unchanged image reuses it and a changed one
185 + # starts from an empty directory. EMPTY, not overwritten: skopeo does not
186 + # reuse blobs already at an oci: destination — measured 2026-08-15, a second
187 + # copy of the same image into the same layout re-copied everything and took
188 + # longer than the first — so copying over the top would leave the previous
189 + # image's blobs behind and carry both onto the ISO.
164 190 if [ "$SKIP_SOURCE" -eq 0 ]; then
165 - say "exporting the install source (several GB, and slow)"
166 - # skopeo from the builder rather than the host: this box is Pop!_OS and
167 - # has no skopeo, and requiring one would make the build depend on which
168 - # distro happens to be running it. The host's container store is bind
169 - # mounted in so skopeo can read the image out of it.
170 - priv podman run --rm --privileged \
171 - --security-opt label=type:unconfined_t \
172 - -v /var/lib/containers/storage:/var/lib/containers/storage \
173 - -v "$WORKDIR/source":/source \
174 - --entrypoint skopeo \
175 - "$BUILDER" \
176 - copy "containers-storage:$IMAGE" "oci:/source/alloy:local"
191 + IMAGE_ID="$(privc podman image inspect --format '{{.Id}}' "$IMAGE")"
192 + [ -n "$IMAGE_ID" ] || die "cannot read the image id of $IMAGE"
193 +
194 + if [ "$(privc cat "$SOURCE_STAMP" 2>/dev/null || true)" = "$IMAGE_ID" ] \
195 + && privc test -f "$SOURCE_CACHE/alloy/index.json"; then
196 + say "install source is already exported for this image"
197 + else
198 + say "exporting the install source (several GB, and slow)"
199 + privc rm -rf "${SOURCE_CACHE:?}" "$SOURCE_STAMP"
200 + privc mkdir -p "$SOURCE_CACHE"
201 + # skopeo from the builder rather than the host: this box is Pop!_OS and
202 + # has no skopeo, and requiring one would make the build depend on which
203 + # distro happens to be running it. The host's container store is bind
204 + # mounted in so skopeo can read the image out of it.
205 + priv podman run --rm --privileged \
206 + --security-opt label=type:unconfined_t \
207 + -v /var/lib/containers/storage:/var/lib/containers/storage \
208 + -v "$SOURCE_CACHE":/source \
209 + --entrypoint skopeo \
210 + "$BUILDER" \
211 + copy "containers-storage:$IMAGE" "oci:/source/alloy:local"
212 + # Written after the copy, so a run killed half way through leaves a stamp
213 + # that does not match and the next build re-exports rather than shipping
214 + # a truncated layout.
215 + printf '%s\n' "$IMAGE_ID" | privc tee "$SOURCE_STAMP" >/dev/null
216 + fi
217 + SOURCE_MOUNT="$SOURCE_CACHE"
177 218 else
178 219 say "skipping the install source"
220 + # An empty directory rather than the cache: --skip-source means the ISO
221 + # carries no install source, and mounting a populated cache would put one
222 + # on the medium the flag says to leave off.
223 + privc mkdir -p "$WORKDIR/source"
224 + SOURCE_MOUNT="$WORKDIR/source"
179 225 fi
180 226
181 227 # 5. Mount the image's root filesystem and assemble.
@@ -197,7 +243,7 @@
197 243 --security-opt label=type:unconfined_t \
198 244 -v "$ROOTFS":/rootfs:ro \
199 245 -v "$OUTPUT":/output \
200 - -v "$WORKDIR/source":/source:ro \
246 + -v "$SOURCE_MOUNT":/source:ro \
201 247 "$BUILDER"
202 248
203 249 privc test -f "$ARTIFACT" || die "no ISO produced"
@@ -86,6 +86,10 @@
86 86
87 87 That is A/B staged with rollback, the same mechanism a registry-fed update would use, with the image coming off local container storage instead of the network. bootc 1.16.3 accepts registry, oci, oci-archive, docker-daemon and containers-storage.
88 88
89 + That is the same machine adopting its own build. For a *different* machine, and above all for a VM under `build/vmtest`, the loop that matters is `build/dev-push.sh`: it runs a registry on the dev box, pushes `localhost/alloy:local` into it, and prints the `bootc switch` line to run on the target. A registry rather than an archive because a registry moves layers, so a config-only rebuild is megabytes rather than three gigabytes down the wire. It is a development tool that lives and dies with a `--stop`, and it changes nothing about the position above: no image is published anywhere.
90 +
91 + The reason it exists is the measured shape of the loop. A config-only change is fifteen seconds of `podman build` and then four minutes of ISO assembly, and the ISO is installer media nobody is going to install from when the question is whether a sway binding works. Building one to see a config change is the single most expensive habit available here.
92 +
89 93 So "we publish nothing" costs a build, not a machine. What a user gives up against a published image is the time to build it, and what they gain is that the thing they boot is the thing they configured. An earlier draft of this file said updating meant rebuilding *and reinstalling*; that was wrong, and it made the no-registry decision look far more expensive than it is.
90 94
91 95 ## Layer structure
@@ -12,9 +12,10 @@
12 12 - **A Linux machine with podman.** The build runs rootful.
13 13 - **Disk.** The image, the container cache, and the ISO working directory
14 14 together want tens of gigabytes free.
15 - - **Time.** A cold build compiles nothing of Fedora but does pull around 600
16 - packages and compress a squashfs. Budget an hour on a laptop, more on slow
17 - storage.
15 + - **Time.** Around fifteen minutes on a current laptop with a fast mirror,
16 + most of it in three roughly equal parts: compiling the console and the
17 + terminal, pulling and installing around 600 packages, and assembling the
18 + ISO. A slow connection moves the second part and dominates the rest.
18 19
19 20 There is no path to an Alloy ISO from macOS or Windows. The build needs Linux
20 21 with podman, and nothing emulates that cheaply enough to recommend.
@@ -82,6 +83,23 @@
82 83 `crates/alloy/credits.toml`, which is what the installer's credits page
83 84 reads. It is hand-curated on purpose, so nothing adds itself.
84 85
86 + ## What the base ships that you do not need
87 +
88 + The Fedora base is a general-purpose server image, and three of the things it
89 + carries cannot be reached from an Alloy install: an AWS SDK, eighteen
90 + architectures of `qemu-user-static`, and toolbox, whose job distrobox already
91 + does. The build drops them, which is 297 MB off the image and therefore off
92 + the squashfs, the stick and the install.
93 +
94 + build/build-iso.sh --build-arg TRIM=keep
95 +
96 + keeps them, and the one capability that comes back with them is running
97 + containers built for a foreign architecture.
98 +
99 + Firmware is never trimmed. A medium you build has to boot hardware nobody
100 + asked about when it was built, so every firmware package the base ships stays
101 + in, and the build fails rather than producing an image that lost one.
102 +
85 103 Two builds a week apart can differ: the RPM set is not pinned to a snapshot
86 104 yet. Fonts and the Rust dependency tree are pinned; the Fedora packages are
87 105 not.
@@ -41,10 +41,10 @@
41 41 //!
42 42 //! [`wizard::Steps`](crate::wizard) exists and this does not use it. A wizard
43 43 //! is the shape for a fixed sequence ending in a destructive act, which is the
44 - //! installer. This is six choices that fit on one screen and are read together
45 - //! before either action is taken, and stepping through six one-question pages
46 - //! would hide the summary that is the whole point of the screen. The
47 - //! destructive act is guarded by its own confirmation instead.
44 + //! installer. This is seven choices that fit on one screen and are read
45 + //! together before either action is taken, and stepping through seven
46 + //! one-question pages would hide the summary that is the whole point of the
47 + //! screen. The destructive act is guarded by its own confirmation instead.
48 48 //!
49 49 //! # The dd path is not re-derived, and that is deliberate
50 50 //!
@@ -226,6 +226,49 @@
226 226 }
227 227 }
228 228
229 + /// What to do about the base image's own dead weight.
230 + ///
231 + /// The only choice on this screen that is about `fedora-bootc` rather than
232 + /// about Alloy. The base is a general-purpose server image and carries an AWS
233 + /// SDK, eighteen architectures of `qemu-user-static`, and toolbox, none of
234 + /// which anything in Alloy reaches; the Containerfile's `ARG TRIM` argues the
235 + /// list and holds the measurement.
236 + ///
237 + /// Size here is build time everywhere else: the ext4 populate, the squashfs,
238 + /// the write to a stick and the install all scale with it, so a base package
239 + /// nobody can reach is paid for four times.
240 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
241 + pub(crate) enum Trim {
242 + /// Drop them. The default, because the capability lost with them is
243 + /// foreign-architecture emulation, which the house rules forbid using.
244 + #[default]
245 + Unused,
246 + /// Ship the base as it comes, for whoever disagrees on their own machine.
247 + Keep,
248 + }
249 +
250 + impl Trim {
251 + const ALL: [Trim; 2] = [Trim::Unused, Trim::Keep];
252 +
253 + const fn value(self) -> &'static str {
254 + match self {
255 + Trim::Unused => "unused",
256 + Trim::Keep => "keep",
257 + }
258 + }
259 +
260 + const fn label(self) -> &'static str {
261 + match self {
262 + Trim::Unused => "drop what nothing here reaches (297 MiB)",
263 + Trim::Keep => "keep the base as it ships",
264 + }
265 + }
266 +
267 + fn parse(value: &str) -> Option<Self> {
268 + Self::ALL.into_iter().find(|t| t.value() == value)
269 + }
270 + }
271 +
229 272 /// What the build produces.
230 273 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
231 274 pub(crate) enum Artifact {
@@ -291,6 +334,7 @@
291 334 /// docs on why that property is load-bearing.
292 335 pub(crate) pubkey: String,
293 336 pub(crate) artifact: Artifact,
337 + pub(crate) trim: Trim,
294 338 }
295 339
296 340 impl Default for Choices {
@@ -306,6 +350,7 @@
306 350 hostname: String::new(),
307 351 pubkey: String::new(),
308 352 artifact: Artifact::default(),
353 + trim: Trim::default(),
309 354 }
310 355 }
311 356 }
@@ -331,6 +376,7 @@
331 376 .collect::<Vec<_>>()
332 377 .join(","),
333 378 ),
379 + ("TRIM".to_string(), self.trim.value().to_string()),
334 380 ];
335 381 if !self.hostname.is_empty() {
336 382 args.push(("ALLOY_HOSTNAME".to_string(), self.hostname.clone()));
@@ -457,6 +503,7 @@
457 503 .map(|lang| format!("{:?}", lang.value()))
458 504 .collect();
459 505 let _ = writeln!(out, "langs = [{}]", langs.join(", "));
506 + let _ = writeln!(out, "trim = {:?}", self.trim.value());
460 507 let _ = writeln!(out, "artifact = {:?}", self.artifact.value());
461 508 let _ = writeln!(out, "hostname = {:?}", self.hostname);
462 509 let _ = writeln!(out, "pubkey = {:?}", self.pubkey);
@@ -485,6 +532,9 @@
485 532 choices.browser =
486 533 Browser::parse(value).with_context(|| format!("unknown browser `{value}`"))?;
487 534 }
535 + if let Some(value) = word("trim") {
536 + choices.trim = Trim::parse(value).with_context(|| format!("unknown trim `{value}`"))?;
537 + }
488 538 if let Some(value) = word("artifact") {
489 539 choices.artifact =
490 540 Artifact::parse(value).with_context(|| format!("unknown artifact `{value}`"))?;
@@ -665,6 +715,7 @@
665 715 /// is a model nobody can predict from looking at it; four toggles are four
666 716 /// things you can see the state of.
667 717 Lang(Lang),
718 + Trim,
668 719 Artifact,
669 720 Hostname,
670 721 Pubkey,
@@ -677,7 +728,7 @@
677 728 fn all() -> Vec<Row> {
678 729 let mut rows = vec![Row::Profile, Row::Browser];
679 730 rows.extend(Lang::ALL.map(Row::Lang));
680 - rows.extend([Row::Artifact, Row::Hostname, Row::Pubkey]);
731 + rows.extend([Row::Trim, Row::Artifact, Row::Hostname, Row::Pubkey]);
681 732 rows
682 733 }
683 734
@@ -686,6 +737,7 @@
686 737 Row::Profile => "profile".to_string(),
687 738 Row::Browser => "browser".to_string(),
688 739 Row::Lang(lang) => lang.value().to_string(),
740 + Row::Trim => "base trim".to_string(),
689 741 Row::Artifact => "artifact".to_string(),
690 742 Row::Hostname => "hostname".to_string(),
691 743 Row::Pubkey => "ssh pubkey".to_string(),
@@ -699,6 +751,7 @@
699 751 }
700 752 Row::Browser => "the one stack pick Alloy declines to make for you",
701 753 Row::Lang(lang) => lang.label(),
754 + Row::Trim => "base packages nothing in Alloy reaches. Never firmware",
702 755 Row::Artifact => "ISO boots into the installer; raw and qcow2 are installed systems",
703 756 Row::Hostname => "baked in, so a headless box is found at <name>.local",
704 757 Row::Pubkey => "a PUBLIC key from ~/.ssh, or a path. The installer's only credential",
@@ -831,6 +884,9 @@
831 884 self.choices.browser = step(&Browser::ALL, self.choices.browser, forward);
832 885 }
833 886 }
887 + Row::Trim => {
888 + self.choices.trim = step(&Trim::ALL, self.choices.trim, forward);
889 + }
834 890 Row::Artifact => {
835 891 self.choices.artifact = step(&Artifact::ALL, self.choices.artifact, forward);
836 892 }
@@ -1089,6 +1145,13 @@
1089 1145 FieldKind::Toggle(self.choices.langs.contains(&lang)),
1090 1146 )
1091 1147 .indent(true),
1148 + Row::Trim => alloy_tui::AlloyField::new(
1149 + theme,
1150 + label,
1151 + FieldKind::Enum {
1152 + label: self.choices.trim.label(),
1153 + },
1154 + ),
1092 1155 Row::Artifact => alloy_tui::AlloyField::new(
1093 1156 theme,
1094 1157 label,
@@ -1259,6 +1322,9 @@
1259 1322 assert_eq!(choices.browser, Browser::Helium);
1260 1323 assert!(choices.langs.contains(&Lang::Rust));
1261 1324 assert_eq!(choices.artifact, Artifact::Iso);
1325 + // Trimmed by default. What it costs is foreign-architecture emulation,
1326 + // which the house rules forbid using in the first place.
1327 + assert_eq!(choices.trim, Trim::Unused);
1262 1328 }
1263 1329
1264 1330 /// The build args are the whole contract with the Containerfile, so their
@@ -1268,9 +1334,10 @@
1268 1334 fn the_build_args_name_what_the_containerfile_reads() {
1269 1335 let args = Choices::default().build_args();
1270 1336 let names: Vec<&str> = args.iter().map(|(k, _)| k.as_str()).collect();
1271 - assert_eq!(names, ["PROFILE", "BROWSER", "LANGS"]);
1337 + assert_eq!(names, ["PROFILE", "BROWSER", "LANGS", "TRIM"]);
1272 1338 assert_eq!(args[0].1, "client");
1273 1339 assert_eq!(args[2].1, "rust");
1340 + assert_eq!(args[3].1, "unused");
1274 1341 }
1275 1342
1276 1343 #[test]
@@ -1336,6 +1403,7 @@
1336 1403 browser: Browser::None,
1337 1404 langs: BTreeSet::from([Lang::Go]),
1338 1405 artifact: Artifact::Qcow2,
1406 + trim: Trim::Keep,
1339 1407 hostname: "bench".to_string(),
1340 1408 pubkey: "/home/max/.ssh/id_ed25519.pub".to_string(),
1341 1409 };
@@ -1345,6 +1413,7 @@
1345 1413 assert_eq!(parsed.browser, Browser::None);
1346 1414 assert_eq!(parsed.langs, BTreeSet::from([Lang::Go]));
1347 1415 assert_eq!(parsed.artifact, Artifact::Qcow2);
1416 + assert_eq!(parsed.trim, Trim::Keep);
1348 1417 assert_eq!(parsed.hostname, "bench");
1349 1418 assert_eq!(parsed.pubkey, "/home/max/.ssh/id_ed25519.pub");
1350 1419 }
@@ -80,7 +80,7 @@
80 80 /// `HOME` is not enough because the path is `/usr/lib/alloy`. Rewriting the
81 81 /// path for the test rather than mocking it keeps the script byte-identical to
82 82 /// the one in the image apart from its prefix.
83 - fn record_for(profile: &str, browser: &str, langs: &str, hostname: &str) -> String {
83 + fn record_for(profile: &str, browser: &str, langs: &str, trim: &str, hostname: &str) -> String {
84 84 // Unique per call, not per set of arguments. Tests run concurrently and
85 85 // three of them ask for the same combination, so a name derived from the
86 86 // arguments had two threads sharing one directory and removing it from
@@ -105,6 +105,7 @@
105 105 .env("PROFILE", profile)
106 106 .env("BROWSER", browser)
107 107 .env("LANGS", langs)
108 + .env("TRIM", trim)
108 109 .env("ALLOY_HOSTNAME", hostname)
109 110 .env("ALLOY_SSH_KEY", "")
110 111 .output()
@@ -112,7 +113,7 @@
112 113
113 114 assert!(
114 115 output.status.success(),
115 - "the record writer failed for profile={profile} browser={browser} langs={langs}:\n{}",
116 + "the record writer failed for profile={profile} browser={browser} langs={langs} trim={trim}:\n{}",
116 117 String::from_utf8_lossy(&output.stderr),
117 118 );
118 119
@@ -123,20 +124,20 @@
123 124 }
124 125
125 126 /// The contract, over the combinations that differ structurally: both
126 - /// profiles, every browser, an empty and a multi-entry language list, and a
127 - /// hostname both set and unset.
127 + /// profiles, every browser, an empty and a multi-entry language list, both
128 + /// trims, and a hostname both set and unset.
128 129 #[test]
129 130 fn every_record_the_build_can_write_parses() {
130 131 let cases = [
131 - ("client", "helium", "rust", "bench"),
132 - ("client", "firefox", "rust,go,python,zig", ""),
133 - ("client", "none", "", "laptop"),
134 - ("server", "none", "rust", "build-host-2"),
135 - ("server", "none", "", ""),
132 + ("client", "helium", "rust", "unused", "bench"),
133 + ("client", "firefox", "rust,go,python,zig", "keep", ""),
134 + ("client", "none", "", "unused", "laptop"),
135 + ("server", "none", "rust", "keep", "build-host-2"),
136 + ("server", "none", "", "unused", ""),
136 137 ];
137 138
138 - for (profile, browser, langs, hostname) in cases {
139 - let record = record_for(profile, browser, langs, hostname);
139 + for (profile, browser, langs, trim, hostname) in cases {
140 + let record = record_for(profile, browser, langs, trim, hostname);
140 141
141 142 let parsed = alloy_build_record::parse(&record).unwrap_or_else(|err| {
142 143 panic!(
@@ -148,6 +149,7 @@
148 149 assert_eq!(parsed.profile, profile, "profile round trip\n{record}");
149 150 assert_eq!(parsed.browser, browser, "browser round trip\n{record}");
150 151 assert_eq!(parsed.hostname, hostname, "hostname round trip\n{record}");
152 + assert_eq!(parsed.trim, trim, "trim round trip\n{record}");
151 153
152 154 let expected: Vec<&str> = if langs.is_empty() {
153 155 Vec::new()
@@ -163,7 +165,7 @@
163 165 /// empty string. It parses either way as TOML; only one of them is true.
164 166 #[test]
165 167 fn no_languages_is_an_empty_list_rather_than_one_empty_entry() {
166 - let record = record_for("server", "none", "", "");
168 + let record = record_for("server", "none", "", "unused", "");
167 169 assert!(
168 170 record.contains("langs = []"),
169 171 "an empty LANGS must write an empty array:\n{record}"
@@ -175,7 +177,7 @@
175 177 /// it reproduces decisions rather than an image.
176 178 #[test]
177 179 fn the_record_disclaims_being_a_lockfile() {
178 - let record = record_for("client", "helium", "rust", "bench");
180 + let record = record_for("client", "helium", "rust", "unused", "bench");
179 181 assert!(
180 182 record.contains("not a lockfile"),
181 183 "the record must say it pins choices and not resolutions:\n{record}"
@@ -187,7 +189,7 @@
187 189 /// shape and nobody reads a missing key as a key that was lost.
188 190 #[test]
189 191 fn the_image_record_carries_no_pubkey_path() {
190 - let record = record_for("client", "helium", "rust", "bench");
192 + let record = record_for("client", "helium", "rust", "unused", "bench");
191 193 assert!(record.contains("pubkey = \"\""), "{record}");
192 194 }
193 195
@@ -205,6 +207,7 @@
205 207 pub(crate) profile: String,
206 208 pub(crate) browser: String,
207 209 pub(crate) langs: Vec<String>,
210 + pub(crate) trim: String,
208 211 pub(crate) hostname: String,
209 212 }
210 213
@@ -237,6 +240,7 @@
237 240 profile: word("profile")?,
238 241 browser: word("browser")?,
239 242 langs,
243 + trim: word("trim")?,
240 244 hostname: word("hostname")?,
241 245 })
242 246 }
@@ -1,0 +1,147 @@
1 + #!/usr/bin/env bash
2 + #
3 + # dev-push.sh — hand a freshly built image to a machine already running Alloy,
4 + # without building an ISO.
5 + #
6 + # The ISO is how a machine is installed. It is the wrong artifact for "did my
7 + # sway config land", and using it that way is what makes the edit-to-look-at-it
8 + # loop four minutes long: measured on fw13, a config-only change is 15 seconds
9 + # of `podman build` followed by an ISO assembly that costs sixteen times that
10 + # and produces installer media nobody is going to install from.
11 + #
12 + # An installed machine adopts a locally built image in place. That is not a
13 + # workaround: it is the same A/B staged switch with the same rollback that a
14 + # registry-fed update uses (docs/IMAGE.md, "Updates"), with the image coming
15 + # off a registry on this box instead of one on the internet.
16 + #
17 + # Why a registry and not a file. bootc reads several transports, and the
18 + # obvious ones move the whole image every time: `containers-storage` is not
19 + # reachable from another machine at all, and an archive is 3 GB down the wire
20 + # per rebuild. A registry moves layers, and a config-only rebuild changes the
21 + # tail of the image, so the second push and every push after it is megabytes.
22 + # The registry is a dev tool that runs on this box and is torn down with
23 + # `--stop`; nothing about it is the distribution story, which stays "no image
24 + # is published anywhere" (docs/IMAGE.md, "Registry").
25 + #
26 + # Usage:
27 + # build/dev-push.sh # push localhost/alloy:local, print the
28 + # # line to run on the target
29 + # build/dev-push.sh --address HOST # print that line for a given address
30 + # build/dev-push.sh --port 5000 # a different port
31 + # build/dev-push.sh --stop # stop the registry and forget its blobs
32 + #
33 + # On the target, the first time:
34 + #
35 + # printf '[[registry]]\nlocation = "ADDR"\ninsecure = true\n' \
36 + # | sudo tee /etc/containers/registries.conf.d/99-alloy-dev.conf
37 + # sudo bootc switch --transport registry ADDR/alloy:local
38 + # sudo systemctl reboot
39 + #
40 + # and after that, for every later push, `sudo bootc upgrade && sudo systemctl
41 + # reboot`: the machine remembers where it was switched to.
42 + #
43 + # The insecure line is what plain HTTP costs. It is scoped to one address in a
44 + # drop-in file, it is a development machine talking to a registry on the same
45 + # desk, and `build/vmtest` is the intended target. Do not carry it onto a
46 + # machine that matters.
47 +
48 + set -euo pipefail
49 +
50 + REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
51 +
52 + # priv / privc. run0 where it exists, sudo where it does not; see the header
53 + # of build/privilege.sh for which of the two a call site wants.
54 + # shellcheck source=build/privilege.sh
55 + . "$REPO_ROOT/build/privilege.sh"
56 +
57 + IMAGE="localhost/alloy:local"
58 + REGISTRY_IMAGE="docker.io/library/registry:2"
59 + CONTAINER="alloy-dev-registry"
60 + # A named volume rather than a tmpfs, so the blobs of the last push survive a
61 + # restart. That is the whole economy of this script: what makes the second
62 + # push cheap is the registry already holding the layers that did not change.
63 + VOLUME="alloy-dev-registry"
64 + PORT=5000
65 + ADDRESS=""
66 + STOP=0
67 +
68 + die() { printf 'error: %s\n' "$*" >&2; exit 1; }
69 + say() { printf '==> %s\n' "$*"; }
70 +
71 + while [ $# -gt 0 ]; do
72 + case "$1" in
73 + --address) ADDRESS="${2:?--address needs a host}"; shift 2 ;;
74 + --port) PORT="${2:?--port needs a number}"; shift 2 ;;
75 + --stop) STOP=1; shift ;;
76 + # The header block is the help text, so it stops where the comments stop.
77 + -h|--help) awk 'NR==1 {next} !/^#/ {exit} {sub(/^# ?/, ""); print}' "${BASH_SOURCE[0]}"; exit 0 ;;
78 + *) die "unknown argument: $1 (see --help)" ;;
79 + esac
80 + done
81 +
82 + command -v podman >/dev/null || die "podman not found"
83 +
84 + if [ "$STOP" -eq 1 ]; then
85 + say "stopping $CONTAINER"
86 + privc podman rm -f "$CONTAINER" >/dev/null 2>&1 || true
87 + privc podman volume rm "$VOLUME" >/dev/null 2>&1 || true
88 + say "stopped. The next push starts from an empty registry and moves the whole image."
89 + exit 0
90 + fi
91 +
92 + privc podman image exists "$IMAGE" \
93 + || die "$IMAGE is not in the root store; build it first with build/build-iso.sh --skip-source or podman build"
94 +
95 + # The registry, started if it is not already up. Rootful, because the image it
96 + # serves lives in the root store and this script pushes from there.
97 + if [ "$(privc podman inspect -f '{{.State.Running}}' "$CONTAINER" 2>/dev/null || echo false)" != "true" ]; then
98 + privc podman rm -f "$CONTAINER" >/dev/null 2>&1 || true
99 + say "starting $CONTAINER on :$PORT"
100 + priv podman run -d --name "$CONTAINER" \
101 + -p "$PORT:5000" \
102 + -v "$VOLUME:/var/lib/registry" \
103 + "$REGISTRY_IMAGE" >/dev/null
104 + else
105 + say "$CONTAINER is up"
106 + fi
107 +
108 + # skopeo out of the Alloy image itself. The base carries it, so this needs no
109 + # skopeo on the host — fw13 is Pop!_OS and has none — and no second image to
110 + # keep current. --network host so 127.0.0.1 means this box rather than the
111 + # skopeo container's own loopback, which is the failure that looks like the
112 + # registry being down.
113 + say "pushing $IMAGE (first push moves the image; later ones move what changed)"
114 + priv podman run --rm --privileged --network host \
115 + --security-opt label=type:unconfined_t \
116 + -v /var/lib/containers/storage:/var/lib/containers/storage \
117 + --entrypoint skopeo \
118 + "$IMAGE" \
119 + copy --dest-tls-verify=false \
120 + "containers-storage:$IMAGE" "docker://127.0.0.1:$PORT/alloy:local"
121 +
122 + # What to type on the target. Printed rather than run over ssh: the target is a
123 + # machine that is about to be told to boot something else, and the script that
124 + # builds an image should not also be the thing that reboots your laptop.
125 + if [ -z "$ADDRESS" ]; then
126 + echo
127 + echo " Reachable as, depending on what the target is:"
128 + echo " 10.0.2.2:$PORT a qemu guest on user-mode networking (build/vmtest)"
129 + echo " <this host>:$PORT anything else, by tailnet name or LAN address"
130 + ADDRESS="<host>:$PORT"
131 + else
132 + ADDRESS="$ADDRESS:$PORT"
133 + fi
134 +
135 + cat <<EOF
136 +
137 + On the target, once:
138 + printf '[[registry]]\\nlocation = "$ADDRESS"\\ninsecure = true\\n' \\
139 + | sudo tee /etc/containers/registries.conf.d/99-alloy-dev.conf
140 + sudo bootc switch --transport registry $ADDRESS/alloy:local
141 + sudo systemctl reboot
142 +
143 + And for every push after that:
144 + sudo bootc upgrade && sudo systemctl reboot
145 +
146 + Rolling back is bootc's own: sudo bootc rollback && sudo systemctl reboot
147 + EOF
@@ -1,0 +1,133 @@
1 + //! `TRIM` may never reach firmware, and the Containerfile is where that holds.
2 + //!
3 + //! The trim exists because size in the image is build time everywhere after it:
4 + //! the ext4 populate, the squashfs, the write to a stick, the install. That
5 + //! makes the list of packages it removes a thing people will want to extend,
6 + //! and the largest single candidate a naive extension finds is
7 + //! `nvidia-gpu-firmware` at 101 MiB, with the rest of `*-firmware` behind it.
8 + //!
9 + //! Taking any of it would be wrong in a way no build catches. A medium built
10 + //! here has to boot hardware nobody was asked about at build time, and astra
11 + //! needs the nvidia blobs specifically. The image would build clean, the ISO
12 + //! would write clean, and the failure would arrive as a machine that does not
13 + //! come up on somebody else's desk.
14 + //!
15 + //! So the rule is a text check over the Containerfile rather than a sentence in
16 + //! a comment: no package the trim removes may be a firmware package. In the
17 + //! same spirit as `profile_split.rs` and `build_context.rs`, and for the same
18 + //! reason — it runs on every `cargo test` and costs nothing, where a build only
19 + //! catches what a build can see.
20 +
21 + use std::path::PathBuf;
22 +
23 + fn containerfile() -> String {
24 + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../Containerfile");
25 + std::fs::read_to_string(&path)
26 + .unwrap_or_else(|err| panic!("cannot read {}: {err}", path.display()))
27 + }
28 +
29 + /// The `RUN` block that acts on `$TRIM`, with continuations joined and comment
30 + /// lines dropped, the way the image parser hands it to the shell.
31 + ///
32 + /// Pulled out of the file rather than restated here: a copy would keep passing
33 + /// while the real block grew a line naming a firmware package.
34 + fn trim_instruction(text: &str) -> String {
35 + let mut instruction: Option<String> = None;
36 +
37 + for raw in text.lines() {
38 + let line = raw.trim_end();
39 + let trimmed = line.trim_start();
40 + if trimmed.starts_with('#') {
41 + continue;
42 + }
43 +
44 + match &mut instruction {
45 + None => {
46 + let Some(body) = trimmed.strip_prefix("RUN ") else {
47 + continue;
48 + };
49 + instruction = Some(body.to_string());
50 + }
51 + Some(current) => {
52 + current.push(' ');
53 + current.push_str(trimmed);
54 + }
55 + }
56 +
57 + if line.ends_with('\\') {
58 + let current = instruction.as_mut().expect("inside an instruction");
59 + current.pop();
60 + continue;
61 + }
62 +
63 + let finished = instruction.take().expect("just built one");
64 + // The removal block, not the validation block: both mention $TRIM, and
65 + // only one of them removes anything.
66 + if finished.contains("TRIM") && finished.contains("dnf remove") {
67 + return finished;
68 + }
69 + }
70 +
71 + panic!("no RUN block in the Containerfile removes packages under $TRIM");
72 + }
73 +
74 + /// The packages the block removes, read off the `list=` assignment it builds
75 + /// them from.
76 + ///
77 + /// The list is a named variable in the Containerfile for this test's sake, so
78 + /// that what gets removed can be read without parsing shell. An extraction that
79 + /// finds nothing panics rather than passing: a block that no longer has the
80 + /// shape this reads is a block this test is no longer checking.
81 + fn trimmed_packages(instruction: &str) -> Vec<String> {
82 + let (_, after) = instruction
83 + .split_once("list=\"")
84 + .expect("the trim block assigns its package list to `list=`");
85 + let (list, _) = after
86 + .split_once('"')
87 + .expect("the `list=` assignment is not closed");
88 +
89 + list.split_whitespace().map(str::to_string).collect()
90 + }
91 +
92 + #[test]
93 + fn the_trim_removes_no_firmware() {
94 + let instruction = trim_instruction(&containerfile());
95 + let packages = trimmed_packages(&instruction);
96 + assert!(
97 + !packages.is_empty(),
98 + "the trim removes nothing:\n{instruction}"
99 + );
100 +
101 + for package in packages {
102 + assert!(
103 + !package.contains("firmware"),
104 + "the trim list names `{package}`, and firmware is exactly what it must never take: \
105 + a medium built here boots hardware nobody was asked about at build time, and astra \
106 + needs the nvidia blobs.\n\n{instruction}"
107 + );
108 + }
109 + }
110 +
111 + /// The other half of the same rule. Removing is one way to lose firmware; the
112 + /// block is also where the check that it is still present lives, and a rewrite
113 + /// that dropped the check would leave nothing asserting the rule at build time.
114 + #[test]
115 + fn the_trim_block_asserts_firmware_survived() {
116 + let instruction = trim_instruction(&containerfile());
117 + assert!(
118 + instruction.contains("linux-firmware") && instruction.contains("nvidia-gpu-firmware"),
119 + "the trim block must assert firmware is still installed, on both branches:\n\n{instruction}"
120 + );
121 + }
122 +
123 + /// `TRIM` is a word from a known set before anything reads it, the same way
124 + /// `PROFILE` and `BROWSER` are. A typo has to fail at the argument rather than
125 + /// fall through to the `else` and quietly build the untrimmed image.
126 + #[test]
127 + fn an_unknown_trim_is_refused_before_it_is_read() {
128 + let text = containerfile();
129 + assert!(
130 + text.contains(r#"case "$TRIM" in"#),
131 + "TRIM is not validated against its set; a typo would build the wrong image silently"
132 + );
133 + }