max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
9 files changed,
+470 insertions,
-43 deletions
| @@ -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" |
| @@ -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 | + | } |