| 1 |
# Alloy — bootable container image |
| 2 |
# |
| 3 |
# Built with `podman build`. Nothing is published: distribution is the builder |
| 4 |
# and not the artifact, so `quay.io/alloy/alloy` is not a future address either |
| 5 |
# (decided 2026-07-30; docs/STACK.md#distribution, docs/IMAGE.md). |
| 6 |
# See docs/IMAGE.md for the composition strategy and rationale. |
| 7 |
# |
| 8 |
# Layer order optimizes rebuild speed: repos first (rarely change), |
| 9 |
# then fonts (large downloads, pinned), then package installs (change |
| 10 |
# with STACK.md), then config tree (changes most often, so lives at |
| 11 |
# the tail). |
| 12 |
# |
| 13 |
# Two stages. Everything the image runs ships from a repo except the |
| 14 |
# Alloy console, which is this project's own binary and has no repo to |
| 15 |
# ship from — the one case docs/IMAGE.md's stage policy allows. Screen |
| 16 |
# recording is still a gap (docs/STACK.md#screen-recorder). |
| 17 |
|
| 18 |
# ===================================================================== |
| 19 |
# Build stage — the Alloy console. |
| 20 |
# ===================================================================== |
| 21 |
# Fedora 43 rather than a rust: image or the host, so the toolchain and |
| 22 |
# the glibc the console links against are the ones it will run on. This |
| 23 |
# is also why the console is not simply built on fw13 and copied in: |
| 24 |
# fw13 is Pop!_OS 24.04, a different libc, and per CLAUDE.md builds are |
| 25 |
# native per architecture rather than cross-compiled. |
| 26 |
# |
| 27 |
# Cargo.toml pins edition 2024 and rust-version 1.86; Fedora 43 clears |
| 28 |
# both. `--locked` so the image builds the dependency graph the repo |
| 29 |
# committed rather than whatever resolves that day. |
| 30 |
# |
| 31 |
# Pinned by digest, and the tag is kept alongside it for readability only — |
| 32 |
# the digest is what resolves. `:43` is a floating tag: it moves on every |
| 33 |
# Fedora respin, so two builds of the same alloy commit a week apart used to |
| 34 |
# build against different toolchains and different glibc. That is exactly the |
| 35 |
# drift the fonts below are pinned to avoid, and the font comment's argument |
| 36 |
# ("a cache hit stops meaning the same bytes") was never carried to `FROM` |
| 37 |
# until now. See wiki `alloy-distribution`, "Rebuilding a past image". |
| 38 |
# |
| 39 |
# This is the multi-arch index digest, not a per-arch manifest, so it still |
| 40 |
# resolves natively on both build hosts (fw13 amd64, astra arm64) — CLAUDE.md |
| 41 |
# forbids cross-compiling, so a per-arch pin would break one of them. |
| 42 |
# |
| 43 |
# To move it: build/refresh-base-digests.sh, which resolves the tags and |
| 44 |
# rewrites these two lines. Do that deliberately, not as drive-by maintenance. |
| 45 |
FROM registry.fedoraproject.org/fedora:43@sha256:4432141107de445eae7bcb3b5b9d29d39136109cff407bcaf517109c84f789ef AS rust-build |
| 46 |
|
| 47 |
# No python3 any more. It was here for tools/vtrgb.py, which rendered the |
| 48 |
# greeter's console palette; skelgen emits that table now, along with the rest |
| 49 |
# of the desktop skeleton, so there is one implementation of the ANSI mapping |
| 50 |
# instead of three. |
| 51 |
# git, and the three libraries shop links, on top of the toolchain. The console |
| 52 |
# needs none of them; shop is Wayland and GPU facing, so it links |
| 53 |
# libwayland-client and libxkbcommon at build time and will not compile |
| 54 |
# without their headers. `ldd` on the built binary is the check if this list |
| 55 |
# ever looks wrong. |
| 56 |
# |
| 57 |
# fontconfig-devel is the third, and it arrived with the terminal rather than |
| 58 |
# with anything here: shop asks the system which font carries a glyph the |
| 59 |
# bundled Iosevka lacks (CJK, hangul), and the crate behind that question links |
| 60 |
# libfontconfig through pkg-config rather than dlopening it. So a SHOP_REV move |
| 61 |
# can add a build dependency, which is what happened at shop@53551eb and broke |
| 62 |
# both profiles at this step. The runtime already carries fontconfig for the |
| 63 |
# fonts layer, so only the headers were ever missing. |
| 64 |
# |
| 65 |
# rpm-build and createrepo_c are the last two, and they are not build tools for |
| 66 |
# either binary. This stage packages both into RPMs at the end (see "The |
| 67 |
# component repo" below), because the runtime image carries our components as |
| 68 |
# uninstalled packages rather than as files. A stage of its own would want a |
| 69 |
# third digest-pinned FROM, and build/refresh-base-digests.sh rewrites exactly |
| 70 |
# two. |
| 71 |
RUN dnf install -y cargo rust git wayland-devel libxkbcommon-devel \ |
| 72 |
fontconfig-devel pkgconf rpm-build createrepo_c \ |
| 73 |
&& dnf clean all |
| 74 |
|
| 75 |
# How many rustc jobs, taken from memory rather than from core count. |
| 76 |
# |
| 77 |
# cargo sizes `-j` by cores alone, and a heavy crate costs rustc around 3 GB. |
| 78 |
# fw13 is 12 cores against 14 GB, so the default aims twelve of those at |
| 79 |
# fourteen gigabytes and the machine pages instead of compiling: measured |
| 80 |
# across ~/Code on 2026-08-11, two concurrent clippy-drivers at 3.0 GB each |
| 81 |
# with 460 MB free and a quarter to a third of wall-clock stalled on paging. |
| 82 |
# The tree's own ~/.cargo config caps it at six for that reason, and that file |
| 83 |
# is outside the build context and cannot reach in here. |
| 84 |
# |
| 85 |
# So the rule is derived rather than copied: this Containerfile is what |
| 86 |
# strangers build Alloy with, and their boxes are not fw13. Half the gigabytes, |
| 87 |
# never more than the cores, never less than one. |
| 88 |
# |
| 89 |
# It matters more since the two stages build concurrently (`--jobs 2` in |
| 90 |
# build/build-iso.sh and build/build-image.sh): the package installs of the |
| 91 |
# runtime stage now overlap this one's compiles, and paging here would hand |
| 92 |
# back exactly what the overlap was worth. |
| 93 |
RUN set -eu; \ |
| 94 |
gb=$(awk '/MemTotal/ {print int($2 / 1024 / 1024)}' /proc/meminfo); \ |
| 95 |
jobs=$(( gb / 2 )); \ |
| 96 |
[ "$jobs" -ge 1 ] || jobs=1; \ |
| 97 |
[ "$jobs" -le "$(nproc)" ] || jobs=$(nproc); \ |
| 98 |
mkdir -p /root/.cargo; \ |
| 99 |
printf '[build]\njobs = %d\n' "$jobs" > /root/.cargo/config.toml; \ |
| 100 |
echo "cargo: $jobs jobs ($(nproc) cores, ${gb} GB)" |
| 101 |
|
| 102 |
# ===================================================================== |
| 103 |
# shop, Alloy's terminal. |
| 104 |
# ===================================================================== |
| 105 |
# Built from source because nothing serves a prebuilt shop package. shop is |
| 106 |
# packaged — `build/rpm/shop.spec` is written and the RPM stage 600 lines below |
| 107 |
# cuts it from the binary this stage produces — but the spec has no `%build`, |
| 108 |
# so this stage is still the only definition of how shop is compiled and the |
| 109 |
# only place that compiles it. |
| 110 |
# |
| 111 |
# THIS ARRANGEMENT HAS A STATED END: GoingsOn alloy `a29eccdf`. When something |
| 112 |
# else cuts shop's package, this stage installs it and the terminal stops being |
| 113 |
# compiled here. Everything below that is scaffolding for building from a git |
| 114 |
# URL retires with it, and that comment names the list. It is blocked on who |
| 115 |
# builds the package and on which arches (`f3544f34`), and on somewhere to |
| 116 |
# serve it from (`86cb87b9`). Until then the scaffolding stays and is load |
| 117 |
# bearing; do not thin it out on the grounds that it is temporary. |
| 118 |
# |
| 119 |
# Builders-not-images is untouched by any of that: users still build their own |
| 120 |
# ISO. What a served repo buys is that they stop having to rebuild it to |
| 121 |
# receive a fix (GoingsOn `d866e125`). |
| 122 |
# |
| 123 |
# The consequence for this stage, and it holds either way: a component the base |
| 124 |
# image carries cannot be replaced client-side, so a hotfixable component must |
| 125 |
# NOT be built into the image at all. It is layered at install time instead. |
| 126 |
# See build/layertest/README.md for the measurements that force this, and note |
| 127 |
# that a `COPY` into /usr/bin is the worst of the shapes — an unowned file |
| 128 |
# cannot even be layered over, it dies in checkout. |
| 129 |
# |
| 130 |
# The mirror of quasi-type's pinned bases, named here rather than in the font |
| 131 |
# stage below because the fetch it exists for is `cargo install shop`, 180 lines |
| 132 |
# further down. shop's `shop-font/build.rs` cuts a third face from the same |
| 133 |
# Atkinson base with the cache directory and the offline flag both hardcoded, so |
| 134 |
# the seed cache the font stage carries has no path and no flag to aim at. A |
| 135 |
# mirror is the only one of the available fixes that covers that fetch without |
| 136 |
# shop changing, which is why it is an ENV over the whole stage rather than a |
| 137 |
# flag on the cut. |
| 138 |
# |
| 139 |
# A base URL. quasi-type addresses every pinned file under it by the sha256 the |
| 140 |
# pin already carries, tries it before upstream, and verifies the bytes against |
| 141 |
# that digest either way, so a mirror can serve the pinned file or nothing. It |
| 142 |
# cannot serve a different one, which is what makes this a second source rather |
| 143 |
# than a second thing to trust. Bytes that do not match fall through to the |
| 144 |
# pinned url rather than failing the build, so an out-of-date mirror is not |
| 145 |
# worse than no mirror. |
| 146 |
# |
| 147 |
# Set it empty to switch it off and fetch upstream only, which is what a build |
| 148 |
# with no access to our infrastructure wants. Nothing requires it to answer: the |
| 149 |
# font stage reports which pinned files it holds rather than asserting any, since |
| 150 |
# a mirror that is down is a slower build and not a broken one. |
| 151 |
# |
| 152 |
# Needs a QUASI_TYPE_REV at or after 43845e3, where the variable starts being |
| 153 |
# read, and a SHOP_REV whose own quasi-type pin is at or after it. All three move |
| 154 |
# together for that reason. The files are in the MNW server's `static/bases/`, |
| 155 |
# served by the `/static` ServeDir with no route of its own. |
| 156 |
ARG QUASI_TYPE_MIRROR=https://makenot.work/static/bases |
| 157 |
ENV QUASI_TYPE_MIRROR=${QUASI_TYPE_MIRROR} |
| 158 |
|
| 159 |
# Pinned by revision, not by branch. An unpinned build is a different image |
| 160 |
# every day for the same Containerfile, and the terminal is the one component |
| 161 |
# where "it worked yesterday" has no recovery path from inside the session. |
| 162 |
# Bump this deliberately, and run `build/check-rust-stage.sh` before pushing |
| 163 |
# the bump: shop links its system libraries rather than dlopening them, so a |
| 164 |
# revision can arrive needing a -devel package the dnf line above does not |
| 165 |
# install, and this file is where that has to be fixed. On 2026-08-09 nobody |
| 166 |
# ran it and the break reached three remotes. |
| 167 |
# |
| 168 |
# That sentence used to end there, as an instruction. It is a gate now: a clean |
| 169 |
# run records the revision in `build/rust-stage-verified`, and |
| 170 |
# `crates/alloy/tests/shop_rev_verified.rs` fails while this line names a |
| 171 |
# different one, so an unchecked bump is refused by `cargo test` rather than by |
| 172 |
# an hour of podman. The astra sweep still runs the script nightly |
| 173 |
# (`rust-stage`), which is the backstop for a pinned revision that stops |
| 174 |
# building without anyone editing this file. |
| 175 |
# |
| 176 |
# `cargo install` rather than a second COPY-and-build stage: shop is a separate |
| 177 |
# repo with its own workspace, so there is no local tree to copy and no |
| 178 |
# existing pin to imitate. It used to carry `--locked` as the console's build |
| 179 |
# below does; see the comment on the install itself for why it cannot any more. |
| 180 |
# |
| 181 |
# Above the console's source on purpose. This layer is keyed on the revision |
| 182 |
# alone, so console edits — the thing that changes most often — reuse it |
| 183 |
# instead of rebuilding a terminal that did not change. Moving it below the |
| 184 |
# `COPY crates/` would defeat the dependency-cache split that follows. |
| 185 |
ARG SHOP_REV=144438245fa3b25b897f4c61ae88f49b3508f66a |
| 186 |
|
| 187 |
# Does the remote have anything to build from, before anything expensive runs. |
| 188 |
# |
| 189 |
# A guard against a state this image was actually in for several days. shop was |
| 190 |
# registered and listed on makenot.work but had never received a push, because |
| 191 |
# of the mnw-cli receive-pack deadlock, so the URL served zero refs and every |
| 192 |
# build died at this step. `cargo install` said `revspec '07fb4e70...' not |
| 193 |
# found`, which reads as a bad SHA and sends you to check the revision. The |
| 194 |
# revision was fine. The remote was empty. Fixed since: the push landed and the |
| 195 |
# remote carries main. |
| 196 |
# |
| 197 |
# An empty repo is not an error to git. `ls-remote` on one exits 0 and prints |
| 198 |
# nothing, which is why nothing downstream caught it, while a genuinely absent |
| 199 |
# repo fails loudly on its own. So counting refs is the check that separates |
| 200 |
# "published" from merely "named", and it is cheap enough to keep forever. |
| 201 |
# |
| 202 |
# What this does NOT prove is that ${SHOP_REV} is in the remote. Reachability of |
| 203 |
# an arbitrary commit is not a question the wire protocol answers: ls-remote |
| 204 |
# lists ref tips, a pin is allowed to point below one, and makenot.work refuses |
| 205 |
# unadvertised objects, so a direct fetch of a non-tip rev fails even when the |
| 206 |
# commit is present. The pin is normally an ancestor of main rather than its |
| 207 |
# tip, which is the ordinary state of a deliberate pin and not a fault. So a |
| 208 |
# non-tip rev is reported and left to cargo, which clones and resolves it from |
| 209 |
# history, rather than failed here on evidence that cannot support it. |
| 210 |
RUN set -eu; \ |
| 211 |
url=https://makenot.work/git/max/shop.git; \ |
| 212 |
refs=$(git ls-remote "$url" 2>/dev/null) \ |
| 213 |
|| { echo "cannot reach $url to check SHOP_REV" >&2; exit 1; }; \ |
| 214 |
[ -n "$refs" ] \ |
| 215 |
|| { echo "$url serves zero refs, so no revision can resolve. shop is named on the server but nothing has been pushed to it. See GoingsOn alloy problem 49732815 for the last time this happened." >&2; exit 1; }; \ |
| 216 |
if echo "$refs" | grep -q "^${SHOP_REV}"; then \ |
| 217 |
echo "SHOP_REV ${SHOP_REV} is a ref tip on the remote"; \ |
| 218 |
else \ |
| 219 |
echo "SHOP_REV ${SHOP_REV} is not a ref tip; it may still be reachable, leaving it to cargo"; \ |
| 220 |
fi |
| 221 |
|
| 222 |
# `--locked` came off on 2026-08-17, and the reason is a property of the tree |
| 223 |
# rather than of shop. shop now takes an in-house git dependency (`quasi-type`, |
| 224 |
# which cuts its bundled face), and every machine in that tree redirects in-house |
| 225 |
# git URLs to working copies through `~/Code/.cargo/config.toml`'s `[patch]` |
| 226 |
# block. Cargo writes a patched package into the lock with no `source` line, so |
| 227 |
# the committed lock names a package a fresh clone cannot resolve and `--locked` |
| 228 |
# refuses to update it: measured, `cargo metadata --locked` on a clean clone of |
| 229 |
# shop fails outright. The tree's own note says as much — `--locked` is off the |
| 230 |
# table for as long as that block exists. |
| 231 |
# |
| 232 |
# What replaces the guarantee, for the part that matters here: shop pins |
| 233 |
# `quasi-type` by revision in its own manifest, so the face it bundles is a |
| 234 |
# pinned cut rather than whatever resolved that day, and it is the same cut this |
| 235 |
# image installs. Everything else in shop's graph is a crates.io semver |
| 236 |
# requirement, which is the same latitude every other build in the tree takes. |
| 237 |
RUN cargo install \ |
| 238 |
--git https://makenot.work/git/max/shop.git \ |
| 239 |
--rev "${SHOP_REV}" \ |
| 240 |
--root /shop \ |
| 241 |
shop |
| 242 |
|
| 243 |
# The binary exists and runs. Every other way this fails is silent at build |
| 244 |
# time and total at first login: sway binds $mod+Return to `shop` and the |
| 245 |
# launcher spawns `${TERMINAL:-shop}`, so a missing binary is a session with |
| 246 |
# no way to reach a shell and no way to edit the config that would fix it, |
| 247 |
# short of a VT. Same reasoning as the console's stub assertions below. |
| 248 |
RUN test -x /shop/bin/shop \ |
| 249 |
|| { echo "shop did not install; the session would have no terminal" >&2; exit 1; } |
| 250 |
|
| 251 |
# ===================================================================== |
| 252 |
# The house faces, cut rather than downloaded. |
| 253 |
# |
| 254 |
# Quasi Mono and Quasi Body are a pinned base plus the house glyph set, run |
| 255 |
# through `quasi-type` (wiki `typography-standard`). They replaced a 60 MB |
| 256 |
# IosevkaTerm Nerd Font download and an upstream Atkinson tarball, and the |
| 257 |
# marks in them are the same drawings every other Alloy surface uses instead |
| 258 |
# of whatever each renderer's fallback ordered first. |
| 259 |
# |
| 260 |
# Cut here rather than fetched because there is nothing to fetch: built faces |
| 261 |
# are deliberately not committed anywhere, since a font in a repo is a second |
| 262 |
# source of truth that nothing rebuilds. The pipeline pins its own bases by |
| 263 |
# sha256, so this layer is reproducible for the same reason the download it |
| 264 |
# replaced was. |
| 265 |
# |
| 266 |
# A clone and `cargo run` rather than `cargo install`: the tool reads its pins |
| 267 |
# and its cache out of the checkout it is run from, so an installed binary has |
| 268 |
# nowhere to put a downloaded base. |
| 269 |
# |
| 270 |
# Above the console's source for the same reason shop is: keyed on a revision |
| 271 |
# that changes rarely, so a console edit does not recut two fonts. |
| 272 |
# |
| 273 |
# THE PART THAT IS NOT REPRODUCIBLE IS AVAILABILITY, and it took the build down |
| 274 |
# on 2026-08-17. The pins name files on raw.githubusercontent.com, which |
| 275 |
# rate-limits by IP, and one clean build asks it three times: the two cuts below |
| 276 |
# and shop's own build script, which cuts a third face into its OUT_DIR. The |
| 277 |
# host answered 429 and the build died at `cargo install shop`, an hour in, |
| 278 |
# behind every expensive layer. A sha256 makes the bytes the same bytes; it says |
| 279 |
# nothing about whether they arrive. "Build it yourself" is Alloy's only install |
| 280 |
# path (wiki `alloy-distribution`), so a fetch that fails is an install that |
| 281 |
# cannot happen, and that makes this a property of the distribution rather than |
| 282 |
# a flaky step. |
| 283 |
# |
| 284 |
# WHAT THE SEED COVERS IS STILL THIS BLOCK'S TWO CUTS, AND NOTHING ELSE. Say it |
| 285 |
# here rather than let a reader infer a whole-build guarantee from the word |
| 286 |
# `sealed`. `cargo install shop` is 140 lines above, and moving the COPY above it |
| 287 |
# would change nothing: shop's `shop-font/build.rs` calls |
| 288 |
# `quasi_type::cut_native(&out, &out.join("bases"), false, ...)`, with the cache |
| 289 |
# directory hardcoded to its own OUT_DIR and `offline` hardcoded false, so there |
| 290 |
# is no path and no flag for this file to aim at a carried copy. Read from shop |
| 291 |
# at rev ${SHOP_REV} on 2026-08-29. |
| 292 |
# |
| 293 |
# WHAT COVERS THE FETCH THE SEED DOES NOT COVER IS THE MIRROR, and that is why |
| 294 |
# the mirror is the fix this stage took. `QUASI_TYPE_MIRROR` is read inside |
| 295 |
# quasi-type, which is the crate on both sides of the hardcoding, so it reaches |
| 296 |
# a fetch this file cannot otherwise touch. The seed still does not cover that |
| 297 |
# fetch: a mirror is a better source and not a carried copy, so `sealed` means |
| 298 |
# exactly what it meant. |
| 299 |
# |
| 300 |
# THE CONTRACT SHOP WOULD HAVE TO IMPLEMENT, if `sealed` is ever to be a claim |
| 301 |
# about the build and not only about the cut: read the cache directory from an |
| 302 |
# environment variable, falling back to `OUT_DIR/bases` when it is unset so |
| 303 |
# nothing about a plain `cargo build` changes, and take `offline` from a second |
| 304 |
# one. Two lines in that build script. When they exist, the COPY moves above |
| 305 |
# `cargo install shop` and this stage exports both variables. It is shop's change |
| 306 |
# to make and it is not made here; the mirror lowered its urgency rather than |
| 307 |
# removing it, since an offline build host still cannot seal that fetch. |
| 308 |
# |
| 309 |
# TWO MORE THINGS STAND AGAINST THE OTHER TWO FETCHES, and neither is the durable |
| 310 |
# fix the mirror is. |
| 311 |
# |
| 312 |
# 1. The retry is upstream, in quasi-type's own fetch: curl `--retry 5 |
| 313 |
# --retry-delay 2 --retry-all-errors`, which covers 429, 408, the 5xx |
| 314 |
# family and the connection failures a shared network produces. It is in |
| 315 |
# the pinned revision above, so it is what this build already gets, and it |
| 316 |
# is what shop's fetch gets too, since that is the same code. |
| 317 |
# 2. The seed below, which is what a build can carry rather than ask for. |
| 318 |
# `/base-cache` is copied from `build/base-cache/` in the context, so the |
| 319 |
# supported way to use it is to put the files there; both wrapper scripts |
| 320 |
# forward `--build-arg` and nothing else, so the `podman build -v |
| 321 |
# <dir>:/base-cache:ro` route is a bare `podman build` away from them, and |
| 322 |
# that produces an image with no build stamp (docs/IMAGE.md). Seeded files |
| 323 |
# are copied into the checkout's own `bases/cache/`, which is the directory |
| 324 |
# quasi-type reads before it fetches anything. Seeding cannot forge a base: |
| 325 |
# every cached file is verified against the pin's sha256 exactly as a |
| 326 |
# downloaded one is, so a wrong or tampered seed fails the cut rather than |
| 327 |
# shipping. |
| 328 |
# |
| 329 |
# `QUASI_BASES=sealed` turns the seed into a guarantee about the cut. It passes |
| 330 |
# `--offline`, so a base the seed does not carry fails here rather than reaching |
| 331 |
# for a host that may not answer. It does not make the build offline: the clone |
| 332 |
# above and `cargo build` below still go out, and so does shop's own fetch. Use |
| 333 |
# it on a build host with a warm cache; `fetch`, the default, is what a stranger |
| 334 |
# cloning the repo gets and still works with an empty seed. |
| 335 |
# |
| 336 |
# THE SEALED GATE READS THE PINS RATHER THAN COUNTING FILES. A seed that is |
| 337 |
# present but wrong is the likely mistake, since the cache names are long and |
| 338 |
# percent-encoded, and a count cannot tell a complete seed from one file. So the |
| 339 |
# names are derived from the checkout's own `bases/pins.toml`, which is the same |
| 340 |
# file the tool reads: base id, base version and the leaf of each pinned url, |
| 341 |
# joined the way `Face::cache_name` joins them, plus the `.zip` an archive-pinned |
| 342 |
# base caches and the `-LICENSE.txt` a `license_url` caches. |
| 343 |
# |
| 344 |
# THE ARCHIVE BRANCH NAMES THE ZIP AND THE LICENCE BOTH. `license_text()` in |
| 345 |
# quasi-type caches `<id>-<ver>-LICENSE.txt` for any base declaring a |
| 346 |
# `license_url`, archive-pinned or not, and reads the licence out of the zip only |
| 347 |
# when there is no such url. The licence entry therefore follows the url rather |
| 348 |
# than the shape of the pin, and the archive branch prints it too. Neither base |
| 349 |
# is archive-pinned today, so the derived list is the same either way; what this |
| 350 |
# buys is that the seed is not one file short on the day one is. |
| 351 |
# Derived rather than listed here because a list would be a second copy of the |
| 352 |
# pin, and it would go stale the first time QUASI_TYPE_REV moved to a base with |
| 353 |
# a new version in its file names. |
| 354 |
# |
| 355 |
# THE COPY ON INFRASTRUCTURE WE OWN is the mirror. The four pinned files live |
| 356 |
# in the MNW server's `static/bases/` named by their sha256, and |
| 357 |
# `QUASI_TYPE_MIRROR` near the top of this stage points quasi-type at them. It |
| 358 |
# is an ENV over the whole stage rather than a flag here because the fetch that |
| 359 |
# took the build down was shop's, not these two, and shop reads the same |
| 360 |
# variable through the same crate. |
| 361 |
# |
| 362 |
# It adds a source and does not remove one: every base still carries its |
| 363 |
# upstream url and falls back to it, so a build with no access to makenot.work |
| 364 |
# is slower on a cold cache and nothing more. |
| 365 |
# |
| 366 |
# THE CUT IS UNCONDITIONAL, and that is a decision rather than an omission. Only |
| 367 |
# the client installs these faces (see the profile block near the end of this |
| 368 |
# file), so cutting them on `server` looks like waste. What the conditional |
| 369 |
# would buy is the two cuts on a server build, and not the third fetch: `cargo |
| 370 |
# install shop` above runs on both profiles, since both carry the terminal's |
| 371 |
# RPM, and shop's build script cuts its own face from the same Atkinson base. |
| 372 |
# What it would cost is the property that makes this stage cheap. Nothing in it |
| 373 |
# reads `$PROFILE`, so both profiles share every layer of it, including the |
| 374 |
# console build below and the RPM packaging after that. |
| 375 |
ARG QUASI_TYPE_REV=52c5bc069fa0b0f3f74bad6957e2d5ca12c0b57d |
| 376 |
ARG QUASI_BASES=fetch |
| 377 |
COPY build/base-cache/ /base-cache/ |
| 378 |
RUN set -eu; \ |
| 379 |
case "$QUASI_BASES" in \ |
| 380 |
fetch|sealed) ;; \ |
| 381 |
*) echo "unknown QUASI_BASES '$QUASI_BASES'; expected 'fetch' or 'sealed'" >&2; exit 1 ;; \ |
| 382 |
esac; \ |
| 383 |
slots="quasi-mono quasi-body"; \ |
| 384 |
git clone --no-checkout https://makenot.work/git/max/quasi-type.git /quasi-type; \ |
| 385 |
git -C /quasi-type checkout --detach "${QUASI_TYPE_REV}"; \ |
| 386 |
mkdir -p /quasi-type/bases/cache; \ |
| 387 |
seeded=0; \ |
| 388 |
for base in /base-cache/*; do \ |
| 389 |
[ -f "$base" ] || continue; \ |
| 390 |
case "${base##*/}" in *.md) continue ;; esac; \ |
| 391 |
cp "$base" /quasi-type/bases/cache/; \ |
| 392 |
seeded=$((seeded + 1)); \ |
| 393 |
done; \ |
| 394 |
wanted=$(awk -v want="$slots" ' \ |
| 395 |
function leaf(s, n, p) { n = split(s, p, "/"); return p[n] } \ |
| 396 |
function val(v) { sub(/^[^=]*= */, "", v); gsub(/"/, "", v); return v } \ |
| 397 |
$1 == "[[base]]" { nb++; sec = "base"; next } \ |
| 398 |
$1 == "[[base.face]]" { sec = "face"; next } \ |
| 399 |
$1 == "[[slot]]" { ns++; sec = "slot"; next } \ |
| 400 |
sec == "base" && $1 == "id" { bid[nb] = val($0); next } \ |
| 401 |
sec == "base" && $1 == "version" { bver[nb] = val($0); next } \ |
| 402 |
sec == "base" && $1 == "url" { archive[nb] = 1; next } \ |
| 403 |
sec == "base" && $1 == "license_url" { license[nb] = 1; next } \ |
| 404 |
sec == "face" && $1 == "url" { faces[nb] = faces[nb] " " leaf(val($0)); next } \ |
| 405 |
sec == "face" && $1 == "path" { faces[nb] = faces[nb] " " leaf(val($0)); next } \ |
| 406 |
sec == "slot" && $1 == "id" { sid[ns] = val($0); next } \ |
| 407 |
sec == "slot" && $1 == "base" { sbase[ns] = val($0); next } \ |
| 408 |
END { \ |
| 409 |
n = split(want, w, " "); \ |
| 410 |
for (i = 1; i <= n; i++) { \ |
| 411 |
b = 0; \ |
| 412 |
for (s = 1; s <= ns; s++) if (sid[s] == w[i]) for (k = 1; k <= nb; k++) if (bid[k] == sbase[s]) b = k; \ |
| 413 |
if (b == 0) { print "unknown-slot:" w[i]; continue } \ |
| 414 |
if (archive[b]) { print bid[b] "-" bver[b] ".zip"; if (license[b]) print bid[b] "-" bver[b] "-LICENSE.txt"; continue } \ |
| 415 |
m = split(faces[b], f, " "); \ |
| 416 |
for (j = 1; j <= m; j++) print bid[b] "-" bver[b] "-" f[j]; \ |
| 417 |
if (license[b]) print bid[b] "-" bver[b] "-LICENSE.txt"; \ |
| 418 |
} \ |
| 419 |
}' /quasi-type/bases/pins.toml); \ |
| 420 |
[ -n "$wanted" ] \ |
| 421 |
|| { echo "derived no base file names from the pins; either bases/pins.toml moved or its shape changed, and the sealed gate cannot check a seed it cannot name" >&2; exit 1; }; \ |
| 422 |
missing=; \ |
| 423 |
for name in $wanted; do \ |
| 424 |
case "$name" in \ |
| 425 |
unknown-slot:*) echo "the pins name no slot '${name#unknown-slot:}'; this stage cuts slots the checkout does not have" >&2; exit 1 ;; \ |
| 426 |
esac; \ |
| 427 |
test -f /quasi-type/bases/cache/"$name" || missing="$missing $name"; \ |
| 428 |
done; \ |
| 429 |
echo "quasi-type bases: seeded $seeded file(s) from /base-cache, mode $QUASI_BASES, missing:${missing:- none}"; \ |
| 430 |
mirror="${QUASI_TYPE_MIRROR:-}"; \ |
| 431 |
if [ -n "$mirror" ] && [ "$QUASI_BASES" = fetch ]; then \ |
| 432 |
have=0; total=0; \ |
| 433 |
for digest in $(sed -n 's/^\(license_\)\{0,1\}sha256 = "\([0-9a-f]\{64\}\)"$/\2/p' /quasi-type/bases/pins.toml); do \ |
| 434 |
total=$((total + 1)); \ |
| 435 |
curl --fail --silent --head --location --max-time 20 "$mirror/$digest" >/dev/null 2>&1 \ |
| 436 |
&& have=$((have + 1)); \ |
| 437 |
done; \ |
| 438 |
echo "quasi-type mirror: $mirror holds $have of $total pinned file(s); the rest fall through to their upstream urls"; \ |
| 439 |
else \ |
| 440 |
echo "quasi-type mirror: not consulted (mirror '$mirror', mode $QUASI_BASES)"; \ |
| 441 |
fi; \ |
| 442 |
if [ "$QUASI_BASES" = sealed ]; then \ |
| 443 |
[ -z "$missing" ] \ |
| 444 |
|| { echo "QUASI_BASES=sealed and the seed is short of:$missing. The cut would fail on the first of them after building the tool. Copy them into build/base-cache/ (its README says where from) or build with QUASI_BASES=fetch" >&2; exit 1; }; \ |
| 445 |
offline=--offline; \ |
| 446 |
else \ |
| 447 |
offline=; \ |
| 448 |
fi; \ |
| 449 |
cargo build --release --locked --manifest-path /quasi-type/Cargo.toml; \ |
| 450 |
for slot in $slots; do \ |
| 451 |
/quasi-type/target/release/quasi-type build "$slot" --out /faces ${offline}; \ |
| 452 |
done; \ |
| 453 |
rm -rf /quasi-type/target |
| 454 |
|
| 455 |
# Both faces, and the licence beside them. The cut asserts its own coverage — |
| 456 |
# every codepoint an Alloy surface emits, checked against the built `cmap` — |
| 457 |
# so what is left here is that the files arrived. |
| 458 |
RUN test -f "/faces/QuasiMono[wght].ttf" && test -f "/faces/QuasiBody[wght].ttf" \ |
| 459 |
|| { echo "the house faces were not cut; every TUI would draw borders from a fallback" >&2; exit 1; }; \ |
| 460 |
test -f /faces/OFL-QuasiMono.txt && test -f /faces/OFL-QuasiBody.txt \ |
| 461 |
|| { echo "the OFL text is missing; the licence has to travel with a modified face" >&2; exit 1; }; \ |
| 462 |
rm -f /faces/*.woff2 |
| 463 |
|
| 464 |
WORKDIR /src |
| 465 |
|
| 466 |
# The dependency graph first, against a stub main. Without this split |
| 467 |
# every console edit re-downloads and rebuilds every crate underneath it, |
| 468 |
# and the console is the part of this image that changes most often. |
| 469 |
# |
| 470 |
# EVERY workspace member is named, or cargo cannot resolve the workspace and |
| 471 |
# the whole split silently degrades to a full rebuild every time. Adding a |
| 472 |
# crate under crates/ means adding it here as well; there is no glob that |
| 473 |
# would do it, because a glob would copy the sources this stage exists to |
| 474 |
# leave behind. |
| 475 |
COPY Cargo.toml Cargo.lock ./ |
| 476 |
COPY crates/alloy/Cargo.toml crates/alloy/Cargo.toml |
| 477 |
COPY crates/skelgen/Cargo.toml crates/skelgen/Cargo.toml |
| 478 |
COPY crates/backdrop/Cargo.toml crates/backdrop/Cargo.toml |
| 479 |
RUN mkdir -p crates/alloy/src crates/skelgen/src crates/backdrop/src \ |
| 480 |
&& echo 'fn main() {}' > crates/alloy/src/main.rs \ |
| 481 |
&& echo 'fn main() {}' > crates/skelgen/src/main.rs \ |
| 482 |
&& echo 'fn main() {}' > crates/backdrop/src/main.rs \ |
| 483 |
&& cargo build --release --locked \ |
| 484 |
&& rm -rf target/release/alloy target/release/deps/alloy-* \ |
| 485 |
target/release/.fingerprint/alloy-* \ |
| 486 |
target/release/alloy-skelgen target/release/deps/alloy_skelgen-* \ |
| 487 |
target/release/deps/skelgen-* target/release/.fingerprint/skelgen-* \ |
| 488 |
target/release/alloy-drift target/release/deps/alloy_drift-* \ |
| 489 |
target/release/.fingerprint/alloy-drift-* |
| 490 |
|
| 491 |
# Then the real source. The removals above are what make cargo rebuild |
| 492 |
# the binary rather than find the stub's artifact already in place. |
| 493 |
COPY crates/ crates/ |
| 494 |
RUN cargo build --release --locked |
| 495 |
|
| 496 |
# Assert the stub is gone rather than trusting the glob that removed it. |
| 497 |
# |
| 498 |
# Those `rm` patterns are the only thing standing between this stage and |
| 499 |
# shipping `fn main() {}` as the console: if cargo ever changes artifact |
| 500 |
# naming, the glob matches nothing, `rm` still exits 0, cargo finds the unit |
| 501 |
# fresh, and the stub is uplifted and copied into the image. Every subcommand |
| 502 |
# would then be a silent no-op, including `alloy install`, which is the ISO's |
| 503 |
# entire purpose, failing by doing nothing on a machine that was about to be |
| 504 |
# repartitioned. |
| 505 |
# |
| 506 |
# clap's `version` gives the real binary a `--version` that prints its name; |
| 507 |
# the stub prints nothing and exits 0. That is the whole difference, so that is |
| 508 |
# what gets checked. The makeover themes glob below is asserted for the same |
| 509 |
# reason: silently copying nothing rebuilds exactly the failure it fixes. |
| 510 |
RUN /src/target/release/alloy --version | grep -q '^alloy ' \ |
| 511 |
|| { echo "built console is the stub; the cache-split cleanup matched nothing" >&2; exit 1; } |
| 512 |
|
| 513 |
# skelgen gets the same assertion for the same reason. Its stub would exit 0 |
| 514 |
# having written nothing, and `--help` is the one thing clap gives the real |
| 515 |
# binary and not `fn main() {}`. |
| 516 |
RUN /src/target/release/alloy-skelgen --help | grep -q -- '--templates' \ |
| 517 |
|| { echo "built skelgen is the stub; the cache-split cleanup matched nothing" >&2; exit 1; } |
| 518 |
|
| 519 |
# alloy-drift gets the same assertion, and it is the one of the three where the |
| 520 |
# stub would be hardest to notice: `fn main() {}` on the background layer draws |
| 521 |
# nothing and exits, shop closes with it, and the desktop falls back to the flat |
| 522 |
# colour -- which is what a machine whose backdrop was never built looks like |
| 523 |
# too. `--help` is again the whole difference. |
| 524 |
RUN /src/target/release/alloy-drift --help | grep -q -- '--pattern' \ |
| 525 |
|| { echo "built alloy-drift is the stub; the cache-split cleanup matched nothing" >&2; exit 1; } |
| 526 |
|
| 527 |
# The one subcommand name that is spelled out in a shell script rather than |
| 528 |
# resolved by the compiler. usr/bin/alloy-session runs `alloy theme apply` and |
| 529 |
# swallows its exit code, because nothing before the exec is allowed to fail a |
| 530 |
# login; the cost of that `|| true` is that renaming the verb makes the wrapper |
| 531 |
# a no-op and leaves every account on the day render forever, with one line in |
| 532 |
# the journal per login and no other symptom. clap answers `--help` before any |
| 533 |
# dispatch, so this asks the binary whether the verb exists without needing a |
| 534 |
# theme, a home directory, or a skeleton. |
| 535 |
RUN /src/target/release/alloy theme apply --help >/dev/null \ |
| 536 |
|| { echo "the binary has no 'theme apply'; usr/bin/alloy-session would silently do nothing" >&2; exit 1; } |
| 537 |
|
| 538 |
# The console's themes, staged at a path the runtime stage can name. |
| 539 |
# |
| 540 |
# theme.rs has no built-in palette on purpose (docs/TOKENS.md: no hex in |
| 541 |
# Rust), so a console with no theme file on any search path does not fall |
| 542 |
# back, it exits. Its last-resort path is makeover's bundled themes, which |
| 543 |
# resolve through CARGO_MANIFEST_DIR into the registry checkout — a build |
| 544 |
# stage that gets discarded. Only the binary was copied out, so every |
| 545 |
# `alloy` subcommand in the image failed with "Theme not found". |
| 546 |
# |
| 547 |
# Only the two Akari defaults are taken. makeover ships around thirty, |
| 548 |
# most of them other people's palettes carrying attribution obligations; |
| 549 |
# Akari is the one Alloy is entitled to call its own (docs/TOKENS.md). |
| 550 |
# Widening this means shipping THIRD-PARTY-NOTICES alongside it. |
| 551 |
# |
| 552 |
# The glob is asserted rather than trusted: silently copying nothing here |
| 553 |
# would rebuild exactly the failure this fixes. |
| 554 |
# |
| 555 |
# **The version comes from Alloy's own lock**, and `makeover-*` used to stand in |
| 556 |
# for it. That worked only while exactly one makeover was ever unpacked into the |
| 557 |
# registry, and it stopped on 2026-08-17: `cargo install shop` resolves shop's |
| 558 |
# graph, shop takes `makeover = "2.5.0"` and its lock has moved to 2.6.0, while |
| 559 |
# Alloy is deliberately held at 2.5.1 (GO makeover `85ad7547`, an unresolved |
| 560 |
# regression in derived emphasis). Two directories, and the glob asserted its way |
| 561 |
# to a failed build rather than staging the wrong themes — which is the assertion |
| 562 |
# working, and the reason it is here. |
| 563 |
# |
| 564 |
# Naming the version is also the more correct claim on its own terms: what the |
| 565 |
# console needs staged is the themes of the makeover *it* was compiled against, |
| 566 |
# not whichever one happens to be on disk. |
| 567 |
RUN set -eux; \ |
| 568 |
ver=$(awk '/^name = "makeover"$/{f=1;next} f&&/^version = /{gsub(/[";]/,"",$3); print $3; exit}' /src/Cargo.lock); \ |
| 569 |
[ -n "$ver" ] || { echo "alloy's own lock names no makeover, so there is no version to stage" >&2; exit 1; }; \ |
| 570 |
set -- /root/.cargo/registry/src/*/makeover-$ver/themes; \ |
| 571 |
[ "$#" -eq 1 ] && [ -d "$1" ] || { echo "expected one makeover-$ver themes dir, got: $*" >&2; exit 1; }; \ |
| 572 |
mkdir -p /staged-themes; \ |
| 573 |
cp -a "$1/akari-dawn.toml" "$1/akari-night.toml" /staged-themes/ |
| 574 |
|
| 575 |
# The desktop skeleton, rendered from the two staged themes. |
| 576 |
# |
| 577 |
# Everything in the image that carries a color — GTK, sway, yazi, helix, rio, |
| 578 |
# zathura, the greeter's console palette, the kernel's own vt.default_* table — |
| 579 |
# comes out of this one step. The templates hold structure and token names; the |
| 580 |
# theme files hold the colors. Nothing here is committed, because it is a pure |
| 581 |
# function of the two (CLAUDE.md: never store regenerables). |
| 582 |
# |
| 583 |
# This replaces about four hundred hex literals that were transcribed by hand |
| 584 |
# across seventeen files, three of which held their own copy of the ANSI slot |
| 585 |
# mapping and no two of which agreed. |
| 586 |
# |
| 587 |
# skelgen fails the build on an unknown token or an empty render, so the |
| 588 |
# assertions the vtrgb and theme-glob steps needed are inside it. What has to |
| 589 |
# stay out here is everything about the shape of the output tree, because a |
| 590 |
# template tree that silently matched nothing, or a night set that silently went |
| 591 |
# missing, copies a plausible-looking overlay and says nothing at all. |
| 592 |
# |
| 593 |
# Two renders per themed template now, the plain one and a `.night` sibling. The |
| 594 |
# sibling is written next to its target, which is not where it belongs: left |
| 595 |
# there, every new user gets a ~/.config/mako/config.night and a |
| 596 |
# config.night beside every other themed file. No program picks those up — |
| 597 |
# helix scans for *.toml, sway reads `config` — so the failure is clutter rather |
| 598 |
# than breakage, which is exactly the kind that ships. Hence the relocation |
| 599 |
# below, and the assertion that it left nothing behind. |
| 600 |
# |
| 601 |
# Every loop is `for x in $(find ...)` rather than `find | while read`, and that |
| 602 |
# is not a style choice: a piped `while` runs its body in a subshell, so an |
| 603 |
# `exit 1` inside it exits the subshell and the RUN succeeds anyway. A build |
| 604 |
# guard that cannot fail the build is worse than no guard. Word splitting is safe |
| 605 |
# here because no path in templates/ contains whitespace, and the counterpart |
| 606 |
# assertions below would catch it if one ever did. |
| 607 |
# |
| 608 |
# Four guards, none of them a hardcoded number, because the old `-ge 17` floor |
| 609 |
# went stale the day the night set landed and a floor that passes by accident |
| 610 |
# reads exactly like a floor that passes on purpose: |
| 611 |
# |
| 612 |
# 1. The file count is derived, not written down: one output per template, plus |
| 613 |
# one more for each template whose first line is a `variants` directive. |
| 614 |
# Equality rather than a floor, so a render that stops fanning out fails |
| 615 |
# here, and adding a template needs no edit. |
| 616 |
# 2. Both vt tables are checked, not only the day one. setvtrgb rejects |
| 617 |
# anything that is not three rows of sixteen values, and it rejects it at |
| 618 |
# boot on the greeter, where nobody is reading logs. |
| 619 |
# 3. Every rendered file under /etc/skel must have a night render, with the two |
| 620 |
# helix palettes named as the deliberate exceptions: those two files are the |
| 621 |
# polarities themselves, picked by filename, so a `.night` sibling of either |
| 622 |
# would be a second copy of one of them under the wrong name. This is the |
| 623 |
# guard that catches a themed template losing its directive, which guard 1 |
| 624 |
# cannot: guard 1 derives its expectation from the same first lines. |
| 625 |
# 4. Every night render must have a plain counterpart, which is what |
| 626 |
# `alloy theme apply`'s pristine test compares against. Together with 3 it |
| 627 |
# is a bijection, so a stray file on either side fails the build. |
| 628 |
COPY templates/ /src/templates/ |
| 629 |
RUN set -eux; \ |
| 630 |
/src/target/release/alloy-skelgen \ |
| 631 |
--templates /src/templates \ |
| 632 |
--out /staged-skel \ |
| 633 |
--theme default=/staged-themes/akari-dawn.toml \ |
| 634 |
--theme night=/staged-themes/akari-night.toml; \ |
| 635 |
templates="$(find /src/templates -type f | wc -l)"; \ |
| 636 |
variants="$(find /src/templates -type f -exec awk 'FNR == 1 && /^[[:space:]]*@\{! *variants *=/ { print FILENAME }' {} + | wc -l)"; \ |
| 637 |
rendered="$(find /staged-skel -type f | wc -l)"; \ |
| 638 |
[ "$rendered" -eq "$((templates + variants))" ] \ |
| 639 |
|| { echo "skelgen wrote $rendered files; $templates templates of which $variants render twice should have produced $((templates + variants))" >&2; exit 1; }; \ |
| 640 |
for table in /staged-skel/usr/share/alloy/vtrgb /staged-skel/usr/share/alloy/vtrgb.night; do \ |
| 641 |
: "commas, and no whitespace: setvtrgb parses each line comma-delimited"; \ |
| 642 |
: "and rejects anything else before it opens a console. This guard split"; \ |
| 643 |
: "on whitespace until 2026-08-03 and so passed a file setvtrgb refused,"; \ |
| 644 |
: "which is why the palette never once applied on any image."; \ |
| 645 |
awk -F, 'NF != 16 { exit 1 } /[[:space:]]/ { exit 1 } END { if (NR != 3) exit 1 }' "$table" \ |
| 646 |
|| { echo "$table is not three rows of sixteen comma-separated values; setvtrgb would reject it" >&2; exit 1; }; \ |
| 647 |
done; \ |
| 648 |
for night in $(find /staged-skel/etc/skel -type f -name '*.night'); do \ |
| 649 |
rel="${night#/staged-skel/etc/skel/}"; \ |
| 650 |
dest="/staged-skel/usr/share/alloy/skel-night/${rel%.night}"; \ |
| 651 |
mkdir -p "$(dirname "$dest")"; \ |
| 652 |
mv "$night" "$dest"; \ |
| 653 |
done; \ |
| 654 |
[ -z "$(find /staged-skel/etc/skel -name '*.night')" ] \ |
| 655 |
|| { echo "a night render was left in /etc/skel; every new user would get it" >&2; exit 1; }; \ |
| 656 |
for day in $(find /staged-skel/etc/skel -type f); do \ |
| 657 |
rel="${day#/staged-skel/etc/skel/}"; \ |
| 658 |
case "$rel" in .config/helix/themes/akari-dawn.toml|.config/helix/themes/akari-night.toml) continue;; esac; \ |
| 659 |
[ -f "/staged-skel/usr/share/alloy/skel-night/$rel" ] \ |
| 660 |
|| { echo "$rel has no night render; a night session would keep the light one forever" >&2; exit 1; }; \ |
| 661 |
done; \ |
| 662 |
for applied in $(find /staged-skel/usr/share/alloy/skel-night -type f); do \ |
| 663 |
rel="${applied#/staged-skel/usr/share/alloy/skel-night/}"; \ |
| 664 |
[ -f "/staged-skel/etc/skel/$rel" ] \ |
| 665 |
|| { echo "night render $rel has no plain counterpart in /etc/skel" >&2; exit 1; }; \ |
| 666 |
done |
| 667 |
|
| 668 |
# The two skeleton sources must not overlap. |
| 669 |
# |
| 670 |
# The runtime stage lays the repo's etc/skel down first and this rendered tree |
| 671 |
# second, so a path present in both silently resolves to the render and leaves a |
| 672 |
# committed file in the repo that nothing in the image ever reads. That is how |
| 673 |
# etc/skel/.config/helix/config.toml would come back: it moved into templates/ |
| 674 |
# to carry `theme = "@{meta.id}"`, and a restored copy would look like the |
| 675 |
# source of truth while the image used the other one. |
| 676 |
# |
| 677 |
# etc/skel is copied in here rather than compared in the runtime stage because |
| 678 |
# this is the only stage that holds both trees, and this stage is discarded. |
| 679 |
COPY etc/skel/ /src/etc-skel/ |
| 680 |
RUN set -eux; \ |
| 681 |
for staged in $(find /src/etc-skel -type f); do \ |
| 682 |
rel="${staged#/src/etc-skel/}"; \ |
| 683 |
[ ! -e "/staged-skel/etc/skel/$rel" ] \ |
| 684 |
|| { echo "etc/skel/$rel is also rendered from templates/; one of the two is dead" >&2; exit 1; }; \ |
| 685 |
done |
| 686 |
|
| 687 |
# ===================================================================== |
| 688 |
# The component repo: our two binaries, packaged and never installed. |
| 689 |
# ===================================================================== |
| 690 |
# The runtime stage does not copy the console and the terminal into /usr/bin. |
| 691 |
# It carries this repo instead, and alloy-layer-components.service lays both |
| 692 |
# down as rpm-ostree layers on the first boot after an install. |
| 693 |
# |
| 694 |
# The reason is the one thing measured in build/layertest that changes the |
| 695 |
# design: a component the base image carries cannot be replaced client-side. |
| 696 |
# An installed base package fails to depsolve against a layer of the same name, |
| 697 |
# `override replace` records a request that never activates, and a loose file |
| 698 |
# in /usr/bin — which is what this image shipped until 2026-08-14 — is worse |
| 699 |
# than either, because layering over an unowned file dies in checkout with |
| 700 |
# "File exists". Any of the three is a machine whose console can never be |
| 701 |
# fixed without rebuilding an ISO and writing a drive. |
| 702 |
# |
| 703 |
# Carrying the RPMs is not carrying the component, and that distinction is the |
| 704 |
# whole design. What blocks a layer is an installed package or a file at the |
| 705 |
# path being layered over; an uninstalled .rpm under /usr/share is neither. So |
| 706 |
# the packages travel with the image, which means they travel on the ISO, which |
| 707 |
# means an offline install still produces a working machine and a machine whose |
| 708 |
# owner has not consented to anything yet never reaches the network for them. |
| 709 |
# |
| 710 |
# Packaged here rather than by build/rpm/build.sh, which is the standalone |
| 711 |
# hotfix path and cannot be called from a build stage. The specs are shared, so |
| 712 |
# the two produce the same package; `build.sh --component shop --binary` is how |
| 713 |
# a hotfix is cut from the very binary an image shipped. |
| 714 |
COPY build/rpm/alloy.spec build/rpm/shop.spec /src/rpm/ |
| 715 |
RUN set -eux; \ |
| 716 |
: "The version comes off each binary, and the console's is cross-checked"; \ |
| 717 |
: "against its crate. An RPM claiming a version the binary inside it does"; \ |
| 718 |
: "not report is invisible afterwards — rpm answers one thing, --version"; \ |
| 719 |
: "another, and --version is what a person quotes in a bug report."; \ |
| 720 |
: ""; \ |
| 721 |
: "head -1 is load-bearing, not caution. Both binaries answer --version"; \ |
| 722 |
: "with a version line followed by copyright and licence lines, so an awk"; \ |
| 723 |
: "over the whole output returns five words rather than one and the"; \ |
| 724 |
: "comparison below fails on a version that was correct all along."; \ |
| 725 |
alloy_version="$(/src/target/release/alloy --version | head -1 | awk '{print $2}')"; \ |
| 726 |
crate_version="$(grep -m1 '^version' /src/crates/alloy/Cargo.toml | cut -d'"' -f2)"; \ |
| 727 |
[ -n "$alloy_version" ] && [ "$alloy_version" = "$crate_version" ] \ |
| 728 |
|| { echo "console reports '$alloy_version', crates/alloy/Cargo.toml says '$crate_version'" >&2; exit 1; }; \ |
| 729 |
: "shop is a separate repo pinned by revision, so there is no manifest in"; \ |
| 730 |
: "this context to check it against. It answers --version without opening"; \ |
| 731 |
: "a window, which is the only reason this can ask."; \ |
| 732 |
shop_version="$(/shop/bin/shop --version | head -1 | awk '{print $2}')"; \ |
| 733 |
[ -n "$shop_version" ] \ |
| 734 |
|| { echo "shop did not report a version; SHOP_REV may predate it" >&2; exit 1; }; \ |
| 735 |
mkdir -p /rpmbuild/SOURCES /staged-rpm; \ |
| 736 |
install -m 0755 /src/target/release/alloy /rpmbuild/SOURCES/alloy; \ |
| 737 |
install -m 0755 /shop/bin/shop /rpmbuild/SOURCES/shop; \ |
| 738 |
rpmbuild --define '_topdir /rpmbuild' \ |
| 739 |
--define "alloy_version $alloy_version" -bb /src/rpm/alloy.spec; \ |
| 740 |
rpmbuild --define '_topdir /rpmbuild' \ |
| 741 |
--define "shop_version $shop_version" -bb /src/rpm/shop.spec; \ |
| 742 |
cp /rpmbuild/RPMS/*/*.rpm /staged-rpm/; \ |
| 743 |
createrepo_c /staged-rpm; \ |
| 744 |
: "Exactly one package each, and the metadata that makes them a repo. A"; \ |
| 745 |
: "repo missing one is an install that reaches the greeter with no console"; \ |
| 746 |
: "or no terminal behind it. Globbed rather than named: the %{dist} tag and"; \ |
| 747 |
: "the arch come from whichever host is building, and neither is this"; \ |
| 748 |
: "assertion's business — one file, carrying the version asked for, is."; \ |
| 749 |
set -- /staged-rpm/alloy-*.rpm; \ |
| 750 |
[ "$#" -eq 1 ] && [ -f "$1" ] \ |
| 751 |
|| { echo "expected one console RPM, got: $*" >&2; exit 1; }; \ |
| 752 |
case "$1" in */alloy-"$alloy_version"-*) ;; \ |
| 753 |
*) echo "console RPM $1 is not version $alloy_version" >&2; exit 1;; esac; \ |
| 754 |
set -- /staged-rpm/shop-*.rpm; \ |
| 755 |
[ "$#" -eq 1 ] && [ -f "$1" ] \ |
| 756 |
|| { echo "expected one terminal RPM, got: $*" >&2; exit 1; }; \ |
| 757 |
case "$1" in */shop-"$shop_version"-*) ;; \ |
| 758 |
*) echo "terminal RPM $1 is not version $shop_version" >&2; exit 1;; esac; \ |
| 759 |
[ -f /staged-rpm/repodata/repomd.xml ] \ |
| 760 |
|| { echo "createrepo_c wrote no metadata; the carried repo would resolve nothing" >&2; exit 1; } |
| 761 |
|
| 762 |
# ===================================================================== |
| 763 |
# Runtime image — the bootable container itself. |
| 764 |
# ===================================================================== |
| 765 |
# Digest-pinned for the same reason as the build stage above, and it matters |
| 766 |
# more here: this one is the operating system. See that comment for why the |
| 767 |
# tag is kept beside the digest and why it is the multi-arch index. |
| 768 |
FROM registry.fedoraproject.org/fedora-bootc:43@sha256:c1133226662ce7f79c2398bf6f86047ff37fd82e843dee4d5f9cb8f3dbb58c8d |
| 769 |
|
| 770 |
# ===================================================================== |
| 771 |
# The profile: client or server. |
| 772 |
# ===================================================================== |
| 773 |
# `client` is Alloy as docs/STACK.md describes it — compositor, greeter, |
| 774 |
# session, browser, the whole desktop. `server` is the same machine with |
| 775 |
# every one of those removed: the console, the shell and CLI stack, the |
| 776 |
# hardware-health group, sshd, avahi and the themed bare console, and |
| 777 |
# nothing that needs a screen. |
| 778 |
# |
| 779 |
# Ruled by Max 2026-08-01: one Containerfile with a build ARG, over the |
| 780 |
# two-leaf-file shape and over having no server variant at all. Brief and |
| 781 |
# the alternatives are in wiki `alloy-server-variant`; GO alloy 1372b159 |
| 782 |
# item 2. |
| 783 |
# |
| 784 |
# WHY THIS DOES NOT COST THE ASSERTIONS, which was the standing objection |
| 785 |
# to doing it this way. This file's correctness mechanism is that it |
| 786 |
# asserts, at build time, things a config file merely assumes: that the |
| 787 |
# session wrapper parses, that the polkit grant names actions that exist, |
| 788 |
# that satty and jq are really there for alloy-shot. Each of those checks |
| 789 |
# was written because its absence had already caused a silent failure. The |
| 790 |
# objection was that a build ARG turns them into `if` blocks, and a |
| 791 |
# conditional assertion is one that can be skipped without a word. |
| 792 |
# |
| 793 |
# So no conditional here skips a check. **Every `$PROFILE` conditional in |
| 794 |
# this file asserts on BOTH branches**: where `client` proves a thing is |
| 795 |
# present and correct, `server` proves it is absent. There is no path |
| 796 |
# through any of them that verifies nothing, which means a branch taken by |
| 797 |
# mistake fails the build instead of quietly doing less. Two things hold |
| 798 |
# that up: |
| 799 |
# |
| 800 |
# 1. The validity assertion immediately below. It is unconditional, and |
| 801 |
# it runs before anything reads $PROFILE, so a typo can never fall |
| 802 |
# through to a branch and build something nobody asked for. |
| 803 |
# 2. tests/profile_split.rs, which reads this file and fails if any |
| 804 |
# `$PROFILE` conditional lacks an `else`. |
| 805 |
# |
| 806 |
# That is a stronger guarantee than the file had before the split, because |
| 807 |
# the `server` branches assert absence, which nothing used to check at all. |
| 808 |
ARG PROFILE=client |
| 809 |
|
| 810 |
# ===================================================================== |
| 811 |
# The other two builder choices: the browser, and language toolchains. |
| 812 |
# ===================================================================== |
| 813 |
# Both come from `alloy image` (crates/alloy/src/image.rs), which is the |
| 814 |
# builder TUI wiki `alloy-distribution` calls Alloy's whole distribution |
| 815 |
# mechanism. It exposes only the choices Alloy deliberately declines to |
| 816 |
# make, which is why these two are ARGs and the theme, terminal, editor |
| 817 |
# and shell are not. |
| 818 |
# |
| 819 |
# BROWSER is not the non-endorsement it was until 2026-08-18. Alloy |
| 820 |
# picks Firefox and defends it (wiki `alloy-byo-principle`), so this ARG |
| 821 |
# is no longer "the choice Alloy declines to make" — it survives because |
| 822 |
# `none` is a real answer, not an error: someone installing their own |
| 823 |
# from Flathub should not pay for one they will remove. |
| 824 |
# |
| 825 |
# Alloy still makes no claim that Gecko is the better engine. The claim |
| 826 |
# is about the candidate. Firefox is the only browser reachable on an |
| 827 |
# image-based system whose defaults Alloy can stand behind, and standing |
| 828 |
# behind them costs one file: /etc/firefox/pref/alloy.js. |
| 829 |
ARG BROWSER=firefox |
| 830 |
|
| 831 |
# LANGS is asked for by name in the rust comment further down: 610 MiB |
| 832 |
# across 16 packages, "per-image language selection at mint time is the |
| 833 |
# way this stops being one number for everyone". This is that. Comma |
| 834 |
# separated, and validated against a curated set below rather than |
| 835 |
# treated as a package list — the builder is not a package manager, and |
| 836 |
# the gate lives here so it holds even when the TUI is bypassed. |
| 837 |
# |
| 838 |
# THE DEFAULT IS WHAT THE IMAGE ITSELF REQUIRES, ruled by Max 2026-08-17. |
| 839 |
# EMPTY, ruled by Max 2026-08-27: nothing in the image requires a |
| 840 |
# toolchain, so a stock image carries none. A toolchain that is merely |
| 841 |
# useful is picked at mint time. |
| 842 |
# |
| 843 |
# The 2026-08-17 ruling is unchanged; what changed is that it was never |
| 844 |
# applied to its last survivor. `rust` was kept on the claim that sandod |
| 845 |
# shells out to `cargo build --release` and would break without cargo, |
| 846 |
# which is an argument about the machine being a BUILD HOST rather than |
| 847 |
# about the image requiring rust to function. Measured 2026-08-27: the |
| 848 |
# `cargo install --git ...shop.git` at the top of this file runs in the |
| 849 |
# `rust-build` builder stage, and the shipped image starts from |
| 850 |
# `fedora-bootc` 600 lines later and compiles nothing at runtime. So the |
| 851 |
# toolchain that builds shop was never the toolchain that ships. |
| 852 |
# |
| 853 |
# The go/python/zig sentence below is therefore the whole rule now, |
| 854 |
# applied to every language including rust, rather than a note about |
| 855 |
# three of them. |
| 856 |
# |
| 857 |
# GO WAS IN THIS DEFAULT AND CAME BACK OUT THE SAME DAY. Recording both |
| 858 |
# rulings, because the second reverses the first and a bare `rust` reads |
| 859 |
# like the question was never asked. |
| 860 |
# |
| 861 |
# The first rule was "the default is what compiles the shipped stack": |
| 862 |
# someone rebuilding a program Alloy ships should not have to install a |
| 863 |
# toolchain first. It was measured rather than assumed, by reading each |
| 864 |
# shipped binary for a Go build id or a rustc path: |
| 865 |
# |
| 866 |
# Rust hx yazi nu starship zoxide btm dua rg fd bat eza satty, plus |
| 867 |
# alloy and shop themselves. 14. |
| 868 |
# Go direnv fzf gopass syncthing restic tailscale cliphist. 7. |
| 869 |
# C sway mako swaylock, and the hardware-health group. |
| 870 |
# |
| 871 |
# What killed it is that the rule was never applied evenly. It already |
| 872 |
# stopped short of the C tier — sway and mako want meson, ninja, |
| 873 |
# wayland-protocols and a spread of -devel packages that no language |
| 874 |
# toggle models — so "you can rebuild what we ship" was true for two |
| 875 |
# thirds of the stack and quietly false for the rest. A rule with a hole |
| 876 |
# that size is a preference. Then the exclusive-closure pass priced it: |
| 877 |
# Go's marginal cost is 356 MB, six percent of the image, against seven |
| 878 |
# programs nobody has rebuilt. Nothing in ~/Code is Go either — a full |
| 879 |
# find turns up four tree-sitter fixtures in an archived repo and no |
| 880 |
# go.mod — so the toolchain was not earning its place from the build-host |
| 881 |
# side either. |
| 882 |
# |
| 883 |
# THE COST, STATED SO IT IS NOT DISCOVERED: rebuilding syncthing, restic, |
| 884 |
# tailscale, gopass, direnv, fzf or cliphist from source on a stock image |
| 885 |
# now needs `LANGS=rust,go` at mint time, or a toolchain in $HOME. That is |
| 886 |
# the trade, and it is reversible at zero cost, which most things here are |
| 887 |
# not. |
| 888 |
# |
| 889 |
# PARTLY REVISITED 2026-09-07. The hole described above is why the rule was |
| 890 |
# dropped, and it is still there for the C tier. The Rust tier is not: the |
| 891 |
# language block below now carries wayland-devel, libxkbcommon-devel and |
| 892 |
# fontconfig-devel on the rust arm, so a LANGS=rust image can rebuild alloy |
| 893 |
# and shop: the console, from this repo's workspace, and the terminal, from |
| 894 |
# its own. Those are the two the runtime stage ships as packages of ours |
| 895 |
# rather than as somebody's RPM, and the two a hotfix can reach. |
| 896 |
# |
| 897 |
# They are not everything this project compiles. `cargo build` over the |
| 898 |
# workspace also produces alloy-drift, the backdrop, which ships as a plain |
| 899 |
# binary in /usr/bin because nothing hotfixes it, and alloy-skelgen, which |
| 900 |
# renders templates/ in the builder stage and never ships at all. Both come |
| 901 |
# back for free on a rust image, since rebuilding the console builds the |
| 902 |
# workspace that holds them. |
| 903 |
# |
| 904 |
# The reasoning is beside that install; the short version is that an uneven |
| 905 |
# rule beats no rule when the even part is our own software. |
| 906 |
# |
| 907 |
# python and zig are off for the same reason, which is the rule: nothing |
| 908 |
# in the image requires them. |
| 909 |
# |
| 910 |
# THE BUILD-HOST COST, and it is the one that bites this fleet. A build |
| 911 |
# host is now a mint-time choice rather than a property of any profile. |
| 912 |
# fw13's cutover turns on the machine still being able to compile, and an |
| 913 |
# Alloy astra is a build host too; both need `LANGS=rust` passed when |
| 914 |
# their images are minted, which wiki `alloy-fw13-migration` and |
| 915 |
# `alloy-astra-migration` now say. Sando is the same fact from the other |
| 916 |
# end: sandod refuses to compile anywhere but its configured build host, |
| 917 |
# so that host's image is one that asked for rust. |
| 918 |
# |
| 919 |
# js is the newest arm and is off by the same rule. Nothing in the image |
| 920 |
# is JavaScript. What asks for it is the build-host role: Sando's |
| 921 |
# code_smoke gate runs `npm run build` over MNW's two frontends before it |
| 922 |
# creates a database or boots anything, and MNW's own build.rs downgrades |
| 923 |
# a missing npm to a cargo warning, so without node a fresh worktree |
| 924 |
# produces no bundle at all rather than a stale one. The gate is what |
| 925 |
# catches it, fatally and on purpose. So the arm exists and the default |
| 926 |
# does not carry it: a machine that builds MNW asks for `LANGS=rust,js`, |
| 927 |
# and every other mint pays nothing. |
| 928 |
# |
| 929 |
# Weight, MEASURED 2026-08-21 by minting the arm rather than by asking |
| 930 |
# dnf about it: three packages, 22 MiB download, 87 MiB installed, and a |
| 931 |
# 93.2 MiB delta between a LANGS=rust image and a LANGS=rust,js one |
| 932 |
# (4,983,258,990 to 5,080,964,131 bytes, podman inspect .Size). nodejs |
| 933 |
# is 162 KiB of that and nodejs-libs 78.2 MiB. |
| 934 |
# |
| 935 |
# The estimate this arm was filed with said five packages and 216 MiB, |
| 936 |
# from `dnf install --assumeno`. That number included the weak deps, and |
| 937 |
# install_weak_deps=False is what takes it to three packages -- so the |
| 938 |
# estimate was measuring a transaction this arm does not run. Node costs |
| 939 |
# about 40% of what the task predicted. nodejs-full-i18n, 31.6 MiB of the |
| 940 |
# estimate, is confirmed absent from the built image. |
| 941 |
ARG LANGS= |
| 942 |
|
| 943 |
# DB is the database the image carries, and it exists because the version |
| 944 |
# matters more than the presence. `postgres16` installs postgresql16 and |
| 945 |
# postgresql16-server out of `updates`, whatever 16.x they are on the day: |
| 946 |
# three packages, 9 MiB of download and 38 MiB installed, measured with |
| 947 |
# `dnf install --assumeno` against 16.14-1.fc43 on 2026-08-21. The install arm |
| 948 |
# greps for the major and not the patch, because the patch moves under us and |
| 949 |
# already has: 16.15-1.fc43 by 2026-08-25. |
| 950 |
# |
| 951 |
# Fedora's own `postgresql-server` is 18.4 and is deliberately not what this |
| 952 |
# installs. Sando's scratch cluster exists to test MNW against what production |
| 953 |
# runs, and prod `alpha-west-1` is PostgreSQL 16; a scratch cluster two |
| 954 |
# majors ahead tests a database nobody deploys. |
| 955 |
# |
| 956 |
# The default is `none` and the image carries neither binary today |
| 957 |
# (`command -v psql postgres` finds nothing, `rpm -q postgresql` says not |
| 958 |
# installed), so a default mint is unchanged by this dial existing. |
| 959 |
# |
| 960 |
# No `systemctl enable`, no initdb. The cluster is machine state rather than |
| 961 |
# image state and belongs with the machine that runs it. This arm puts the |
| 962 |
# binaries on PATH and stops. |
| 963 |
# |
| 964 |
# A dial rather than a gate on `PROFILE=server` (Max, 2026-08-25): whether the |
| 965 |
# server profile is a build-host surface is a separate open question, and |
| 966 |
# hanging the database off the profile would have answered it by accident. If |
| 967 |
# it is later ruled that way, `DB` can default to `postgres16` under that |
| 968 |
# profile without changing anything here. |
| 969 |
ARG DB=none |
| 970 |
|
| 971 |
# GUI is the system side of building a desktop app, and it is a dial for the |
| 972 |
# same reason DB is: a machine that ships one and a machine that builds one need |
| 973 |
# different things, and the difference should be a file rather than a memory. |
| 974 |
# |
| 975 |
# `tauri` installs the WebKitGTK development set the four Tauri apps compile |
| 976 |
# against. Alloy ships no webkit at all today -- `rpm -qa | grep -i webkit` is |
| 977 |
# empty on a stock mint -- so a build host without this dial fails at link time |
| 978 |
# on the first app it is asked to build, having looked healthy until then. |
| 979 |
# |
| 980 |
# THE GLIBC QUESTION THIS USED TO BE TANGLED WITH IS SETTLED. Until 2026-09-04 |
| 981 |
# the plan was to build every AppImage inside a Debian-family distrobox, because |
| 982 |
# Fedora 43's glibc is 2.42 against Ubuntu 24.04's 2.39 and cargo-tauri copies |
| 983 |
# the WebKit libraries into the bundle while linuxdeploy excludes libc, so the |
| 984 |
# copies meet the user's glibc rather than the builder's. Max ruled that a 2.42 |
| 985 |
# floor is acceptable and backwards compatibility is not a goal, which is what |
| 986 |
# makes a native build the answer and this dial the whole of it. |
| 987 |
# |
| 988 |
# Not gated on PROFILE, on the same reasoning as the language block: a build |
| 989 |
# host is a role rather than a profile, and astra is a server that builds. |
| 990 |
ARG GUI=none |
| 991 |
|
| 992 |
# TRIM is the one builder choice about the *base* rather than about Alloy. |
| 993 |
# fedora-bootc is a general-purpose server base, and three of the things it |
| 994 |
# carries are unreachable from any Alloy install however the machine is used: |
| 995 |
# python3-botocore (an AWS SDK, with boto3 and s3transfer behind it), |
| 996 |
# qemu-user-static for eighteen foreign architectures, and toolbox, whose job |
| 997 |
# distrobox already does here. 22 packages, and nothing else in the image |
| 998 |
# requires any of them. |
| 999 |
# |
| 1000 |
# WHAT IT IS WORTH, MEASURED RATHER THAN ASSUMED (2026-08-15, both profiles |
| 1001 |
# built the same hour so the package versions match). dnf reports 297 MiB |
| 1002 |
# freed, and `/usr` does shrink by 304 MiB: 170 MiB out of /usr/bin, 123 MiB |
| 1003 |
# out of /usr/lib. **The image on disk shrinks by 3.8 MiB.** |
| 1004 |
# |
| 1005 |
# The difference is how the base is composed. Every file in the base's /usr is |
| 1006 |
# a hardlink into /sysroot/ostree/repo/objects — `stat` reports nlink 2 and the |
| 1007 |
# object is findable with `find -samefile` — so removing the /usr path drops a |
| 1008 |
# link and frees no blocks, because the repo still holds the other one. dnf is |
| 1009 |
# accounting for the rpm database, which is not the same question as what the |
| 1010 |
# medium carries. Nothing here can prune the repo either: those objects belong |
| 1011 |
# to the base commit, which is still referenced. |
| 1012 |
# |
| 1013 |
# So it is not the build-speed lever it looks like. What it should still buy is |
| 1014 |
# the *installed* system, whose deployment is built from /usr rather than from |
| 1015 |
# the image's own repo, and that has not been measured yet — believe it when an |
| 1016 |
# install has been weighed both ways. Kept because the packages are genuinely |
| 1017 |
# unreachable and because whoever measures that wants the switch to already |
| 1018 |
# exist, not because the medium got smaller. |
| 1019 |
# |
| 1020 |
# WHAT THIS NEVER TOUCHES IS FIRMWARE, and that is a decision rather than an |
| 1021 |
# oversight. `nvidia-gpu-firmware` alone is 101 MiB and is the largest single |
| 1022 |
# thing a naive trim would take; astra needs it, and a medium built here has |
| 1023 |
# to boot hardware nobody asked about at build time. The assertion below holds |
| 1024 |
# firmware present on both branches so a future edit to this list cannot |
| 1025 |
# quietly reach it. |
| 1026 |
# |
| 1027 |
# The accepted cost of `unused` is binfmt emulation: no `podman run` of a |
| 1028 |
# foreign-architecture container. Every rule in ~/Code/CLAUDE.md points that |
| 1029 |
# way already (builds are native per architecture, nothing is cross-compiled), |
| 1030 |
# so this removes a capability the house style forbids using. `keep` is there |
| 1031 |
# for whoever disagrees on their own machine. |
| 1032 |
ARG TRIM=unused |
| 1033 |
|
| 1034 |
# Unconditional, and first. Everything downstream trusts that these are |
| 1035 |
# words from known sets, so this is the assertion the other assertions |
| 1036 |
# stand on. `PROFILE=cleint` has to die here rather than silently build a |
| 1037 |
# server image because no `then` branch matched, and `LANGS=rust,cobol` |
| 1038 |
# has to die here rather than at a dnf error 400 lines later. |
| 1039 |
RUN set -eu; \ |
| 1040 |
case "$PROFILE" in \ |
| 1041 |
client|server) ;; \ |
| 1042 |
*) echo "unknown PROFILE '$PROFILE'; expected 'client' or 'server'" >&2; exit 1 ;; \ |
| 1043 |
esac; \ |
| 1044 |
case "$BROWSER" in \ |
| 1045 |
firefox|none) ;; \ |
| 1046 |
helium) echo "BROWSER=helium was removed 2026-08-18: Alloy does not ship a third party's patchset over Chromium's defaults. Use 'firefox' or 'none'." >&2; exit 1 ;; \ |
| 1047 |
*) echo "unknown BROWSER '$BROWSER'; expected 'firefox' or 'none'" >&2; exit 1 ;; \ |
| 1048 |
esac; \ |
| 1049 |
for lang in $(echo "$LANGS" | tr ',' ' '); do \ |
| 1050 |
case "$lang" in \ |
| 1051 |
rust|c|go|python|zig|js) ;; \ |
| 1052 |
*) echo "unknown language '$lang'; the builder offers rust, c, go, python, zig and js" >&2; exit 1 ;; \ |
| 1053 |
esac; \ |
| 1054 |
done; \ |
| 1055 |
case "$DB" in \ |
| 1056 |
none|postgres16) ;; \ |
| 1057 |
*) echo "unknown DB '$DB'; expected 'none' or 'postgres16'" >&2; exit 1 ;; \ |
| 1058 |
esac; \ |
| 1059 |
case "$GUI" in \ |
| 1060 |
none|tauri) ;; \ |
| 1061 |
*) echo "unknown GUI '$GUI'; expected 'none' or 'tauri'" >&2; exit 1 ;; \ |
| 1062 |
esac; \ |
| 1063 |
case "$TRIM" in \ |
| 1064 |
unused|keep) ;; \ |
| 1065 |
*) echo "unknown TRIM '$TRIM'; expected 'unused' or 'keep'" >&2; exit 1 ;; \ |
| 1066 |
esac; \ |
| 1067 |
case "$PROFILE:$BROWSER" in \ |
| 1068 |
client:*|server:none) ;; \ |
| 1069 |
server:*) echo "PROFILE=server ships no graphical session and cannot carry BROWSER=$BROWSER" >&2; exit 1 ;; \ |
| 1070 |
esac; \ |
| 1071 |
echo "building profile=$PROFILE browser=$BROWSER langs=${LANGS:-none} trim=$TRIM db=$DB gui=$GUI" |
| 1072 |
|
| 1073 |
# ===================================================================== |
| 1074 |
# Install-time filtering: translations and documentation |
| 1075 |
# ===================================================================== |
| 1076 |
# 279 MB of a naive build is files rpm has marked with a language and 101 MB |
| 1077 |
# is documentation. Two rpm settings drop most of it, and they only bind |
| 1078 |
# packages installed *after* they are set, which is why they are here — above |
| 1079 |
# terra-release, above every `dnf install` in this stage — rather than |
| 1080 |
# somewhere more convenient. Set late they silently do almost nothing and the |
| 1081 |
# build still passes, which is the failure mode the assertion after the last |
| 1082 |
# install exists to catch. |
| 1083 |
# |
| 1084 |
# /etc/rpm/macros.image-language-conf %_install_langs en:en_US |
| 1085 |
# /etc/dnf/dnf.conf tsflags=nodocs |
| 1086 |
# |
| 1087 |
# MEASURED, not argued (2026-08-17): gtk3, gtk4, helix and nushell built both |
| 1088 |
# ways. `/usr/share/locale` grew 112 MB without these and 0.5 MB with them. |
| 1089 |
# Alloy's own share of the cut is about 170 MB of translations plus about |
| 1090 |
# 92 MB of documentation. The ~160 MB the base carries in before this line |
| 1091 |
# runs is out of reach and stays. This is what actually makes Alpine images |
| 1092 |
# small — it is not musl, and it works on glibc. Full lever list and the |
| 1093 |
# figures behind it: wiki `alloy-image-size-levers`. |
| 1094 |
# |
| 1095 |
# Install-time filtering rather than a prune, and the difference is worth |
| 1096 |
# stating: the files are never installed and the rpm database knows it, so |
| 1097 |
# `rpm -V` still agrees with the image. Compare the cursor prune, where the |
| 1098 |
# database declares 188 MB against 27 MB on disk by design. Prefer this shape |
| 1099 |
# wherever both would work. |
| 1100 |
# |
| 1101 |
# THE MAN PAGE LOSS IS DECIDED, NOT OVERLOOKED. Fedora marks man pages %doc |
| 1102 |
# and rpm offers no separate switch, so `nodocs` takes them with it. A default |
| 1103 |
# Alloy install has no `man` for anything installed after this line; the |
| 1104 |
# console carries its own help and docs/manual exists, so the loss is for |
| 1105 |
# third-party tools rather than for Alloy's own surface. `TRIM=keep` restores |
| 1106 |
# them. Ruled by Max 2026-08-17 over the recommendation, which was the |
| 1107 |
# language macro alone. |
| 1108 |
# |
| 1109 |
# LICENCES ARE NOT AT RISK, checked both ways: rpm treats %license separately |
| 1110 |
# from %doc, `/usr/share/licenses` stayed 12 MB and gtk4's licence stayed |
| 1111 |
# readable. The assertion after the last install proves it anyway, because the |
| 1112 |
# credits manifest is hand-curated against what the image ships and a future |
| 1113 |
# rpm change here would be a licensing problem rather than a size one. |
| 1114 |
# |
| 1115 |
# It rides TRIM rather than taking an axis of its own. TRIM already means |
| 1116 |
# "remove what this machine cannot use" and defaults to `unused`, so this is |
| 1117 |
# the same argument one layer down and the builder matrix stays |
| 1118 |
# profile x browser x langs x trim. Rejected: a LOCALES arg, which is the |
| 1119 |
# fourth axis wiki `alloy-distribution` asks nobody to add, and doing it |
| 1120 |
# unconditionally, which leaves no way back for a build-it-yourself consumer |
| 1121 |
# who wants documentation. |
| 1122 |
# |
| 1123 |
# The baseline written here is what that assertion compares against: the |
| 1124 |
# base's own `/usr/share/locale`, read at build time rather than hardcoded, |
| 1125 |
# since it moves when the FROM line does. The assertion deletes it. |
| 1126 |
RUN set -eu; \ |
| 1127 |
mkdir -p /usr/lib/alloy /etc/rpm; \ |
| 1128 |
du -sb /usr/share/locale | cut -f1 > /usr/lib/alloy/.locale-baseline; \ |
| 1129 |
[ -s /usr/lib/alloy/.locale-baseline ] \ |
| 1130 |
|| { echo "could not read the base's /usr/share/locale size; the assertion below would have nothing to compare against" >&2; exit 1; }; \ |
| 1131 |
grep -q '^\[main\]' /etc/dnf/dnf.conf \ |
| 1132 |
|| { echo "/etc/dnf/dnf.conf has no [main] section; tsflags would land outside every section and do nothing" >&2; exit 1; }; \ |
| 1133 |
if [ "$TRIM" = unused ]; then \ |
| 1134 |
echo '%_install_langs en:en_US' > /etc/rpm/macros.image-language-conf; \ |
| 1135 |
sed -i '/^\[main\]/a tsflags=nodocs' /etc/dnf/dnf.conf; \ |
| 1136 |
grep -q '^tsflags=nodocs$' /etc/dnf/dnf.conf \ |
| 1137 |
|| { echo "tsflags=nodocs did not land in /etc/dnf/dnf.conf" >&2; exit 1; }; \ |
| 1138 |
echo "langs: en:en_US only, and no documentation or man pages (rides TRIM=unused)"; \ |
| 1139 |
else \ |
| 1140 |
rm -f /etc/rpm/macros.image-language-conf; \ |
| 1141 |
echo "langs: every language and all documentation (TRIM=keep)"; \ |
| 1142 |
fi |
| 1143 |
|
| 1144 |
# ===================================================================== |
| 1145 |
# Third-party repos |
| 1146 |
# ===================================================================== |
| 1147 |
# Tailscale is not in Fedora main; drop their .repo file directly. |
| 1148 |
RUN curl -fsSL -o /etc/yum.repos.d/tailscale.repo \ |
| 1149 |
https://pkgs.tailscale.com/stable/fedora/tailscale.repo |
| 1150 |
|
| 1151 |
# Terra — Fedora repo for parts of the Wayland ecosystem (swww, |
| 1152 |
# starship, satty, and some others). Does not carry Rust binaries like |
| 1153 |
# nushell or yazi; COPRs below handle those. |
| 1154 |
# https://terra.fyralabs.com/ |
| 1155 |
# |
| 1156 |
# `dnf clean all` matters more here than the install does. terra-release is |
| 1157 |
# 674 BYTES; the layer measured 238 MB, and every byte of the difference was |
| 1158 |
# repo metadata in /var/cache/libdnf5 (fedora 64 MB, updates 37 MB, |
| 1159 |
# updates-archive 43 MB, terra 3.7 MB, and their solv caches). A later layer's |
| 1160 |
# `dnf clean all` deletes it, which is why the built image shows 28 KB of |
| 1161 |
# cache and the size stays: a delete in a later layer is a whiteout, not a |
| 1162 |
# refund. Cleaning inside the layer that filled it is the only thing that |
| 1163 |
# reclaims the bytes. |
| 1164 |
# |
| 1165 |
# It does not merely move the cost to the next layer. Measured 2026-08-17 on |
| 1166 |
# fedora-bootc, this three-layer chain against itself: |
| 1167 |
# as it was: 188 MB + 41.2 MB + 42.5 MB = 272 MB |
| 1168 |
# as it is: 34.5 MB + 37 MB + 42.8 MB = 114 MB |
| 1169 |
# The next dnf line re-downloads the metadata and deletes it in the same |
| 1170 |
# layer, so it pays nothing to keep. What is left in each number is the |
| 1171 |
# ~34 MB rpmdb rewrite, which is the floor every dnf layer pays and the |
| 1172 |
# largest unattributed cost in the image. docs/IMAGE.md#size has it. |
| 1173 |
# |
| 1174 |
# One new line in the build log comes with this and is not a fault. The next |
| 1175 |
# dnf run now meets a cold cache, so it prints |
| 1176 |
# >>> repomd.xml GPG signature verification error: Signing key not found |
| 1177 |
# for Terra and Tailscale before importing the key from the RPM-GPG-KEY-* |
| 1178 |
# files terra-release itself installed, and then proceeds. It was invisible |
| 1179 |
# before only because the cache the clean now removes was still warm. |
| 1180 |
RUN dnf install -y --nogpgcheck \ |
| 1181 |
--repofrompath='terra,https://repos.fyralabs.com/terra$releasever' \ |
| 1182 |
terra-release \ |
| 1183 |
&& dnf clean all |
| 1184 |
|
| 1185 |
# dnf5's copr plugin isn't in the base bootc image; pull it so we |
| 1186 |
# can `dnf copr enable` for the Rust-Wayland stragglers. |
| 1187 |
RUN dnf install -y 'dnf5-command(copr)' && dnf clean all |
| 1188 |
|
| 1189 |
# ublue-os/staging is the community-maintained COPR that packages |
| 1190 |
# much of the Wayland / Rust ecosystem for atomic Fedora derivatives. |
| 1191 |
# We're not using their base image, but their COPR is a legitimate |
| 1192 |
# adoption (same relationship the rest of Alloy has to Fedora repos). |
| 1193 |
# |
| 1194 |
# Verified 2026-07-19: it does NOT carry wl-screenrec. That was the |
| 1195 |
# reason it needed its own cargo stage and got deferred; a cargo stage |
| 1196 |
# now exists for the console, so the toolchain half of that cost is |
| 1197 |
# already paid and only clang plus the ffmpeg headers remain. See the |
| 1198 |
# deferral note in docs/STACK.md#screen-recorder — the pick still |
| 1199 |
# stands, the arithmetic behind refusing it has changed. Check here |
| 1200 |
# before assuming a Rust Wayland tool is packaged. |
| 1201 |
RUN dnf copr enable -y ublue-os/staging |
| 1202 |
|
| 1203 |
# nushell binary (Terra has crates only) |
| 1204 |
RUN dnf copr enable -y atim/nushell |
| 1205 |
|
| 1206 |
# yazi binary |
| 1207 |
RUN dnf copr enable -y varlad/yazi |
| 1208 |
|
| 1209 |
# satty (screenshot annotator) needs no COPR. It resolves from Terra, at |
| 1210 |
# 0.21.1-1.fc43 — checked 2026-07-30 by repoquery against the full repo |
| 1211 |
# set this file configures, which is the only way to answer it, since the |
| 1212 |
# question is which of four enabled repos wins. |
| 1213 |
# |
| 1214 |
# This block used to say satty "is expected in ublue-os/staging", and it |
| 1215 |
# was not there: repoquery with terra absent returns nothing for satty at |
| 1216 |
# all. The install line worked the whole time because Terra was answering |
| 1217 |
# a question nobody had asked it. Recording the wrong repo costs nothing |
| 1218 |
# until someone drops the one that was actually carrying the package. |
| 1219 |
|
| 1220 |
# ===================================================================== |
| 1221 |
# fontconfig, for the faces staged much further down. |
| 1222 |
# |
| 1223 |
# The two house faces used to be two large downloads here, and this block |
| 1224 |
# sat ahead of the package list because of it: they were the biggest |
| 1225 |
# fetches in the build and the package list is the line that changes most |
| 1226 |
# often. Neither half of that is true any more. The faces are cut in the |
| 1227 |
# rust stage (wiki `typography-standard`), so there is nothing to |
| 1228 |
# download, and copying them in *here* would put a layer that changes |
| 1229 |
# whenever any Rust source does above the 7 GB of package installs below. |
| 1230 |
# So the copy moved to the other `--from=rust-build` staging near the end |
| 1231 |
# of the file, and what is left here is the one package `fc-cache` needs. |
| 1232 |
# |
| 1233 |
# CLIENT ONLY, since 2026-08-19, and the reason is where glyphs are drawn |
| 1234 |
# rather than how much they weigh. Nothing on a headless box rasterises a |
| 1235 |
# character: there is no compositor, no GTK, and no terminal, since shop is |
| 1236 |
# client-only and the server carries only its uninstalled RPM. A TUI reached |
| 1237 |
# over ssh is drawn by the CLIENT's font stack out of the client's own faces, |
| 1238 |
# so the server end of that session never opens a font file. The image agreed |
| 1239 |
# with itself on this already, since the profile prune takes |
| 1240 |
# /etc/skel/.config/fontconfig, so `HOME=/etc/skel fc-match monospace` on a |
| 1241 |
# server image answers Adwaita Mono, the base's face, and not Alloy's. |
| 1242 |
# |
| 1243 |
# Measured before removing anything (2026-08-19): the only binaries that link |
| 1244 |
# libfontconfig are fontconfig's own fc-* tools, and no installed package |
| 1245 |
# requires it. The base has none either, so declining to install it here is |
| 1246 |
# what actually removes it rather than a line that a dependency quietly puts |
| 1247 |
# back. It takes 784 KiB of tools, 120 KiB of cache and the 208 KiB of faces |
| 1248 |
# below with it. What the measurement did not cover, when it was made, was a |
| 1249 |
# server-profile image: none had been built, so what a server image ends up |
| 1250 |
# carrying was read off the package set rather than seen on disk. |
| 1251 |
# |
| 1252 |
# The `else` branch is the claim from the other side, and it is a real one, with |
| 1253 |
# one word since measured: it holds that the base still ships no fontconfig, so |
| 1254 |
# this line is the only thing that could put one on a server image AT THIS POINT |
| 1255 |
# IN THE BUILD. That last part is not a hedge. `GUI=tauri` installs the GTK and |
| 1256 |
# WebKit devel set further down and drags fontconfig in underneath it, which is |
| 1257 |
# what the first astra mint found on 2026-09-04. This assertion is unaffected -- |
| 1258 |
# it runs before that block and is still true where it stands -- and the claim |
| 1259 |
# that matters more, about what the image ends up with, is asserted at the end of |
| 1260 |
# the file, after every package install, where it can be true. That one now |
| 1261 |
# splits on $GUI; see it for the reasoning. |
| 1262 |
# ===================================================================== |
| 1263 |
RUN set -eu; \ |
| 1264 |
if [ "$PROFILE" = client ]; then \ |
| 1265 |
dnf install -y fontconfig; \ |
| 1266 |
dnf clean all; \ |
| 1267 |
command -v fc-cache >/dev/null \ |
| 1268 |
|| { echo "fontconfig is installed and fc-cache is not on PATH; the face copy below would cache nothing and every app would rescan the font directories at startup" >&2; exit 1; }; \ |
| 1269 |
else \ |
| 1270 |
! command -v fc-cache >/dev/null \ |
| 1271 |
|| { echo "profile=server already carries fontconfig, before any line here asked for it; the base changed, so measure what wants it rather than leaving this branch asserting something untrue" >&2; exit 1; }; \ |
| 1272 |
echo "fontconfig: not installed; nothing headless rasterises a glyph"; \ |
| 1273 |
fi |
| 1274 |
|
| 1275 |
# ===================================================================== |
| 1276 |
# Package additions — full Alloy stack per docs/STACK.md |
| 1277 |
# |
| 1278 |
# Two kinds of line live here and they are not the same kind of |
| 1279 |
# decision. The curated picks are STACK.md's, each one a position the |
| 1280 |
# docs defend. The "session prerequisites" group below is not curated |
| 1281 |
# at all: fedora-bootc is a *server* base, so pieces a graphical |
| 1282 |
# session simply cannot work without are absent and have to be named |
| 1283 |
# explicitly. Treating that group as if it were a matter of taste is |
| 1284 |
# how the image shipped with no audio stack at all — see the group's |
| 1285 |
# own comment. Add to it when something is required; add to the |
| 1286 |
# curated groups only with a STACK.md entry behind it. |
| 1287 |
# |
| 1288 |
# Sources noted per group. Nothing is downloaded directly any more: the |
| 1289 |
# fonts were the last of it and the image cuts its own now (the |
| 1290 |
# `quasi-type` stage above, docs/STACK.md#fonts). The |
| 1291 |
# `flatpak` client is installed as the `sandboxed` level of the isolation |
| 1292 |
# dial, which is also how users pull on-demand apps from Flathub |
| 1293 |
# post-install; no Flatpaks are provisioned at build or first-boot |
| 1294 |
# time. |
| 1295 |
# ===================================================================== |
| 1296 |
# --------------------------------------------------------------------- |
| 1297 |
# Base packages — installed on every profile. |
| 1298 |
# |
| 1299 |
# What is left when the graphical session goes away. Read the split as a |
| 1300 |
# question about the machine rather than about taste: a package is here |
| 1301 |
# if a headless box still has a use for it, and in the client block below |
| 1302 |
# if it needs a compositor, a screen, or a person sitting at one. |
| 1303 |
# |
| 1304 |
# WEAK DEPENDENCIES OFF, the same as the client block below, and it took a |
| 1305 |
# measurement to notice they were on here. The lean profile was accepting |
| 1306 |
# recommends while the fat one refused them, which is backwards, and the |
| 1307 |
# asymmetry was invisible because a recommend is by definition a package |
| 1308 |
# nobody named. Resolved 2026-08-17 against fedora-bootc:43 with terra and |
| 1309 |
# varlad/yazi: this list pulled 142 packages and 199 MiB with recommends |
| 1310 |
# on, and 114 packages and 98 MiB with them off. |
| 1311 |
# |
| 1312 |
# What the flag sheds is not incidental. helix recommends a C toolchain |
| 1313 |
# (gcc, cpp, binutils, make, libstdc++-devel, gcc-c++), and helix and |
| 1314 |
# gopass between them recommend wl-clipboard, xsel, xdg-utils and libX11. |
| 1315 |
# On `server` not one of those can run: there is no compositor and no X. |
| 1316 |
# On `client` the clipboard tools are named explicitly further down, so |
| 1317 |
# nothing there loses them. |
| 1318 |
# |
| 1319 |
# Measured on the built images rather than predicted, because the |
| 1320 |
# prediction was half wrong: gcc-c++, libstdc++-devel, xsel, xdg-utils, |
| 1321 |
# libX11 and man-pages are gone from `server`, and gcc, cpp, binutils and |
| 1322 |
# make were STILL THERE, because `LANGS` defaulted to rust and rust |
| 1323 |
# requires them. So the C compiler on a headless box was a language |
| 1324 |
# decision and not an accident, and the LANGS default was what would have |
| 1325 |
# to change to shed it. It changed on 2026-08-27: `LANGS` is empty by |
| 1326 |
# default, so a stock image of either profile carries no compiler at all |
| 1327 |
# and one minted with `LANGS=rust` gets the C toolchain back along with |
| 1328 |
# it. The flag itself still only removes the C++ half and the X half. |
| 1329 |
# |
| 1330 |
# One recommend IS wanted and is now named below rather than inherited: |
| 1331 |
# `helix-parsers`. It is 185 MiB of tree-sitter grammars and it is what |
| 1332 |
# makes the editor highlight anything, so taking the flag without naming |
| 1333 |
# it would have shipped an editor that renders every file as plain text |
| 1334 |
# and said nothing about why. That is the trade the flag makes visible: |
| 1335 |
# the answer is to name what is wanted, not to keep taking everything. |
| 1336 |
# |
| 1337 |
# NOT SUBTRACTABLE, stated so nobody re-measures it: `gopass` REQUIRES |
| 1338 |
# `fish`, hard, 39.3 MiB, and it survives the flag. A second interactive |
| 1339 |
# shell that nothing in the image runs, in both profiles, because Fedora's |
| 1340 |
# spec requires the completions rather than recommending them. Either |
| 1341 |
# gopass goes or the weight is accepted; it was chosen deliberately |
| 1342 |
# (2026-07-30) so it stays until somebody decides otherwise. |
| 1343 |
# --------------------------------------------------------------------- |
| 1344 |
RUN dnf install -y --setopt=install_weak_deps=False \ |
| 1345 |
# Editor, shell, prompt. The terminal is not here: shop is built from |
| 1346 |
# source in the rust-build stage and copied in below. |
| 1347 |
# |
| 1348 |
# helix-parsers is the tree-sitter grammar set, named because the |
| 1349 |
# line above turns recommends off and it arrived as one. Without it |
| 1350 |
# helix opens every file unhighlighted, which is the quietest way |
| 1351 |
# this image could get worse. |
| 1352 |
helix \ |
| 1353 |
helix-parsers \ |
| 1354 |
nushell \ |
| 1355 |
starship \ |
| 1356 |
zoxide \ |
| 1357 |
direnv \ |
| 1358 |
# File managers |
| 1359 |
yazi \ |
| 1360 |
# System introspection |
| 1361 |
bottom \ |
| 1362 |
dua-cli \ |
| 1363 |
# Command-line staples. The 2026-07-29 feature audit caught the |
| 1364 |
# contradiction: this image ships zoxide and direnv on the argument |
| 1365 |
# that a developer expects them, and shipped no `rg`. These are the |
| 1366 |
# rest of that same argument. All four are Rust, which is not the |
| 1367 |
# reason they are here but does mean they carry no interpreter. |
| 1368 |
# |
| 1369 |
# fd-find installs the binary as `fd` on Fedora; the package name is |
| 1370 |
# the one that differs from the command, which is the only trap in |
| 1371 |
# this group. |
| 1372 |
ripgrep \ |
| 1373 |
fd-find \ |
| 1374 |
bat \ |
| 1375 |
eza \ |
| 1376 |
# Archives. p7zip alone handles 7z; p7zip-plugins is what adds rar, |
| 1377 |
# which is the format a person actually receives and cannot open. |
| 1378 |
# Splitting them would ship the half nobody hits. |
| 1379 |
p7zip \ |
| 1380 |
p7zip-plugins \ |
| 1381 |
# WireGuard. NetworkManager 1.54 speaks WireGuard natively, so there |
| 1382 |
# is no plugin to install and none exists to install: the packages a |
| 1383 |
# search suggests (NetworkManager-wireguard, and the -gnome variant) |
| 1384 |
# are not in Fedora 43. What is missing without this package is key |
| 1385 |
# generation, since `nmcli` imports a config but will not mint one. |
| 1386 |
# wireguard-tools carries `wg` and `wg-quick`. |
| 1387 |
wireguard-tools \ |
| 1388 |
# The host firewall, ruled 2026-08-22 (docs/STACK.md `## Firewall`). |
| 1389 |
# Before it the image had none at all: firewalld absent, no nftables |
| 1390 |
# ruleset, every listener the preset enables reachable from whatever |
| 1391 |
# network the machine was on. |
| 1392 |
# |
| 1393 |
# 2.0 MiB, and the dependency closure is 7.3 MiB because python3 and |
| 1394 |
# nftables are already here. The Python daemon that gets cited as |
| 1395 |
# firewalld's cost is a library load on this image, not an interpreter. |
| 1396 |
# |
| 1397 |
# In the base rather than on `server` alone, and the pick says why: a |
| 1398 |
# laptop is on more untrusted networks than a server is. The two |
| 1399 |
# profiles differ by their default zone, not by whether they have one, |
| 1400 |
# and the zone assignment is further down beside the other config-tree |
| 1401 |
# assertions. |
| 1402 |
firewalld \ |
| 1403 |
# Device authorization for the USB bus, ruled 2026-08-22 (GoingsOn |
| 1404 |
# alloy 63de3d4c: enforcement on by default, interactive activation, |
| 1405 |
# and the gate drops whenever no usable keyboard is present). |
| 1406 |
# |
| 1407 |
# The view half already shipped and needs none of this: `alloy usb` |
| 1408 |
# reads sysfs and works on an image that has never heard of usbguard. |
| 1409 |
# What the package adds is the ability to act on what that screen |
| 1410 |
# shows — deauthorize an attachment, and remember a decision. |
| 1411 |
# |
| 1412 |
# 1.3 MiB, and the closure is 4.8 MiB on this image: protobuf (3.3), |
| 1413 |
# libqb (0.2) and usbguard-selinux (0.01). Measured on the built |
| 1414 |
# client and server images both, and the number is worth stating |
| 1415 |
# because on a bare fedora-bootc:43 the same install is 16.4 MiB — |
| 1416 |
# usbguard-selinux pulls policycoreutils-python-utils and with it |
| 1417 |
# python3-policycoreutils and python3-setools, all of which this |
| 1418 |
# image already carries. Neither profile pays for them twice. |
| 1419 |
# |
| 1420 |
# In the base rather than on `client` alone. A server-profile machine |
| 1421 |
# has a USB bus too, and the ruling gives that profile the stricter |
| 1422 |
# half of the policy (no keyboard gate, because it has no keyboard by |
| 1423 |
# design), so it is the profile that needs this more, not less. |
| 1424 |
# |
| 1425 |
# THE UNIT IS NOT ENABLED HERE, and 50-alloy.preset deliberately does |
| 1426 |
# not list it. The reason is measured rather than cautious: the |
| 1427 |
# package ships an EMPTY /etc/usbguard/rules.conf and |
| 1428 |
# `ImplicitPolicyTarget=block`, and `PresentDevicePolicy=apply-policy` |
| 1429 |
# applies that to devices already attached when the daemon starts. So |
| 1430 |
# enabling the stock unit with the stock policy deauthorizes every USB |
| 1431 |
# device on the machine at boot, keyboard included. The policy (step 3 |
| 1432 |
# of the task) and the keyboard gate (step 4) are what make the enable |
| 1433 |
# line safe, and it lands with them, after the three bench tests the |
| 1434 |
# task names. The assertion further down is what keeps that true. |
| 1435 |
usbguard \ |
| 1436 |
# Hardware health. The base ships nvme-cli, so NVMe wear and SMART |
| 1437 |
# were already readable, and nothing else was: no way to read ECC |
| 1438 |
# corrected-error counts, no SMART for SATA, no path to a BMC. That |
| 1439 |
# is ECC memory you cannot act on, and rising correctables are the |
| 1440 |
# early warning ECC is bought for. It matters first for the bench |
| 1441 |
# build host, whose characterization baseline requires ECC counters |
| 1442 |
# provably readable before it is trusted as a reference (GoingsOn |
| 1443 |
# tailoredmachines 18af3fb3), but a laptop with no ECC at all still |
| 1444 |
# wants SMART on the disk it boots from, so these live in the base |
| 1445 |
# rather than in either profile. |
| 1446 |
# |
| 1447 |
# This group is the one the old single-block comment already said |
| 1448 |
# belonged "in the base rather than waiting on a server variant". |
| 1449 |
# The variant now exists and the group did not move, which is the |
| 1450 |
# outcome that comment was arguing for. |
| 1451 |
# |
| 1452 |
# rasdaemon carries ras-mc-ctl, which is the ECC readback. Note |
| 1453 |
# edac-utils is NOT here: in Fedora 43 it is an empty stub package |
| 1454 |
# (installsize 0) and rasdaemon replaced it. |
| 1455 |
# |
| 1456 |
# smartmontools is the largest of the four by dependency closure, |
| 1457 |
# dragging perl for the mail path it will never use here. Taken |
| 1458 |
# anyway: smartctl is the only SATA/USB SMART reader, and the four |
| 1459 |
# together add 49 MiB installed. |
| 1460 |
rasdaemon \ |
| 1461 |
smartmontools \ |
| 1462 |
# ipmitool talks to the AST2600 BMC on server boards from the host |
| 1463 |
# side, which is how a headless box reports its own sensors and |
| 1464 |
# event log without going through the web UI. The one package here |
| 1465 |
# that is more useful on `server` than on `client`. |
| 1466 |
ipmitool \ |
| 1467 |
# lm_sensors covers everything that is not behind a BMC: laptop |
| 1468 |
# thermals, and on a desktop board the CPU and fan readings. |
| 1469 |
lm_sensors \ |
| 1470 |
# The fuzzy picker behind both TUI menus in usr/bin: alloy-menu |
| 1471 |
# ($mod+d, the launcher) and alloy-clipmenu ($mod+Shift+v, the read |
| 1472 |
# half of the clipboard history). docs/STACK.md rejects graphical |
| 1473 |
# launchers on thesis and names a terminal fuzzy picker in both |
| 1474 |
# places; one package is the whole of that, and without it the |
| 1475 |
# cliphist watchers the sway config has always run had nothing that |
| 1476 |
# could read them back. |
| 1477 |
# |
| 1478 |
# Base rather than client despite that framing: fzf is a shell tool |
| 1479 |
# first, and a headless box's interactive history search wants it |
| 1480 |
# for reasons that have nothing to do with sway. |
| 1481 |
fzf \ |
| 1482 |
# Continuity |
| 1483 |
tailscale \ |
| 1484 |
syncthing \ |
| 1485 |
restic \ |
| 1486 |
# rsync is the transport under the fleet tools rather than a |
| 1487 |
# convenience. ops-exec spawns the binary by name |
| 1488 |
# (MNW/shared/ops-exec/src/transport.rs), and bentod, the bento |
| 1489 |
# driver and sandod all link that crate, so a machine without it |
| 1490 |
# cannot collect a build from another host or hand a release over. |
| 1491 |
# That is the control plane and not a build step: it fails before |
| 1492 |
# anything is compiled, and it fails identically on a box with no |
| 1493 |
# graphical session, which is why it sits in the base. |
| 1494 |
rsync \ |
| 1495 |
# Local discovery, both halves. Alloy could already ANNOUNCE a .local |
| 1496 |
# name and could not RESOLVE one, which is a mismatch rather than a |
| 1497 |
# missing feature, and it only shows up on the machine doing the |
| 1498 |
# looking. |
| 1499 |
# |
| 1500 |
# avahi is listed here despite already being present. It arrived as a |
| 1501 |
# weak dependency (avahi-libs comes in behind cups-libs, pipewire, |
| 1502 |
# samba-client-libs, geoclue2) and enabled itself from its own package |
| 1503 |
# preset, so the responder ran on every install without anyone choosing |
| 1504 |
# it. Naming it makes that a decision, and keeps a future dependency |
| 1505 |
# change from silently removing something the install flow now needs: |
| 1506 |
# a headless box minted with a baked hostname is reached at |
| 1507 |
# `<name>.local`, so publishing is load-bearing. |
| 1508 |
# |
| 1509 |
# Note this is the group whose weak-dependency carriers are mostly |
| 1510 |
# client-side (cups-libs, pipewire, geoclue2). On a headless `server` |
| 1511 |
# those are gone, so avahi arrives only because this line names it. |
| 1512 |
# geoclue2 is back on a `server` that sets GUI=tauri, since it rides in |
| 1513 |
# under GTK and WebKit, measured 2026-09-04 on astra; that changes which |
| 1514 |
# carriers exist and not the conclusion, which is that naming avahi is |
| 1515 |
# what makes it a decision — which is |
| 1516 |
# exactly the profile where the install flow depends on it most. |
| 1517 |
# |
| 1518 |
# nss-mdns is the half that was missing. It needs no nsswitch edit from |
| 1519 |
# us: /etc/nsswitch.conf is a symlink into authselect, and the package's |
| 1520 |
# own %post inserts `mdns4_minimal [NOTFOUND=return]` ahead of `resolve`, |
| 1521 |
# which is the ordering wanted. Doing it by hand would fight authselect |
| 1522 |
# for ownership of a generated file. |
| 1523 |
# |
| 1524 |
# systemd-resolved is left alone. It ships `MulticastDNS=no` compiled in, |
| 1525 |
# so it is not competing for UDP 5353; setting it to `yes` would break |
| 1526 |
# avahi rather than add anything, since only one process can hold that |
| 1527 |
# port. |
| 1528 |
# |
| 1529 |
# avahi-tools is the third half: `avahi-resolve` and `avahi-browse`, so a |
| 1530 |
# machine can check its own mDNS rather than needing a second machine to |
| 1531 |
# check it from. With Tailscale out of the minting path and installs |
| 1532 |
# LAN-only, `ssh installer@<name>.local` IS the install flow, and its |
| 1533 |
# failure mode is invisible on the box that is failing. Named explicitly |
| 1534 |
# for the same reason as avahi above: nothing else pulls it in. |
| 1535 |
avahi \ |
| 1536 |
avahi-tools \ |
| 1537 |
nss-mdns \ |
| 1538 |
# Artifact signing. Every Linux release this tree publishes is |
| 1539 |
# signed with minisign: dist/sign-artifacts.sh checks for it on PATH |
| 1540 |
# and exits if it is absent, and the recipe step that calls it is |
| 1541 |
# sh_ok, so the run aborts rather than shipping the artifact |
| 1542 |
# unsigned. Base rather than client for the same reason as rsync: |
| 1543 |
# signing is a build-host act and has nothing to do with a desktop |
| 1544 |
# session. |
| 1545 |
minisign \ |
| 1546 |
# efibootmgr, for usr/bin/alloy-boot-entry, which names the firmware boot |
| 1547 |
# entry after `bootc install` has made one. It arrives anyway as something |
| 1548 |
# else's dependency; named here because the install step that needs it |
| 1549 |
# exits 0 when it is missing, so losing it would show up as machines |
| 1550 |
# quietly going back to saying Fedora rather than as a build failure. |
| 1551 |
efibootmgr \ |
| 1552 |
# podman, the runtime behind two of `alloy pkg box`'s three isolation |
| 1553 |
# levels: `workspace` calls it directly and distrobox wraps it for |
| 1554 |
# `host`. flatpak is the third and is client-only, since `sandboxed` |
| 1555 |
# exists to run desktop applications through portals. |
| 1556 |
podman \ |
| 1557 |
# gopass is NOT here. It moved to the client block on 2026-08-17, |
| 1558 |
# with gnome-keyring, which it always should have sat beside: a |
| 1559 |
# personal password store is for the person at the machine, and a |
| 1560 |
# headless build host has no use for one. It took `fish` with it, |
| 1561 |
# which is 39.3 MiB gopass hard-requires and nothing else in the |
| 1562 |
# image wants — see the note beside it there. |
| 1563 |
# |
| 1564 |
# git. Not in the base. aliases.nu ships six git aliases, helix's |
| 1565 |
# diff gutters need it, docs/STACK.md sells EDITOR=hx on git commit |
| 1566 |
# messages, and the stated audience is developers. The full package |
| 1567 |
# rather than git-core: the extra weight is documentation and the |
| 1568 |
# perl helpers, which is the difference between a git that works |
| 1569 |
# and a git that works when you ask it a question. |
| 1570 |
git \ |
| 1571 |
# The Rust toolchain used to be an unconditional line here, and its |
| 1572 |
# comment argued at length about the 610 MiB it costs before ending |
| 1573 |
# with "per-image language selection at mint time is the way this |
| 1574 |
# stops being one number for everyone". That is now the `LANGS` ARG |
| 1575 |
# and the toolchain block further down. The reasoning moved with it; |
| 1576 |
# nothing about the cost or the build-host requirement changed. |
| 1577 |
# xdg-user-dirs. Without it a new account gets a bare home and no |
| 1578 |
# ~/Documents, ~/Downloads or ~/Pictures, which is what yazi opens |
| 1579 |
# into. Base because yazi is base; the GTK file-chooser portal that |
| 1580 |
# was the other consumer is client-only. |
| 1581 |
xdg-user-dirs \ |
| 1582 |
# xdg-utils, for /usr/bin/xdg-mime and /usr/bin/xdg-open. Two roles, |
| 1583 |
# and only one of them is a desktop's. The desktop one is yazi's |
| 1584 |
# primary action, which is `xdg-open` on Enter and on Reveal. |
| 1585 |
# The other is the build host's: tauri-bundler shells out to both |
| 1586 |
# binaries at those hard-coded absolute paths while writing a Linux |
| 1587 |
# bundle, so a profile without them cannot bundle a Tauri app at |
| 1588 |
# all. That is the whole argument for base rather than the |
| 1589 |
# client block where this used to sit: headless IS the build-host |
| 1590 |
# profile, so the one profile that most needs to bundle the apps was |
| 1591 |
# the one profile that could not. The mime database the desktop half |
| 1592 |
# resolves against stays client-side; the binaries do not need it to |
| 1593 |
# exist for the bundler's sake. |
| 1594 |
xdg-utils \ |
| 1595 |
# jq. Already present, and that is exactly the problem: it comes from |
| 1596 |
# fedora-bootc rather than from any line here, and the sway config's |
| 1597 |
# Ctrl+Print binding (active-window screenshot) pipes swaymsg through it. |
| 1598 |
# The config's own comment records the binding being enabled *because* |
| 1599 |
# "jq is in the fedora-bootc base already" — a decision resting on an |
| 1600 |
# observation of someone else's package set, which can change without |
| 1601 |
# warning and would take the binding with it silently. Declaring it costs |
| 1602 |
# nothing and turns an inherited assumption into a stated dependency. |
| 1603 |
# |
| 1604 |
# Base rather than client even though that binding is client-side: |
| 1605 |
# usr/bin/alloy-shot is not the only consumer, and a JSON tool on a |
| 1606 |
# build host needs no further argument. |
| 1607 |
jq \ |
| 1608 |
&& dnf clean all |
| 1609 |
|
| 1610 |
# --------------------------------------------------------------------- |
| 1611 |
# Client packages — the graphical session and everything that assumes one. |
| 1612 |
# |
| 1613 |
# Conditional, and the `else` branch is not decoration. Every `$PROFILE` |
| 1614 |
# conditional in this file asserts something on both sides, so that a |
| 1615 |
# branch taken by mistake fails the build instead of quietly skipping |
| 1616 |
# work. Here that means `server` proves the compositor really is absent |
| 1617 |
# rather than trusting that the `then` branch did not run. tests/ |
| 1618 |
# profile_split.rs enforces the rule across the whole file; the |
| 1619 |
# PROFILE-validity assertion above is what makes the condition itself |
| 1620 |
# trustworthy. |
| 1621 |
# |
| 1622 |
# WEAK DEPS ARE OFF HERE, since 2026-08-18, which closes the last gap in |
| 1623 |
# the installation discipline (wiki `alloy-installation-discipline`): the |
| 1624 |
# other three install sites in this file already passed the flag and this |
| 1625 |
# one, behind the largest layer in the image, did not. The printing block |
| 1626 |
# below says outright that it was split into its own RUN to get the flag |
| 1627 |
# around a block that does not have it, which is a workaround for the gap |
| 1628 |
# rather than a reason for it. |
| 1629 |
# |
| 1630 |
# TAKING THE FLAG IS NOT THE WHOLE MOVE, AND THIS IMAGE HAS THE CAUTIONARY |
| 1631 |
# TALE TWICE OVER. A recommend is sometimes load-bearing: `helix-parsers` |
| 1632 |
# is 185 MiB of grammars that arrived as one, and sway-systemd, which |
| 1633 |
# starts the entire systemd user session, arrives as a recommend of |
| 1634 |
# sway-config-upstream — as the sway config template has warned in writing |
| 1635 |
# since July. So the rule is take the flag and then name back what is |
| 1636 |
# wanted, and the group at the end of this list is that naming. |
| 1637 |
# |
| 1638 |
# MEASURED 2026-08-18 by resolving this exact package list both ways |
| 1639 |
# against the same repos (fedora, updates, terra, the three COPRs): |
| 1640 |
# |
| 1641 |
# weak deps on 504 packages |
| 1642 |
# weak deps off 418 packages |
| 1643 |
# off, load-bearing named back 448 packages |
| 1644 |
# |
| 1645 |
# So the change drops 56 packages and 99.3 MiB installed and pulls in |
| 1646 |
# nothing new, which is the half worth checking: a naive flag pulled six |
| 1647 |
# packages that were not there before, and the JACK note below is why. The |
| 1648 |
# two largest are `intel-mediasdk` (22.4 MiB) and |
| 1649 |
# `intel-vpl-gpu-rt` (11.7 MiB), the QuickSync path, which the shipped |
| 1650 |
# mpv.conf's `hwdec=auto-safe` does not select; `mesa-va-drivers` is what |
| 1651 |
# it does use and is named back below. The rest is the GNOME metadata |
| 1652 |
# estate (localsearch, tinysparql, poppler, exiv2, the osinfo set), iOS |
| 1653 |
# device support, event sounds, seventeen supplementary Thai display faces |
| 1654 |
# (6.9 MiB, and not coverage — see below), and bash and fish completions |
| 1655 |
# for shells nobody here runs. |
| 1656 |
# |
| 1657 |
# THE ONE THAT LOOKED LIKE A CASUALTY AND IS NOT: the two |
| 1658 |
# `default-fonts-*` metapackages. A metapackage whose payload is recommends |
| 1659 |
# becomes a no-op under this flag, which would have taken script coverage |
| 1660 |
# for most of the web with it and said nothing at all. Measured rather than |
| 1661 |
# assumed: `default-fonts-other-sans` requires 62 per-language metapackages |
| 1662 |
# and each of those requires its Noto face, so every script resolves with |
| 1663 |
# the flag on. What drops is about twenty supplementary Thai display faces |
| 1664 |
# recommended a level further down, which are alternative faces for a |
| 1665 |
# covered script rather than coverage. The CJK monospace face is the one |
| 1666 |
# real loss and is named back explicitly. |
| 1667 |
# --------------------------------------------------------------------- |
| 1668 |
RUN if [ "$PROFILE" = client ]; then \ |
| 1669 |
dnf install -y --setopt=install_weak_deps=False \ |
| 1670 |
# Compositor and Wayland session (Fedora main) |
| 1671 |
sway \ |
| 1672 |
# Portals: -wlr is the wlroots backend sway needs for screencast. |
| 1673 |
# -gnome was the wrong backend here (it drives screencast through |
| 1674 |
# gnome-shell, which Alloy removes below). -gtk stays for the file |
| 1675 |
# chooser. |
| 1676 |
xdg-desktop-portal xdg-desktop-portal-gtk xdg-desktop-portal-wlr \ |
| 1677 |
# Notifications, screenshot annotate, wallpaper (bar = sway's built-in swaybar) |
| 1678 |
mako \ |
| 1679 |
# notify-send, which is the *client* half. mako implements the |
| 1680 |
# notification server and does not pull this in, so it has been arriving |
| 1681 |
# as somebody else's transitive dependency. usr/bin/alloy-shot calls it on |
| 1682 |
# every screenshot, and the failure mode if it goes missing is the exact |
| 1683 |
# one that script was written to fix: a capture that happens and says |
| 1684 |
# nothing. Same reasoning as jq above. |
| 1685 |
libnotify \ |
| 1686 |
satty \ |
| 1687 |
swww \ |
| 1688 |
# Content viewers. These are what yazi opens files with, which is |
| 1689 |
# test 2 of the packaging policy's in-image tests, so they follow |
| 1690 |
# yazi's consumers rather than yazi itself: all three want a screen. |
| 1691 |
mpv \ |
| 1692 |
imv \ |
| 1693 |
zathura zathura-pdf-mupdf \ |
| 1694 |
# Wayland session glue |
| 1695 |
cliphist \ |
| 1696 |
# Wi-Fi, and it is two packages because NetworkManager on Fedora does |
| 1697 |
# not do Wi-Fi by itself. The device plugin lives in |
| 1698 |
# NetworkManager-wifi and the supplicant is a separate package again; |
| 1699 |
# the base is fedora-bootc, a server base, and carries neither. Without |
| 1700 |
# them the kernel loads the driver and creates the interface, NM never |
| 1701 |
# presents it, and `nmcli device` lists loopback alone. Measured on |
| 1702 |
# fw12 2026-09-03: a laptop with working mt7xxx firmware and no way to |
| 1703 |
# reach a network, on a distro whose own description is laptop-first. |
| 1704 |
# |
| 1705 |
# Client-only, because the server profile's machines are wired by role |
| 1706 |
# (astra is the only one in evidence). If a headless box on Wi-Fi ever |
| 1707 |
# becomes a role, these move to the base block rather than being |
| 1708 |
# duplicated into the server branch. |
| 1709 |
NetworkManager-wifi \ |
| 1710 |
wpa_supplicant \ |
| 1711 |
# And the Intel wireless firmware, which is a separate defect with the |
| 1712 |
# same symptom and a different layer. `--setopt=install_weak_deps=False` |
| 1713 |
# above is what drops it: Fedora's linux-firmware *requires* the |
| 1714 |
# atheros, brcmfmac, mt7xxx and nxpwireless splits and only |
| 1715 |
# *recommends* the iwlwifi ones, so the flag silently produced an image |
| 1716 |
# that can drive every wireless chip except Intel's. Measured on fw12 |
| 1717 |
# 2026-09-03, where the kernel said it in as many words: |
| 1718 |
# |
| 1719 |
# Detected Intel(R) Wi-Fi 6E AX211 160MHz |
| 1720 |
# Direct firmware load for iwlwifi-so-a0-gf-a0-89.ucode failed |
| 1721 |
# no suitable firmware found! |
| 1722 |
# |
| 1723 |
# The driver loads either way, so `lsmod` shows iwlwifi present with a |
| 1724 |
# refcount of zero and no interface is ever created. Same silent shape |
| 1725 |
# as the missing plugin above: nothing fails, and the hardware is |
| 1726 |
# simply not there. |
| 1727 |
# |
| 1728 |
# -mvm covers the modern parts (7260 onward, which is every Intel card |
| 1729 |
# in a machine Alloy claims to support, fw12's AX211 included). -mld is |
| 1730 |
# the newest series and -dvm is pre-2013; neither is in the tested |
| 1731 |
# matrix, and this line grows when the matrix does rather than in |
| 1732 |
# anticipation. |
| 1733 |
iwlwifi-mvm-firmware \ |
| 1734 |
# Lock + idle. swaylock is the adopted lock surface per |
| 1735 |
# docs/STACK.md#lock. A session-lock surface is graphical and |
| 1736 |
# cannot be a TUI, so Alloy adopts rather than authors it. The |
| 1737 |
# themed config in etc/skel had no package behind it until now. |
| 1738 |
swaylock \ |
| 1739 |
swayidle \ |
| 1740 |
swayosd \ |
| 1741 |
# Fingerprint unlock, which swaylock reaches without knowing it: its |
| 1742 |
# /etc/pam.d/swaylock is `auth include login`, login includes |
| 1743 |
# system-auth, and system-auth is what authselect rewrites when the |
| 1744 |
# with-fingerprint feature is enabled below. So the lock screen, the |
| 1745 |
# greeter and run0 all gain the same unlock from one switch, and none |
| 1746 |
# of them needs an Alloy-authored PAM file. |
| 1747 |
# |
| 1748 |
# fprintd-pam is the half that matters and is not pulled in by fprintd: |
| 1749 |
# the daemon can enroll a finger all day, but without the PAM module |
| 1750 |
# nothing ever asks it. Both named so neither arrives as somebody |
| 1751 |
# else's transitive dependency. |
| 1752 |
# |
| 1753 |
# Client-only because the reader is a laptop part and because the |
| 1754 |
# authselect feature this feeds is applied in a client-only layer. |
| 1755 |
# A headless box authenticates with a key, not a finger. |
| 1756 |
fprintd \ |
| 1757 |
fprintd-pam \ |
| 1758 |
playerctl \ |
| 1759 |
gammastep \ |
| 1760 |
# Greeter |
| 1761 |
greetd \ |
| 1762 |
tuigreet \ |
| 1763 |
# No initial-setup. It was here because the bootc Anaconda flow has no |
| 1764 |
# user-creation spoke, so an install otherwise finished with root |
| 1765 |
# locked and no way in. `alloy install` creates the account now, and |
| 1766 |
# keeping it made things worse rather than redundant: it ships enabled |
| 1767 |
# in both graphical.target.wants and multi-user.target.wants, and on |
| 1768 |
# first boot it takes the console and blocks on an Anaconda text spoke |
| 1769 |
# reading "[!] User creation (No user will be created)", in front of a |
| 1770 |
# perfectly good uid 1000 account. Verified in QEMU 2026-07-20. |
| 1771 |
# Removing it is what lets first boot reach greetd. |
| 1772 |
# Cursor, GTK theme |
| 1773 |
bibata-cursor-theme \ |
| 1774 |
adw-gtk3-theme \ |
| 1775 |
# dconf, named rather than left to a transitive pull. It is what |
| 1776 |
# compiles and reads /etc/dconf/db/local, which is where Alloy's |
| 1777 |
# GTK schema defaults live (the file chooser's hidden files, so |
| 1778 |
# far). GTK reads that database through gsettings whether or not |
| 1779 |
# the CLI is present, but `dconf update` at build time and the |
| 1780 |
# assertion after it both need the binary, and a defaults |
| 1781 |
# mechanism that depends on someone else's dependency graph is |
| 1782 |
# one release from silently not applying. |
| 1783 |
dconf \ |
| 1784 |
# Screenshot capture + region-select (sway has no built-in grab) |
| 1785 |
grim slurp \ |
| 1786 |
# The browser is not a line here any more: it is the `BROWSER` ARG |
| 1787 |
# and the block further down, because it is the one stack pick |
| 1788 |
# Alloy declines to make. docs/STACK.md:150 already called it a |
| 1789 |
# non-endorsement rather than a pick; this is where that becomes |
| 1790 |
# true of the build rather than only of the prose. |
| 1791 |
# Script coverage for the browser. Until now the image carried |
| 1792 |
# google-noto-sans-vf (Latin, Greek, Cyrillic) and nothing else, so |
| 1793 |
# the browser rendered every CJK, Arabic, Hebrew, Indic and Thai page |
| 1794 |
# as rows of missing glyphs. Shipping a browser as the default and |
| 1795 |
# then not carrying the fonts a large share of the web is written in |
| 1796 |
# is the same defect class as the emoji alias that named a font the |
| 1797 |
# image never installed: the config was fine, the coverage was |
| 1798 |
# absent, and nothing said so. The fonts outlive any one browser |
| 1799 |
# pick, so this line does not move when the pick does. |
| 1800 |
# |
| 1801 |
# These are Fedora's own coverage metapackages rather than a hand- |
| 1802 |
# picked list, because the failure mode of hand-picking is a script |
| 1803 |
# nobody on this end reads being the one left out. Cost stated rather |
| 1804 |
# than absorbed: -cjk-sans is 62 MiB over 4 packages (the CJK faces are |
| 1805 |
# simply large), -other-sans is 13 MiB over 95. |
| 1806 |
# |
| 1807 |
# THESE SURVIVE THE WEAK-DEPS FLAG, and it was worth checking rather |
| 1808 |
# than assuming, because a metapackage whose payload is recommends |
| 1809 |
# would have become a no-op that installs a manifest and nothing to |
| 1810 |
# render with. Measured 2026-08-18: -other-sans *requires* 62 |
| 1811 |
# per-language metapackages, each of which *requires* its Noto face, |
| 1812 |
# so every script this line exists for is a hard dependency and all |
| 1813 |
# 62 resolve with the flag on. What the flag does drop is about |
| 1814 |
# twenty supplementary Thai display faces recommended one level |
| 1815 |
# further down: Sarabun, Charmonman, Chakra Petch and their |
| 1816 |
# neighbours. Those are alternative faces for a script that is |
| 1817 |
# already covered rather than coverage, so they go. The one real |
| 1818 |
# loss is the CJK monospace face, named back explicitly below. |
| 1819 |
# |
| 1820 |
# No fontconfig change goes with this. The aliases in |
| 1821 |
# etc/skel/.config/fontconfig/fonts.conf use <prefer>, which leaves |
| 1822 |
# fontconfig free to fall through to a font that has the glyph, so |
| 1823 |
# Quasi Body stays the sans for Latin text and Noto covers what it |
| 1824 |
# cannot. |
| 1825 |
# |
| 1826 |
# Emoji is untouched and still deliberately absent (docs/STACK.md): |
| 1827 |
# neither metapackage carries an emoji font, which is why declining |
| 1828 |
# emoji survives this line. |
| 1829 |
default-fonts-cjk-sans \ |
| 1830 |
default-fonts-other-sans \ |
| 1831 |
# flatpak, the `sandboxed` rung of the isolation dial and the install |
| 1832 |
# path for everything Alloy does not bake in. Client-only: portals, |
| 1833 |
# a session bus and a screen are what that rung is for, and on a |
| 1834 |
# headless box `alloy pkg box` simply reports the backend absent, |
| 1835 |
# which the boxes tab already handles as a state rather than an error. |
| 1836 |
flatpak \ |
| 1837 |
# Secrets (docs/STACK.md#secrets), the Secret Service half. |
| 1838 |
# |
| 1839 |
# gnome-keyring is the Secret Service provider, and it is here |
| 1840 |
# because its absence broke Make Creative's own software on Make |
| 1841 |
# Creative's own distro. Nothing in the image registered |
| 1842 |
# org.freedesktop.secrets, and the Rust `keyring` crate and Tauri's |
| 1843 |
# credential plumbing both resolve to it on Linux, so GoingsOn and |
| 1844 |
# Balanced Breakfast had nowhere to put a credential and |
| 1845 |
# synckit-client had nowhere to persist its E2EE key. That surfaces |
| 1846 |
# as a confusing runtime error inside an app, four layers from the |
| 1847 |
# cause. |
| 1848 |
# |
| 1849 |
# gnome-keyring-pam is a SEPARATE package and not a weak dependency |
| 1850 |
# of the one above, which matters more than it looks. Fedora's |
| 1851 |
# /etc/pam.d/greetd already carries the two stanzas that unlock the |
| 1852 |
# keyring with the login password, and both are `-` prefixed: |
| 1853 |
# |
| 1854 |
# -auth optional pam_gnome_keyring.so |
| 1855 |
# -session optional pam_gnome_keyring.so auto_start |
| 1856 |
# |
| 1857 |
# A leading `-` tells PAM to skip a module it cannot load, without a |
| 1858 |
# word in any log. So the provider alone gets you a Secret Service |
| 1859 |
# that works and a keyring locked behind a second password prompt |
| 1860 |
# nobody chose, and the config that was supposed to prevent that is |
| 1861 |
# sitting right there looking correct. Same defect class as the |
| 1862 |
# emoji alias and the swayosd unit path: configuration that is |
| 1863 |
# correct about something absent. Both packages or neither. |
| 1864 |
# |
| 1865 |
# Nothing here ships a /etc/pam.d file. greetd owns that one and the |
| 1866 |
# stanzas are already in it, so the fix is a package rather than a |
| 1867 |
# config edit; the assertion further down is what keeps that true. |
| 1868 |
# That pairing is also why this group is client-only: the PAM half |
| 1869 |
# hangs off greetd's own file, and there is no greetd on `server`. |
| 1870 |
# |
| 1871 |
# KeePassXC also provides the interface and was rejected for |
| 1872 |
# dragging Qt into a ratatui/egui design language, not on |
| 1873 |
# capability. systemd-creds is unrelated and stays: service |
| 1874 |
# credentials for daemons, no interactive component. |
| 1875 |
gnome-keyring \ |
| 1876 |
gnome-keyring-pam \ |
| 1877 |
# gopass is the other half of that sentence: gnome-keyring is the |
| 1878 |
# API programs call, gopass is where a person keeps their own |
| 1879 |
# logins (docs/STACK.md#secrets). age backend, synced by git, |
| 1880 |
# decided 2026-07-30 and re-taken 2026-08-17 on the question of |
| 1881 |
# what to build a house GUI over: a directory of age-encrypted |
| 1882 |
# files is git-native, so a concurrent edit is a conflict on one |
| 1883 |
# secret with a diff, and it reuses the age identity the sops |
| 1884 |
# migration already requires. A single-blob vault (KDBX) syncs as |
| 1885 |
# two silent copies, which is the failure this stack keeps |
| 1886 |
# rejecting. |
| 1887 |
# |
| 1888 |
# HERE RATHER THAN IN THE BASE, moved 2026-08-17. It is a personal |
| 1889 |
# store for the person at the machine, and it costs `fish`: Fedora's |
| 1890 |
# gopass hard-requires /usr/bin/fish for one completion file, 39.3 |
| 1891 |
# MiB of a shell nothing in this image runs, and no flag touches a |
| 1892 |
# hard dependency. Two Terra completion packages then follow fish in |
| 1893 |
# by conditional dependency. Client pays it because gopass is worth |
| 1894 |
# it there; server no longer pays it at all. |
| 1895 |
# |
| 1896 |
# The comment this replaced said gopass "needs no gpg on the age |
| 1897 |
# backend". True of the backend and false of the package: it |
| 1898 |
# requires gnupg2 regardless, and gnupg2 is installed. |
| 1899 |
gopass \ |
| 1900 |
# ----------------------------------------------------------------- |
| 1901 |
# Session prerequisites — not curated picks. See the header above. |
| 1902 |
# fedora-bootc is a server base; everything in this group is |
| 1903 |
# something the shipped configuration already assumes exists. |
| 1904 |
# ----------------------------------------------------------------- |
| 1905 |
# Audio. The base has no sound stack whatsoever: no pipewire, no |
| 1906 |
# wireplumber, and so no `pactl`, which crates/alloy/src/audio.rs |
| 1907 |
# shells out to for every reading and every write. `alloy audio` |
| 1908 |
# is documented as shipped (docs/CONSOLE.md) but had nothing to |
| 1909 |
# front. Established by probing quay.io/fedora/fedora-bootc:43 |
| 1910 |
# directly (rpm -q, and `pactl` absent from PATH), not observed on |
| 1911 |
# hardware — the QEMU punch list is where that gets confirmed. The |
| 1912 |
# Fn-key volume binds, mpv, and anything playerctl controls have |
| 1913 |
# the same hole under them. |
| 1914 |
# |
| 1915 |
# pulseaudio-utils is what actually carries /usr/bin/pactl (the |
| 1916 |
# pipewire-pulseaudio package is the daemon-side compat shim, not |
| 1917 |
# the CLI). Both are needed. No preset lines accompany these: |
| 1918 |
# Fedora's own 90-default-user.preset already socket-activates |
| 1919 |
# pipewire and pipewire-pulse, and `systemctl preset-all` below |
| 1920 |
# picks that up. |
| 1921 |
pipewire \ |
| 1922 |
wireplumber \ |
| 1923 |
pipewire-pulseaudio \ |
| 1924 |
pulseaudio-utils \ |
| 1925 |
# wl-clipboard. The sway config exec's `wl-paste --watch cliphist |
| 1926 |
# store` twice at session start (docs/STACK.md#clipboard-history), |
| 1927 |
# and wl-copy is how anything gets back out of the history. cliphist |
| 1928 |
# was installed without it, so both watchers failed at every login |
| 1929 |
# and the clipboard history could never be populated or pasted from. |
| 1930 |
wl-clipboard \ |
| 1931 |
# -------------------------------------------------------------- |
| 1932 |
# Named back in, because weak deps are off above and each of these |
| 1933 |
# was arriving as somebody's recommend. Measured 2026-08-18 by |
| 1934 |
# diffing the resolved transaction both ways; anything not here is |
| 1935 |
# in the 40 packages the flag drops, and that list is in the |
| 1936 |
# comment above this RUN. |
| 1937 |
# -------------------------------------------------------------- |
| 1938 |
# |
| 1939 |
# sway-systemd is the one that would have taken the whole session. |
| 1940 |
# It arrives as a recommend of sway-config-upstream and its |
| 1941 |
# /etc/sway/config.d/10-systemd-session.conf is what execs |
| 1942 |
# session.sh, which starts sway-session.target, which |
| 1943 |
# graphical-session.target binds to. Every user unit in the image |
| 1944 |
# depends on that chain: pipewire, wireplumber, xdg-user-dirs, the |
| 1945 |
# clipboard watchers. templates/etc/skel/.config/sway/config.in has |
| 1946 |
# said so in writing since 2026-07-21, when it was proven by |
| 1947 |
# reading the uid 1000 journal off a booted install. |
| 1948 |
sway-systemd \ |
| 1949 |
# The database xdg-open resolves against. xdg-utils itself moved |
| 1950 |
# to the base block on 2026-08-21 (see the note beside it there); |
| 1951 |
# these two are the half that only a desktop reads. |
| 1952 |
# etc/skel/.config/yazi/yazi.toml binds `xdg-open` to Enter and to |
| 1953 |
# Reveal, and without a mime database it opens the wrong handler |
| 1954 |
# rather than none. mailcap is the /etc/mime.types half of the |
| 1955 |
# same answer. |
| 1956 |
desktop-file-utils mailcap \ |
| 1957 |
# Vulkan, which docs/IMAGE.md#size argues about at length and |
| 1958 |
# treats as a stack decision rather than an accident: six of its |
| 1959 |
# twelve ICDs drive hardware this image boots on. It has no rpm |
| 1960 |
# dependents at all, because the loader dlopens an ICD off a JSON |
| 1961 |
# manifest, so it was arriving as a recommend and nothing would |
| 1962 |
# have reported its absence except a black window. |
| 1963 |
mesa-vulkan-drivers \ |
| 1964 |
# VA-API, which is what `hwdec=auto-safe` in the shipped mpv.conf |
| 1965 |
# actually selects. Hardware decode is a documented behaviour of |
| 1966 |
# the video player (docs/STACK.md#video), so it is not a recommend |
| 1967 |
# here. |
| 1968 |
mesa-va-drivers \ |
| 1969 |
# mpv plays streams through yt-dlp, capped at 1080p in the shipped |
| 1970 |
# config, and both STACK.md and manual chapter 9 promise it. Its |
| 1971 |
# own recommends (the metadata and cipher helpers) are not named |
| 1972 |
# back: they degrade features rather than remove playback. |
| 1973 |
yt-dlp \ |
| 1974 |
# upower. crates/alloy/src/status.rs reads battery from sysfs |
| 1975 |
# rather than through it and says so, but docs/STACK.md states it |
| 1976 |
# is in the image, the power stack that HARDWARE-FW12 documents |
| 1977 |
# wants it, and it is 311 KiB. |
| 1978 |
upower \ |
| 1979 |
# PipeWire's ALSA and JACK shims. These are the ones that punish a |
| 1980 |
# naive flag rather than merely thinning the image: without the |
| 1981 |
# JACK shim the resolver satisfies the same interface with the real |
| 1982 |
# JACK daemon instead, and the measurement caught it doing exactly |
| 1983 |
# that (jack-audio-connection-kit, libffado, glibmm2.4 and three |
| 1984 |
# more appeared in the transaction the flag was supposed to |
| 1985 |
# shrink). ALSA-only applications route through the shim too. |
| 1986 |
pipewire-alsa pipewire-jack-audio-connection-kit \ |
| 1987 |
# The camera path. Alloy ships no video-call application and the |
| 1988 |
# browser is one, so a webcam that does not work in a call is a |
| 1989 |
# defect on a laptop-first distro. -ipa is where the per-pipeline |
| 1990 |
# image processing lives and libcamera is close to useless without |
| 1991 |
# it. |
| 1992 |
libcamera libcamera-ipa pipewire-plugin-libcamera \ |
| 1993 |
# CJK coverage for the cell grid. The fonts RUN below carries the |
| 1994 |
# proportional metapackages; this is the monospace face, which a |
| 1995 |
# terminal showing CJK text needs and a sans metapackage does not |
| 1996 |
# supply. |
| 1997 |
google-noto-sans-mono-cjk-vf-fonts \ |
| 1998 |
&& dnf clean all \ |
| 1999 |
# bibata-cursor-theme installs FOURTEEN directories and Alloy names one. |
| 2000 |
# At 179 MB it was the third-largest package in the image, behind the |
| 2001 |
# browser and helix's parsers, and ahead of rustc on a mint that asked |
| 2002 |
# for rust (nothing does by default since 2026-08-27). Fedora ships no |
| 2003 |
# per-variant subpackage, so a prune is the only lever. |
| 2004 |
# |
| 2005 |
# Measured in the built image: Bibata-Modern-{Amber,Classic,Ice} at 27 MB |
| 2006 |
# each, Bibata-Original-{Amber,Classic,Ice} at 12 MB, and a -Right variant |
| 2007 |
# of all six at 11-12 MB. Keeping Bibata-Modern-Classic drops 159 MB. |
| 2008 |
# |
| 2009 |
# It runs INSIDE this RUN on purpose. Deleting them in a later layer |
| 2010 |
# writes whiteouts and reclaims nothing from the image, the same trap the |
| 2011 |
# terra metadata was in. |
| 2012 |
# |
| 2013 |
# KEEP is spelled here rather than read from /etc/skel, which is copied in |
| 2014 |
# further down and does not exist yet. The rename this cannot see is caught |
| 2015 |
# at the other end instead: the assertion after the skel copy reads the |
| 2016 |
# theme name back out of skel and fails if its directory is not here. |
| 2017 |
&& KEEP=Bibata-Modern-Classic \ |
| 2018 |
&& test -d "/usr/share/icons/$KEEP" \ |
| 2019 |
&& for theme in /usr/share/icons/Bibata-*; do \ |
| 2020 |
[ "$theme" = "/usr/share/icons/$KEEP" ] || rm -rf "$theme"; \ |
| 2021 |
done \ |
| 2022 |
&& test -d "/usr/share/icons/$KEEP" \ |
| 2023 |
# And the Wi-Fi plugin is really there. `dnf install` succeeding is not |
| 2024 |
# the check: what a wireless machine needs is the plugin file NM loads at |
| 2025 |
# runtime, and the failure this guards is silent by construction. Nothing |
| 2026 |
# errors, no unit fails, `alloy net` renders correctly, and the machine |
| 2027 |
# simply has no wireless device. It went unnoticed from the first image |
| 2028 |
# until 2026-09-03 because every Alloy install so far was reached over a |
| 2029 |
# wire. |
| 2030 |
&& ls /usr/lib64/NetworkManager/*/libnm-device-plugin-wifi.so >/dev/null 2>&1 \ |
| 2031 |
&& rpm -q wpa_supplicant >/dev/null \ |
| 2032 |
# The firmware half, asserted by file rather than by package, because |
| 2033 |
# what the driver needs is a blob at a path and the package is only how |
| 2034 |
# it gets there. |
| 2035 |
&& ls /usr/lib/firmware/iwlwifi-*.ucode* >/dev/null 2>&1; \ |
| 2036 |
else \ |
| 2037 |
# The asserting else. Five sentinels, one per reason a package could |
| 2038 |
# have landed here anyway: sway is a direct name in the `then` branch, |
| 2039 |
# pipewire is the group most likely to arrive as somebody's weak |
| 2040 |
# dependency, and greetd is what would silently turn a headless box |
| 2041 |
# into one waiting at a login prompt on a VT nobody can see. |
| 2042 |
# |
| 2043 |
# gopass and fish joined them 2026-08-17, when gopass moved out of the |
| 2044 |
# base block. fish is the interesting one: nothing in the image runs |
| 2045 |
# it, it is here only as gopass's hard dependency, and it is 39.3 MiB. |
| 2046 |
# If either reappears on a server image, the move has been undone by |
| 2047 |
# something and the 64 MiB is back without anyone deciding it. |
| 2048 |
for unwanted in sway pipewire greetd gopass fish; do \ |
| 2049 |
rpm -q "$unwanted" >/dev/null 2>&1 \ |
| 2050 |
&& { echo "profile=server but $unwanted is installed; the client branch ran or something pulled it in" >&2; exit 1; }; \ |
| 2051 |
done; \ |
| 2052 |
echo "profile=server: no compositor, no audio stack, no greeter"; \ |
| 2053 |
fi |
| 2054 |
|
| 2055 |
# ===================================================================== |
| 2056 |
# The browser, from the BROWSER choice. |
| 2057 |
# ===================================================================== |
| 2058 |
# Its own layer rather than a line in the block above, because it is the |
| 2059 |
# one thing here a user chooses and a rebuild that changes only the |
| 2060 |
# browser should not re-resolve the whole stack. |
| 2061 |
# |
| 2062 |
# Firefox, picked and defended as of 2026-08-18. Wiki |
| 2063 |
# `alloy-byo-principle`, "The browser stops being BYO". |
| 2064 |
# |
| 2065 |
# It is from Fedora's own repos, which is not incidental: they stay |
| 2066 |
# enabled after install, so this is a browser a user can also reach on a |
| 2067 |
# machine that chose `none`. That property is now a selection criterion |
| 2068 |
# for anything Alloy recommends, and it is what ended the previous |
| 2069 |
# ruling. |
| 2070 |
# |
| 2071 |
# WHAT WAS HERE UNTIL TODAY, and why it is gone. Helium is |
| 2072 |
# ungoogled-chromium with the defaults Alloy used to hand-build in fifty |
| 2073 |
# Firefox prefs, packaged by Terra as `helium-browser-bin`. It was the |
| 2074 |
# default precisely because it needed no configuration. Two things ended |
| 2075 |
# it. Terra is disabled post-install (see the repo cleanup layer), so |
| 2076 |
# Helium was unreachable on any machine that did not bake it in. And it |
| 2077 |
# is a maintained patchset over another vendor's bad defaults, which is |
| 2078 |
# not a thing Alloy ships: Helium is doing its best to make a standard |
| 2079 |
# approach tolerable, and Alloy is doing its best to improve standard |
| 2080 |
# approaches. Anyone who wants it can enable Terra on their own machine |
| 2081 |
# and layer it, which is their call and not a shape this image carries. |
| 2082 |
# |
| 2083 |
# The cost of picking Firefox is that stock Firefox has bad defaults by |
| 2084 |
# Alloy's own test, so the pick is paid for in /etc/firefox/pref/alloy.js |
| 2085 |
# — anti-features removed, one hidden control restored, nothing about |
| 2086 |
# appearance. It arrives with `COPY etc/ /etc/` and is asserted below. |
| 2087 |
# |
| 2088 |
# The assertion runs on every path including `none`, and that is the |
| 2089 |
# point: it proves exactly one browser is installed, or that none is, so |
| 2090 |
# a `BROWSER=none` image that picked one up as a dependency fails here |
| 2091 |
# instead of shipping a browser nobody chose. |
| 2092 |
RUN set -eu; \ |
| 2093 |
if [ "$PROFILE" = client ]; then \ |
| 2094 |
case "$BROWSER" in \ |
| 2095 |
firefox) dnf install -y firefox && dnf clean all ;; \ |
| 2096 |
none) echo "no browser in this image, by choice" ;; \ |
| 2097 |
esac; \ |
| 2098 |
else \ |
| 2099 |
echo "no browser: this profile has no session to run one in"; \ |
| 2100 |
fi; \ |
| 2101 |
installed=""; \ |
| 2102 |
for candidate in firefox helium-browser-bin; do \ |
| 2103 |
rpm -q "$candidate" >/dev/null 2>&1 && installed="$installed $candidate"; \ |
| 2104 |
done; \ |
| 2105 |
installed="$(echo $installed)"; \ |
| 2106 |
expected=""; \ |
| 2107 |
if [ "$PROFILE" = client ] && [ "$BROWSER" != none ]; then \ |
| 2108 |
expected="firefox"; \ |
| 2109 |
fi; \ |
| 2110 |
[ "$installed" = "$expected" ] \ |
| 2111 |
|| { echo "browser mismatch: asked for '${expected:-none}', image has '${installed:-none}'" >&2; exit 1; }; \ |
| 2112 |
echo "browser: ${expected:-none}" |
| 2113 |
|
| 2114 |
# ===================================================================== |
| 2115 |
# Language toolchains, from the LANGS choice. |
| 2116 |
# ===================================================================== |
| 2117 |
# The Containerfile asked for this before it existed. The rust line used |
| 2118 |
# to carry a comment ending "per-image language selection at mint time is |
| 2119 |
# the way this stops being one number for everyone", and this is that, |
| 2120 |
# arrived at from the builder rather than from mint time. |
| 2121 |
# |
| 2122 |
# Rust is not in the default set any more (2026-08-27): nothing in the |
| 2123 |
# image requires it, and the Sando argument that kept it is about a |
| 2124 |
# machine being a build host rather than about the image working. It |
| 2125 |
# costs 610 MiB across 16 packages, most of it rust-std-static (164 MiB) |
| 2126 |
# and llvm-libs (139 MiB), which is an order of magnitude above the |
| 2127 |
# hardware-health group and by a distance the largest optional thing in |
| 2128 |
# the image — so it is exactly the kind of thing a mint should be asked |
| 2129 |
# about rather than handed. |
| 2130 |
# |
| 2131 |
# No separate linker line: `rust` pulls gcc, binutils and glibc-devel, so |
| 2132 |
# cc arrives with it. rustup is deliberately not offered here — it |
| 2133 |
# installs into $HOME, needs nothing from the image, and stays the answer |
| 2134 |
# for a pinned or nightly toolchain. |
| 2135 |
# |
| 2136 |
# Profile-blind: languages are as useful on a build host as on a laptop, |
| 2137 |
# which is most of why the build-host role works at all. The empty |
| 2138 |
# default is profile-blind for the same reason — a client image has no |
| 2139 |
# more claim on a compiler than a server one. |
| 2140 |
# |
| 2141 |
# WHAT `c` IS, because it is not a language row like the others. It is the |
| 2142 |
# toolchain somebody needs to clone a repository and build it: gcc and g++, |
| 2143 |
# make, cmake, meson and ninja, and the autotools trio. Ruled 2026-09-07 |
| 2144 |
# (Max), who put the goal as caring "more about being able to clone a repo |
| 2145 |
# and compile it from scratch than keeping the original compiler for the |
| 2146 |
# shipped binary". |
| 2147 |
# |
| 2148 |
# That framing is what keeps this bounded. Reproducing a shipped binary means |
| 2149 |
# matching its toolchain and carrying every library header it linked, which is |
| 2150 |
# unbounded and is the "spread of -devel packages that no language toggle |
| 2151 |
# models" that killed the rebuild-what-we-ship rule. Building an arbitrary |
| 2152 |
# checkout means having the compilers and the build systems, and letting the |
| 2153 |
# person layer whatever that project names. The second is a fixed cost and |
| 2154 |
# this row is it: 193 MiB across 86 packages over a rust image, measured with |
| 2155 |
# `dnf install --assumeno` on fedora-bootc:43, 2026-09-07. |
| 2156 |
# |
| 2157 |
# gcc is named here as well as arriving with rust, so `LANGS=c` alone is a |
| 2158 |
# working C toolchain rather than one that depends on another row being on. |
| 2159 |
# |
| 2160 |
# Weak dependencies off, matching every other install in this file. cmake |
| 2161 |
# recommends its full documentation set and nothing here reads it. |
| 2162 |
# |
| 2163 |
# THREE -devel PACKAGES RIDE WITH rust, and they are not the language's. |
| 2164 |
# wayland-devel, libxkbcommon-devel and fontconfig-devel are what shop |
| 2165 |
# links against, and shop is the terminal this image ships and runs. The |
| 2166 |
# runtime libraries were always here; only the headers were missing, so a |
| 2167 |
# machine with a full Rust toolchain still could not rebuild its own |
| 2168 |
# terminal. Measured 2026-09-07 on a LANGS=rust image: cargo, rustc and |
| 2169 |
# cc all present, and pkg-config found none of the three. |
| 2170 |
# |
| 2171 |
# Attached to the rust arm rather than installed unconditionally, because |
| 2172 |
# headers with no compiler are weight nobody can use. 79 MiB installed |
| 2173 |
# across 61 packages, beside the 610 MiB of the toolchain they arrive with. |
| 2174 |
# |
| 2175 |
# THIS RESTORES A RULE THIS FILE ONCE DROPPED, for the tier it covers. |
| 2176 |
# The rule was "you can rebuild what we ship", and it was dropped where |
| 2177 |
# the Go tier was priced, because it had never been applied evenly: the C |
| 2178 |
# tier (sway, mako, swaylock) wants meson, ninja, wayland-protocols and a |
| 2179 |
# -devel spread no language toggle models, so the rule was two thirds true |
| 2180 |
# and quietly false for the rest. That objection stands and is not |
| 2181 |
# answered here. What changed is the judgement about it: an uneven rule is |
| 2182 |
# worth more than no rule when the even part is the software Alloy itself |
| 2183 |
# writes. alloy and shop are that part, and a LANGS=rust image now builds |
| 2184 |
# both. The Go tier still needs LANGS=rust,go and the C tier still has no |
| 2185 |
# dial. |
| 2186 |
RUN set -eu; \ |
| 2187 |
for lang in $(echo "$LANGS" | tr ',' ' '); do \ |
| 2188 |
case "$lang" in \ |
| 2189 |
rust) dnf install -y rust cargo \ |
| 2190 |
wayland-devel libxkbcommon-devel fontconfig-devel ;; \ |
| 2191 |
c) dnf install -y --setopt=install_weak_deps=False \ |
| 2192 |
gcc gcc-c++ make cmake meson ninja-build \ |
| 2193 |
autoconf automake libtool patch pkgconf ;; \ |
| 2194 |
go) dnf install -y golang ;; \ |
| 2195 |
python) dnf install -y python3 python3-pip ;; \ |
| 2196 |
zig) dnf install -y zig ;; \ |
| 2197 |
js) dnf install -y --setopt=install_weak_deps=False nodejs npm ;; \ |
| 2198 |
esac; \ |
| 2199 |
done; \ |
| 2200 |
dnf clean all; \ |
| 2201 |
for lang in $(echo "$LANGS" | tr ',' ' '); do \ |
| 2202 |
case "$lang" in \ |
| 2203 |
rust) command -v cargo >/dev/null || { echo "rust was asked for and cargo is not in the image" >&2; exit 1; }; \ |
| 2204 |
for mod in wayland-client xkbcommon fontconfig; do \ |
| 2205 |
pkg-config --exists "$mod" \ |
| 2206 |
|| { echo "rust was asked for and $mod has no pkgconfig file; shop cannot be rebuilt on this image" >&2; exit 1; }; \ |
| 2207 |
done ;; \ |
| 2208 |
c) for t in gcc g++ make cmake meson; do \ |
| 2209 |
command -v "$t" >/dev/null \ |
| 2210 |
|| { echo "c was asked for and $t is not in the image" >&2; exit 1; }; \ |
| 2211 |
done; \ |
| 2212 |
command -v ninja >/dev/null || command -v ninja-build >/dev/null \ |
| 2213 |
|| { echo "c was asked for and ninja is not in the image" >&2; exit 1; } ;; \ |
| 2214 |
go) command -v go >/dev/null || { echo "go was asked for and the go binary is not in the image" >&2; exit 1; } ;; \ |
| 2215 |
python) command -v python3 >/dev/null || { echo "python was asked for and python3 is not in the image" >&2; exit 1; } ;; \ |
| 2216 |
zig) command -v zig >/dev/null || { echo "zig was asked for and the zig binary is not in the image" >&2; exit 1; } ;; \ |
| 2217 |
js) command -v node >/dev/null || { echo "js was asked for and node is not in the image" >&2; exit 1; }; \ |
| 2218 |
command -v npm >/dev/null || { echo "js was asked for and npm is not in the image" >&2; exit 1; } ;; \ |
| 2219 |
esac; \ |
| 2220 |
done; \ |
| 2221 |
echo "languages: ${LANGS:-none}" |
| 2222 |
|
| 2223 |
# ===================================================================== |
| 2224 |
# The database, from the DB choice. |
| 2225 |
# ===================================================================== |
| 2226 |
# `ARG DB` above argues the version: this installs postgresql16 rather than |
| 2227 |
# Fedora's default postgresql-server, because the scratch cluster Sando runs |
| 2228 |
# has to be the major production runs. The packages, the weight and the |
| 2229 |
# measurement date are on the ARG. |
| 2230 |
# |
| 2231 |
# Binaries only. Nothing here enables a unit or runs initdb, so a mint with |
| 2232 |
# DB=postgres16 boots with psql and postgres on PATH and no cluster. Creating |
| 2233 |
# one is the machine's business. |
| 2234 |
# |
| 2235 |
# Asserted on both arms, the way the language block above is: `none` has to |
| 2236 |
# stay empty, or the dial is decorative and an image is carrying a database |
| 2237 |
# nobody asked for. |
| 2238 |
RUN set -eu; \ |
| 2239 |
case "$DB" in \ |
| 2240 |
postgres16) \ |
| 2241 |
dnf install -y --setopt=install_weak_deps=False \ |
| 2242 |
postgresql16 postgresql16-server postgresql16-contrib; \ |
| 2243 |
dnf clean all; \ |
| 2244 |
command -v psql >/dev/null \ |
| 2245 |
|| { echo "DB=postgres16 was asked for and psql is not in the image" >&2; exit 1; }; \ |
| 2246 |
command -v postgres >/dev/null \ |
| 2247 |
|| { echo "DB=postgres16 was asked for and postgres is not in the image" >&2; exit 1; }; \ |
| 2248 |
psql --version | grep -q " 16\." \ |
| 2249 |
|| { echo "DB=postgres16 installed $(psql --version), which is not the major production runs" >&2; exit 1; }; \ |
| 2250 |
# The extensions a production dump declares, without which a restore |
| 2251 |
# stops at the first CREATE EXTENSION and Sando's migration_check and |
| 2252 |
# cargo_test gates cannot run at all. Measured 2026-09-04 against |
| 2253 |
# /srv/sando/backups: the server dump needs pgcrypto and pg_trgm, the |
| 2254 |
# multithreaded one pg_trgm. They ship in postgresql16-contrib, and the |
| 2255 |
# unversioned postgresql-contrib is 18.6 on this base, which is the |
| 2256 |
# wrong major and the trap this asserts against. |
| 2257 |
for ext in pgcrypto pg_trgm; do \ |
| 2258 |
find / -name "$ext.control" -path "*/extension/*" 2>/dev/null | grep -q . \ |
| 2259 |
|| { echo "DB=postgres16 was asked for and $ext.control is not in the image; a prod dump would fail to restore" >&2; exit 1; }; \ |
| 2260 |
done; \ |
| 2261 |
# The RPM's %post leaves a regular file in /var, and /var is per-machine |
| 2262 |
# state on a bootc system: `bootc container lint` fails the build on a |
| 2263 |
# non-directory there, because tmpfiles.d has no type that describes an |
| 2264 |
# existing file's contents and so nothing can declare it. Moved under |
| 2265 |
# /usr, where image content belongs; 50-alloy-var-postgres.conf copies it |
| 2266 |
# back on first boot. Not deleted, because it exports PGDATA and `su - |
| 2267 |
# postgres` is how a scratch cluster gets restored into. |
| 2268 |
install -D -m 0644 -o root -g root \ |
| 2269 |
/var/lib/pgsql/.bash_profile /usr/share/alloy/pgsql/bash_profile; \ |
| 2270 |
rm -f /var/lib/pgsql/.bash_profile; \ |
| 2271 |
grep -q '^export PGDATA$' /usr/share/alloy/pgsql/bash_profile \ |
| 2272 |
|| { echo "postgres .bash_profile no longer exports PGDATA; the tmpfiles C line would restore a file that does nothing" >&2; exit 1; }; \ |
| 2273 |
[ ! -e /var/lib/pgsql/.bash_profile ] \ |
| 2274 |
|| { echo "postgres .bash_profile is still in /var; bootc lint will refuse the image" >&2; exit 1; } \ |
| 2275 |
;; \ |
| 2276 |
none) \ |
| 2277 |
! command -v psql >/dev/null \ |
| 2278 |
|| { echo "DB=none and psql is in the image anyway" >&2; exit 1; }; \ |
| 2279 |
! command -v postgres >/dev/null \ |
| 2280 |
|| { echo "DB=none and postgres is in the image anyway" >&2; exit 1; } \ |
| 2281 |
;; \ |
| 2282 |
*) echo "unknown DB '$DB' reached the install arm; the validator did not run" >&2; exit 1 ;; \ |
| 2283 |
esac; \ |
| 2284 |
echo "db: $DB" |
| 2285 |
|
| 2286 |
# ===================================================================== |
| 2287 |
# The desktop-app build set, from the GUI choice. |
| 2288 |
# |
| 2289 |
# `ARG GUI` above argues why this is a dial. This is the Fedora spelling of the |
| 2290 |
# list goingson's README gives for Debian: webkit2gtk4.1-devel is the one that |
| 2291 |
# matters and the rest are what cargo-tauri and linuxdeploy reach for. |
| 2292 |
# libxdo-devel is the Fedora name for Debian's libxdo-dev, and |
| 2293 |
# libappindicator-gtk3-devel for libayatana-appindicator3-dev. |
| 2294 |
# |
| 2295 |
# Weak deps are left ON here, unlike every other install in this file. A -devel |
| 2296 |
# package's recommends are the headers and pkgconfig files its own dependents |
| 2297 |
# need, and dropping them produces a set that installs cleanly and then fails at |
| 2298 |
# `pkg-config --exists` for a library that is on disk. The cost is measured |
| 2299 |
# rather than assumed by the assertion below, which is the real gate: pkg-config |
| 2300 |
# has to find webkit2gtk-4.1, because that is what the Tauri build actually asks. |
| 2301 |
# |
| 2302 |
# No toolchain here. Rust arrives through LANGS and the Tauri CLI is a cargo |
| 2303 |
# install into $HOME, which is machine state; this arm is the system libraries |
| 2304 |
# and stops, the same way the database arm puts binaries on PATH and stops. |
| 2305 |
RUN set -eu; \ |
| 2306 |
case "$GUI" in \ |
| 2307 |
tauri) \ |
| 2308 |
dnf install -y \ |
| 2309 |
webkit2gtk4.1-devel gtk3-devel libsoup3-devel openssl-devel \ |
| 2310 |
librsvg2-devel libappindicator-gtk3-devel libxdo-devel; \ |
| 2311 |
dnf clean all; \ |
| 2312 |
for mod in webkit2gtk-4.1 gtk+-3.0 libsoup-3.0 openssl librsvg-2.0; do \ |
| 2313 |
pkg-config --exists "$mod" \ |
| 2314 |
|| { echo "GUI=tauri was asked for and pkg-config cannot find $mod; a Tauri build would fail at configure time" >&2; exit 1; }; \ |
| 2315 |
done; \ |
| 2316 |
echo "gui: tauri, webkit2gtk-4.1 $(pkg-config --modversion webkit2gtk-4.1)" \ |
| 2317 |
;; \ |
| 2318 |
none) \ |
| 2319 |
! pkg-config --exists webkit2gtk-4.1 2>/dev/null \ |
| 2320 |
|| { echo "GUI=none and the webkit development set is in the image anyway" >&2; exit 1; }; \ |
| 2321 |
echo "gui: none" \ |
| 2322 |
;; \ |
| 2323 |
*) echo "unknown GUI '$GUI' reached the install arm; the validator did not run" >&2; exit 1 ;; \ |
| 2324 |
esac |
| 2325 |
|
| 2326 |
# ===================================================================== |
| 2327 |
# Printing — driverless only, and deliberately in its own layer. |
| 2328 |
# |
| 2329 |
# Alloy prints to IPP Everywhere devices and nothing else. That is the |
| 2330 |
# whole position: every printer sold since roughly 2015 speaks it, the |
| 2331 |
# printer advertises its own capabilities, and there is no driver to |
| 2332 |
# pick, install or match. A printer that needs a vendor driver is not |
| 2333 |
# supported, stated plainly rather than left to be discovered. |
| 2334 |
# |
| 2335 |
# What that buys is the absence of the usual printing stack: |
| 2336 |
# foomatic-db is thousands of PPDs, gutenprint and hplip are vendor |
| 2337 |
# driver estates, and none of them is reachable from a driverless-only |
| 2338 |
# position. Verified against the resolved transaction rather than |
| 2339 |
# assumed — none of the three appears. ghostscript does, as a hard |
| 2340 |
# dependency of cups-filters for rasterizing, and is accepted. |
| 2341 |
# |
| 2342 |
# The prerequisite was already in place, which is what makes this cheap: |
| 2343 |
# discovery is mDNS, and nss-mdns is installed with /etc/nsswitch.conf |
| 2344 |
# already resolving .local (see the avahi block). So a driverless |
| 2345 |
# printer appears by itself and most users never configure anything. |
| 2346 |
# |
| 2347 |
# Weak dependencies off. This was a separate `dnf install` because the |
| 2348 |
# client block above did not pass the flag, so folding these lines in |
| 2349 |
# would have taken the larger set silently: on the default |
| 2350 |
# `install_weak_deps=True` this transaction is 52 packages instead of |
| 2351 |
# 33, and the extra 19 are recommends nothing here asked for. That gap |
| 2352 |
# closed on 2026-08-18 and the client block passes the flag too, so the |
| 2353 |
# split is no longer load-bearing for this reason. It stays because the |
| 2354 |
# assertions below are printing's and belong beside printing's install, |
| 2355 |
# and because a layer per subsystem is what the rest of this file does. |
| 2356 |
# |
| 2357 |
# No scanning. sane-backends is per-device backends and USB permission |
| 2358 |
# work, its failures are opaque, and a scanner is far rarer on a laptop |
| 2359 |
# than a printer. Recorded as a rejection in docs/STACK.md rather than |
| 2360 |
# left as silence, per principle 5. |
| 2361 |
# |
| 2362 |
# The surface is CUPS' own web UI on localhost:631. It needs no GTK and |
| 2363 |
# costs nothing when unused, which matters because socket activation |
| 2364 |
# means the daemon is not running until something connects. |
| 2365 |
# ===================================================================== |
| 2366 |
# Client-only, ruled by Max 2026-08-01. The driverless-only position above |
| 2367 |
# rests on a laptop with a person at it and a printer on the same subnet; |
| 2368 |
# neither reaches a headless box, and a spooler nothing prints to is surface |
| 2369 |
# with no user. The preset lines for cups.socket and cups.path go with it. |
| 2370 |
RUN if [ "$PROFILE" = client ]; then \ |
| 2371 |
dnf install -y --setopt=install_weak_deps=False \ |
| 2372 |
cups \ |
| 2373 |
cups-filters \ |
| 2374 |
&& dnf clean all; \ |
| 2375 |
for unwanted in foomatic-db gutenprint hplip; do \ |
| 2376 |
rpm -q "$unwanted" >/dev/null 2>&1 \ |
| 2377 |
&& { echo "$unwanted arrived with the printing stack; the driverless-only position is no longer true" >&2; exit 1; }; \ |
| 2378 |
done; \ |
| 2379 |
test -f /usr/lib/systemd/system/cups.socket \ |
| 2380 |
|| { echo "cups ships no socket unit; the preset would enable nothing and printing would need a running daemon" >&2; exit 1; }; \ |
| 2381 |
echo "printing: driverless only, no vendor driver packages"; \ |
| 2382 |
else \ |
| 2383 |
rpm -q cups >/dev/null 2>&1 \ |
| 2384 |
&& { echo "profile=server but cups is installed; something pulled in the print spooler" >&2; exit 1; }; \ |
| 2385 |
echo "printing: none on this profile"; \ |
| 2386 |
fi |
| 2387 |
|
| 2388 |
# SwayOSD ships its system unit where systemd does not look. |
| 2389 |
# |
| 2390 |
# Fedora's SwayOSD-0.3.2 installs swayosd-libinput-backend.service into |
| 2391 |
# /usr/lib64/systemd/system/. systemd's system unit search path is |
| 2392 |
# /usr/local/lib/systemd/system and /usr/lib/systemd/system, and on Fedora |
| 2393 |
# /usr/lib64 is a real directory rather than a symlink to /usr/lib, so the unit |
| 2394 |
# is invisible. `enable swayosd-libinput-backend.service` in |
| 2395 |
# etc/systemd/system-preset/50-alloy.preset therefore matched nothing and was |
| 2396 |
# ignored without a word — the same silent-no-op this project already documented |
| 2397 |
# for template units, arrived at by a different route. |
| 2398 |
# |
| 2399 |
# The visible symptom is the one the preset was added to fix on 2026-07-21: the |
| 2400 |
# sway config's `bindsym --release Caps_Lock` and Num_Lock overlays have nothing |
| 2401 |
# running behind them, because the libinput backend that watches modifier state |
| 2402 |
# is a system service and never started. |
| 2403 |
# |
| 2404 |
# Copied rather than symlinked so the unit survives the package moving it, and |
| 2405 |
# asserted first so that the day Fedora fixes the packaging this build fails |
| 2406 |
# loudly instead of silently installing a stale duplicate. |
| 2407 |
# |
| 2408 |
# Client-only because swayosd is: the package is what ships the misplaced |
| 2409 |
# unit, so on `server` there is nothing to relocate. The `else` still checks |
| 2410 |
# rather than skipping, which is what catches the unit arriving as somebody's |
| 2411 |
# dependency and being copied onto a machine with no compositor to drive it. |
| 2412 |
RUN if [ "$PROFILE" = client ]; then \ |
| 2413 |
test -f /usr/lib64/systemd/system/swayosd-libinput-backend.service \ |
| 2414 |
|| { echo "SwayOSD unit is no longer in /usr/lib64 — drop this workaround"; exit 1; }; \ |
| 2415 |
test ! -f /usr/lib/systemd/system/swayosd-libinput-backend.service \ |
| 2416 |
|| { echo "SwayOSD now ships the unit correctly — drop this workaround"; exit 1; }; \ |
| 2417 |
cp /usr/lib64/systemd/system/swayosd-libinput-backend.service \ |
| 2418 |
/usr/lib/systemd/system/swayosd-libinput-backend.service; \ |
| 2419 |
else \ |
| 2420 |
test ! -e /usr/lib64/systemd/system/swayosd-libinput-backend.service \ |
| 2421 |
|| { echo "profile=server but the SwayOSD unit is present; swayosd got installed" >&2; exit 1; }; \ |
| 2422 |
fi |
| 2423 |
|
| 2424 |
# The same packaging bug again, in udev this time, and it is why the brightness |
| 2425 |
# keys do nothing. |
| 2426 |
# |
| 2427 |
# Found on real hardware 2026-07-29: the volume Fn keys show an OSD and the |
| 2428 |
# brightness ones do nothing at all, silently. SwayOSD raises brightness by |
| 2429 |
# writing /sys/class/backlight/<dev>/brightness directly, which ships |
| 2430 |
# root-owned 0644. The package knows this and ships the rule that fixes it: |
| 2431 |
# |
| 2432 |
# ACTION=="add", SUBSYSTEM=="backlight", RUN+="/bin/chgrp video /sys/class/backlight/%k/brightness" |
| 2433 |
# ACTION=="add", SUBSYSTEM=="backlight", RUN+="/bin/chmod g+w /sys/class/backlight/%k/brightness" |
| 2434 |
# |
| 2435 |
# It ships it at /usr/lib64/udev/rules.d/99-swayosd.rules. udev reads |
| 2436 |
# /usr/lib/udev/rules.d, /run/udev/rules.d and /etc/udev/rules.d, and on this |
| 2437 |
# base /usr/lib64/udev is a real directory rather than a symlink, so the rule |
| 2438 |
# never fires and the permissions never change. Same defect as the unit above, |
| 2439 |
# same cause, different subsystem. |
| 2440 |
# |
| 2441 |
# Why it failed silently rather than logging: swayosd-server is started by |
| 2442 |
# `exec swayosd-server` from the sway config, so its stderr goes nowhere a |
| 2443 |
# journal can see it. A permission error on the sysfs write has no reader. |
| 2444 |
# |
| 2445 |
# Copied rather than symlinked, and asserted first, for the same reasons as the |
| 2446 |
# unit: the copy survives the package moving the file, and the day Fedora fixes |
| 2447 |
# the path this build fails loudly instead of installing a stale duplicate that |
| 2448 |
# quietly disagrees with the packaged one. |
| 2449 |
# |
| 2450 |
# This is half the fix. The rule chgrp's to `video`, and a group with no members |
| 2451 |
# grants nothing, so the installer puts the account in it — see the `useradd` |
| 2452 |
# stage in crates/alloy/src/install.rs. Either half alone leaves brightness |
| 2453 |
# broken, which is why the group membership is asserted further down rather than |
| 2454 |
# left to be discovered on a booted machine. |
| 2455 |
# |
| 2456 |
# Client-only for the same reason as the unit above, and with the same |
| 2457 |
# asserting else. A backlight is a screen, and a headless box has neither. |
| 2458 |
RUN set -eu; \ |
| 2459 |
packaged=/usr/lib64/udev/rules.d/99-swayosd.rules; \ |
| 2460 |
canon=/usr/lib/udev/rules.d/99-swayosd.rules; \ |
| 2461 |
if [ "$PROFILE" = client ]; then \ |
| 2462 |
set -x; \ |
| 2463 |
[ -f "$packaged" ] \ |
| 2464 |
|| { echo "SwayOSD no longer ships $packaged — re-check where the backlight rule went" >&2; exit 1; }; \ |
| 2465 |
[ ! -f "$canon" ] \ |
| 2466 |
|| { echo "SwayOSD now ships the udev rule on udev's search path — drop this workaround" >&2; exit 1; }; \ |
| 2467 |
grep -q 'SUBSYSTEM=="backlight"' "$packaged" \ |
| 2468 |
|| { echo "$packaged no longer matches the backlight subsystem; copying it would fix nothing" >&2; exit 1; }; \ |
| 2469 |
grep -q 'chgrp video' "$packaged" \ |
| 2470 |
|| { echo "$packaged no longer chgrp's to video; the installer's group grant is now the wrong group" >&2; exit 1; }; \ |
| 2471 |
mkdir -p /usr/lib/udev/rules.d; \ |
| 2472 |
cp "$packaged" "$canon"; \ |
| 2473 |
else \ |
| 2474 |
[ ! -e "$packaged" ] && [ ! -e "$canon" ] \ |
| 2475 |
|| { echo "profile=server but the SwayOSD backlight rule is present" >&2; exit 1; }; \ |
| 2476 |
fi |
| 2477 |
|
| 2478 |
# distrobox is pinned. v2 is a Go rewrite, at rc.3 as of 2026-06-29, and |
| 2479 |
# upstream's own announcement says v1 stays the production recommendation |
| 2480 |
# and that exported binaries and apps must be re-exported after |
| 2481 |
# upgrading. So the pin excludes v2, and nothing narrower. |
| 2482 |
# |
| 2483 |
# The v1 *line*, not a build. This read `distrobox-1.8.2.5*` and would have |
| 2484 |
# hard-failed the whole image build on an upstream event: Fedora's updates |
| 2485 |
# repo keeps only the newest build, and dnf5 `install` is fatal on an argument |
| 2486 |
# that matches nothing, so the day 1.8.3 lands the build stops with no local |
| 2487 |
# change. Moving off the 1.8 line wants a release note; moving within it does |
| 2488 |
# not, and should not break the build. |
| 2489 |
RUN dnf install -y 'distrobox-1.8*' \ |
| 2490 |
&& dnf clean all |
| 2491 |
|
| 2492 |
# ===================================================================== |
| 2493 |
# Package removals — stock desktop pieces Alloy replaces |
| 2494 |
# ===================================================================== |
| 2495 |
# fedora-bootc:43 is minimal and probably ships none of these, but keep |
| 2496 |
# the remove line for safety in case the base grows. || true swallows |
| 2497 |
# the "package not installed" error path. |
| 2498 |
RUN dnf remove -y \ |
| 2499 |
gdm \ |
| 2500 |
gnome-shell \ |
| 2501 |
gnome-session \ |
| 2502 |
# foot, a second terminal nothing in Alloy invokes. It arrives as a |
| 2503 |
# weak dependency of sway-config-upstream, whose config binds it as |
| 2504 |
# sway's default terminal; Alloy ships its own config and binds shop |
| 2505 |
# (docs/STACK.md#terminal), so nothing here ever launches it. Left in, |
| 2506 |
# it puts three of the eleven entries in the new launcher (Foot, Foot |
| 2507 |
# Client, Foot Server), which is how an unused package stops being |
| 2508 |
# merely 0.8 MB of dead weight. Recommends are not requirements, so |
| 2509 |
# this does not disturb sway-config-upstream itself. |
| 2510 |
# |
| 2511 |
# Since 2026-08-18 the client block turns weak deps off, so foot no |
| 2512 |
# longer arrives and this line removes nothing. Kept anyway: `|| true` |
| 2513 |
# already covers the not-installed path, it is the standing answer to |
| 2514 |
# the day somebody hard-requires it, and a build that reintroduced it |
| 2515 |
# would otherwise reintroduce the three launcher entries with it. |
| 2516 |
foot \ |
| 2517 |
|| true |
| 2518 |
|
| 2519 |
# The base's own dead weight, under TRIM. The list and its cost are argued at |
| 2520 |
# `ARG TRIM` above; this is only the removal. |
| 2521 |
# |
| 2522 |
# Both branches assert, the same way the PROFILE conditionals do. `unused` |
| 2523 |
# proves the packages are really gone rather than trusting a glob that may |
| 2524 |
# have matched nothing, and refuses to pass silently if it found none of them |
| 2525 |
# to remove: an empty match means the base composition moved and this list is |
| 2526 |
# describing a machine that no longer exists, which is the state a comment |
| 2527 |
# would sit in for a year. Firmware is checked on BOTH branches, because the |
| 2528 |
# thing that must stay true is that no trim ever reaches it. |
| 2529 |
RUN set -eu; \ |
| 2530 |
if [ "$TRIM" = unused ]; then \ |
| 2531 |
list="python3-botocore toolbox qemu-user-static*"; \ |
| 2532 |
gone="$(rpm -qa --qf '%{NAME}\n' $list)"; \ |
| 2533 |
[ -n "$gone" ] \ |
| 2534 |
|| { echo "TRIM=unused matched none of the packages it removes; the base changed and the list at ARG TRIM needs re-measuring" >&2; exit 1; }; \ |
| 2535 |
dnf remove -y $gone; \ |
| 2536 |
for pkg in python3-botocore toolbox; do \ |
| 2537 |
! rpm -q "$pkg" >/dev/null 2>&1 \ |
| 2538 |
|| { echo "$pkg survived the trim" >&2; exit 1; }; \ |
| 2539 |
done; \ |
| 2540 |
echo "trim: removed $(echo "$gone" | wc -l) base packages nothing in Alloy reaches"; \ |
| 2541 |
else \ |
| 2542 |
echo "trim: none, the base ships as it comes"; \ |
| 2543 |
fi; \ |
| 2544 |
for fw in linux-firmware nvidia-gpu-firmware; do \ |
| 2545 |
rpm -q "$fw" >/dev/null 2>&1 \ |
| 2546 |
|| { echo "$fw is not in this image; a medium built here cannot drive hardware nobody asked about at build time, and the trim must never reach firmware" >&2; exit 1; }; \ |
| 2547 |
done |
| 2548 |
|
| 2549 |
# The language and documentation macros, proved to have taken. They are set |
| 2550 |
# hundreds of lines above and bind only what is installed after them, so a |
| 2551 |
# silent no-op here looks exactly like success: the build passes, the image is |
| 2552 |
# a quarter of a gigabyte bigger than it should be, and nobody notices for a |
| 2553 |
# year. This runs after the last `dnf install` in the stage for that reason. |
| 2554 |
# |
| 2555 |
# The gate is `/usr/share/locale` against the baseline the macro layer |
| 2556 |
# recorded, plus a wide margin. Four packages alone grew it by 112 MB with the |
| 2557 |
# macros off and by 0.5 MB with them on, so 32 MiB of headroom is unreachable |
| 2558 |
# by a correct build and unmissable by a broken one. Reading the baseline at |
| 2559 |
# build time rather than hardcoding it keeps this honest when the FROM line |
| 2560 |
# moves and the base's own contribution with it. |
| 2561 |
# |
| 2562 |
# Both branches assert, the same way the trim above does. `keep` proves the |
| 2563 |
# macro file is really absent rather than trusting that the conditional took |
| 2564 |
# the branch it was asked for. |
| 2565 |
# |
| 2566 |
# Licences are checked on both branches, for the reason given at the macro |
| 2567 |
# layer: rpm treats %license separately from %doc today, and an upstream |
| 2568 |
# change that swept them together would be a licensing problem rather than a |
| 2569 |
# size one. |
| 2570 |
RUN set -eu; \ |
| 2571 |
baseline="$(cat /usr/lib/alloy/.locale-baseline)"; \ |
| 2572 |
rm -f /usr/lib/alloy/.locale-baseline; \ |
| 2573 |
now="$(du -sb /usr/share/locale | cut -f1)"; \ |
| 2574 |
grown=$(( now - baseline )); \ |
| 2575 |
[ -n "$(ls -A /usr/share/licenses 2>/dev/null)" ] \ |
| 2576 |
|| { echo "/usr/share/licenses is empty; %license was swept along with %doc and crates/alloy/credits.toml no longer describes what the image ships" >&2; exit 1; }; \ |
| 2577 |
if [ "$TRIM" = unused ]; then \ |
| 2578 |
[ "$grown" -le 33554432 ] \ |
| 2579 |
|| { echo "/usr/share/locale grew $((grown / 1048576)) MB over the base, so the language macro did not take. The likely cause is a dnf install that moved above the layer setting it." >&2; exit 1; }; \ |
| 2580 |
[ -f /etc/rpm/macros.image-language-conf ] \ |
| 2581 |
|| { echo "the language macro file is gone; something below the macro layer removed it" >&2; exit 1; }; \ |
| 2582 |
grep -q '^tsflags=nodocs$' /etc/dnf/dnf.conf \ |
| 2583 |
|| { echo "tsflags=nodocs is no longer in /etc/dnf/dnf.conf; documentation was installed from wherever it was dropped onward" >&2; exit 1; }; \ |
| 2584 |
else \ |
| 2585 |
[ ! -f /etc/rpm/macros.image-language-conf ] \ |
| 2586 |
|| { echo "TRIM=keep but the language macro is set; the macro layer ran the wrong branch" >&2; exit 1; }; \ |
| 2587 |
fi; \ |
| 2588 |
echo "langs: /usr/share/locale grew $((grown / 1048576)) MB over the base" |
| 2589 |
|
| 2590 |
# ===================================================================== |
| 2591 |
# Register nushell as a legitimate login shell. |
| 2592 |
# ===================================================================== |
| 2593 |
# The installer sets new accounts to /usr/bin/nu (crates/alloy/src/ |
| 2594 |
# install.rs, LOGIN_SHELL), which is the shell etc/skel/.config/nushell/ |
| 2595 |
# is written for. Fedora's nushell package does not add itself to |
| 2596 |
# /etc/shells, and an unlisted shell is a second-class one: chsh refuses |
| 2597 |
# it, so the account's owner cannot switch away from or back to it, and |
| 2598 |
# anything consulting /etc/shells treats the account as restricted. |
| 2599 |
# |
| 2600 |
# The installer reads this file and refuses to create an account if the |
| 2601 |
# shell is missing from it, so this line is load-bearing rather than |
| 2602 |
# tidy: without it every install stops with "not listed in the target's |
| 2603 |
# /etc/shells". bash stays at /bin/sh and /bin/bash for scripts. |
| 2604 |
RUN echo /usr/bin/nu >> /etc/shells |
| 2605 |
|
| 2606 |
# ===================================================================== |
| 2607 |
# Shell integrations, generated once into nushell's system autoload dir. |
| 2608 |
# ===================================================================== |
| 2609 |
# nushell sources every .nu in its autoload directories at startup, and |
| 2610 |
# /usr/share/nushell/vendor/autoload is the first one it looks in. Files |
| 2611 |
# put here are picked up by every account with no per-user setup, which |
| 2612 |
# is what an image with a read-only /usr wants. |
| 2613 |
# |
| 2614 |
# Generated at build time rather than on first shell start, for two |
| 2615 |
# reasons that only showed up when this was tried the other way round. |
| 2616 |
# `source` is a nushell *parse-time* keyword: it takes a path known while |
| 2617 |
# the file is being parsed, so `let cache = ...; source $cache` fails with |
| 2618 |
# "Value is not a parse-time constant" and, because the failure is a parse |
| 2619 |
# error, nushell discards the whole config file. The account then gets |
| 2620 |
# stock nushell: no theme, no vi mode, no history settings, no aliases, |
| 2621 |
# and the banner that config.nu turns off. The second reason is cost: |
| 2622 |
# each init spawns a process to re-render output that cannot change |
| 2623 |
# between shells on a read-only /usr. |
| 2624 |
# |
| 2625 |
# Load order, measured on nushell 0.112.2 rather than assumed: env.nu, |
| 2626 |
# then config.nu, then the vendor autoload dirs, then the user's. The |
| 2627 |
# autoload pass running *after* config.nu is load-bearing for the |
| 2628 |
# hand-authored direnv.nu that the config tree below drops into this same |
| 2629 |
# directory. config.nu assigns `$env.config` wholesale, hooks included, so |
| 2630 |
# a hook registered any earlier would be discarded before it ever ran. |
| 2631 |
# |
| 2632 |
# Guarded on the binaries so this step does not fail the build if either |
| 2633 |
# package leaves the image; nushell simply autoloads whatever is here. |
| 2634 |
RUN mkdir -p /usr/share/nushell/vendor/autoload \ |
| 2635 |
&& if command -v starship >/dev/null; then \ |
| 2636 |
starship init nu > /usr/share/nushell/vendor/autoload/starship.nu; \ |
| 2637 |
fi \ |
| 2638 |
&& if command -v zoxide >/dev/null; then \ |
| 2639 |
zoxide init nushell > /usr/share/nushell/vendor/autoload/zoxide.nu; \ |
| 2640 |
fi |
| 2641 |
|
| 2642 |
# ===================================================================== |
| 2643 |
# Config tree — the etc/ and usr/ trees in the repo map 1:1 into |
| 2644 |
# the image. Per-user defaults live under etc/skel/.config/; |
| 2645 |
# system-wide config under etc/. See docs/IMAGE.md for the layout. |
| 2646 |
# |
| 2647 |
# Two sources, in this order. The repo tree is everything whose content is |
| 2648 |
# fixed; the rendered tree is everything that carries a color, which is not in |
| 2649 |
# the repo at all — templates/ holds those files with their palette left as |
| 2650 |
# tokens. The generated tree mirrors `/` the same way, so it lands by the same |
| 2651 |
# 1:1 rule and simply completes the tree rather than patching it: no file |
| 2652 |
# appears in both, and a stale copy of a themed file cannot shadow its |
| 2653 |
# generated version because there is no copy to go stale. "No file appears in |
| 2654 |
# both" is checked in the rust-build stage rather than assumed here, since this |
| 2655 |
# is the stage where a collision would resolve quietly in the render's favor. |
| 2656 |
# ===================================================================== |
| 2657 |
COPY etc/ /etc/ |
| 2658 |
COPY usr/ /usr/ |
| 2659 |
COPY --from=rust-build /staged-skel/ / |
| 2660 |
|
| 2661 |
# The backdrop's loop. A COPY into /usr/bin, which the shop stage above calls |
| 2662 |
# the worst of the shapes for anything hotfixable -- an unowned file cannot even |
| 2663 |
# be layered over. It is the right shape here anyway, and for the same reason |
| 2664 |
# the sibling helpers beside it arrive by `COPY usr/ /usr/`: the backdrop must |
| 2665 |
# draw on the first boot, before alloy-layer-components.service has laid the |
| 2666 |
# console down, because that is the boot where somebody most needs to be told |
| 2667 |
# which keys exist. Something that has to be present before layering cannot be |
| 2668 |
# delivered by layering. |
| 2669 |
COPY --from=rust-build /src/target/release/alloy-drift /usr/bin/alloy-drift |
| 2670 |
|
| 2671 |
# ===================================================================== |
| 2672 |
# dconf: the GTK schema defaults, compiled and proven to answer. |
| 2673 |
# ===================================================================== |
| 2674 |
# Alloy's configuration story is files in etc/skel a person can read and edit. |
| 2675 |
# A compiled binary database is a second kind of thing, and this is the whole |
| 2676 |
# of it: GTK/GNOME schema defaults have no file form a user could edit |
| 2677 |
# instead, so a machine-wide default has to be a dconf keyfile or it cannot |
| 2678 |
# exist. What it holds is argued in `etc/dconf/db/local.d/`; wiki |
| 2679 |
# `alloy-packaging-policy` says what may go in and what may not. |
| 2680 |
# |
| 2681 |
# The profile puts `user-db:user` above `system-db:local`, so a person who |
| 2682 |
# sets a key the other way wins and stays winning. That is why a system |
| 2683 |
# default is the correct shape here and why writing into the user's own dconf |
| 2684 |
# from `alloy-session` was refused: this can be overridden, that would |
| 2685 |
# override. |
| 2686 |
# |
| 2687 |
# THE ASSERTION IS THE POINT. `dconf update` exits 0 when it compiles nothing, |
| 2688 |
# so a keyfile in the wrong directory, a bad group name, or a missing profile |
| 2689 |
# all fail silently and leave a defaults mechanism that quietly stopped |
| 2690 |
# applying. So the check is a real read through the profile rather than a test |
| 2691 |
# for the file: `dconf read` consults the databases directly and needs no |
| 2692 |
# session bus, which is what makes it available at build time. |
| 2693 |
RUN set -eu; \ |
| 2694 |
if [ "$PROFILE" = client ]; then \ |
| 2695 |
command -v dconf >/dev/null \ |
| 2696 |
|| { echo "dconf is not in the image; /etc/dconf/db/local cannot be compiled and the GTK defaults would be inert" >&2; exit 1; }; \ |
| 2697 |
test -f /etc/dconf/profile/user \ |
| 2698 |
|| { echo "/etc/dconf/profile/user did not land; with no profile dconf reads the user database alone and db/local is never consulted" >&2; exit 1; }; \ |
| 2699 |
dconf update; \ |
| 2700 |
test -s /etc/dconf/db/local \ |
| 2701 |
|| { echo "dconf update compiled nothing; db/local.d is empty or unreadable" >&2; exit 1; }; \ |
| 2702 |
for key in /org/gtk/settings/file-chooser/show-hidden /org/gtk/gtk4/settings/file-chooser/show-hidden; do \ |
| 2703 |
[ "$(dconf read "$key")" = true ] \ |
| 2704 |
|| { echo "$key does not read true out of the compiled database; the GTK file chooser would hide dotfiles" >&2; exit 1; }; \ |
| 2705 |
done; \ |
| 2706 |
echo "dconf: $(ls /etc/dconf/db/local.d | wc -l) keyfile(s) compiled, GTK3 and GTK4 file choosers show dotfiles"; \ |
| 2707 |
else \ |
| 2708 |
echo "dconf: keyfiles ride along inert on a profile with no session"; \ |
| 2709 |
fi |
| 2710 |
|
| 2711 |
# ===================================================================== |
| 2712 |
# What has to reach a machine that UPGRADED into this image, not only |
| 2713 |
# one installed from it: the /var declarations and the greeter account. |
| 2714 |
# ===================================================================== |
| 2715 |
# Both halves are here for one reason. /var and /etc are per-machine on a bootc |
| 2716 |
# system: `bootc install` copies the image's copies into the stateroot, and an |
| 2717 |
# upgrade re-syncs /usr and leaves both alone. So anything that exists only |
| 2718 |
# because a package's %post or a `useradd` ran during a build lands on fresh |
| 2719 |
# installs and on nothing else. systemd-tmpfiles and systemd-sysusers run at |
| 2720 |
# every boot, and a declaration each is what closes the gap. `bootc container |
| 2721 |
# lint` is what names the two cases, under `var-tmpfiles` and `sysusers`, and |
| 2722 |
# since 2026-08-26 the lint at the bottom of this file is fatal — so a package |
| 2723 |
# added later that drops a new /var directory or a new account stops the build |
| 2724 |
# here rather than warning into a log nobody reads. |
| 2725 |
# |
| 2726 |
# The account is created here rather than up beside the greetd install because |
| 2727 |
# its definition arrives with `COPY usr/ /usr/` just above, and nothing between |
| 2728 |
# the two needs it. It was a bare `useradd` until 2026-08-26; the sysusers file |
| 2729 |
# is now the single definition, and this runs it. |
| 2730 |
# |
| 2731 |
# The client half is a separate file because it cannot merely be inert on |
| 2732 |
# `server`: the three greetd lines name a user that arrives with a package this |
| 2733 |
# profile does not install, and systemd-tmpfiles fails a line whose user does not |
| 2734 |
# resolve. So it is deleted rather than shipped and ignored, and the `else` |
| 2735 |
# proves the deletion happened. |
| 2736 |
# |
| 2737 |
# --dry-run --create on each file is the check that matters, and it is the same |
| 2738 |
# one the flatpak rule gets further down: a tmpfiles line with a typo in it is |
| 2739 |
# accepted by every build step except the boot it silently does nothing on. |
| 2740 |
# |
| 2741 |
# THE GEOCLUE HALF KEYS ON THE IMAGE, not on a dial, and that is the whole point |
| 2742 |
# of it being separate. geoclue arrives with the GTK and WebKit stack, so it is |
| 2743 |
# on every client and also on a server that sets GUI=tauri; its line was in the |
| 2744 |
# client file, which a server deletes whole, and the first astra mint failed |
| 2745 |
# bootc's var-tmpfiles lint on exactly that path. Keying the fix on `$PROFILE` |
| 2746 |
# and `$GUI` would answer today's two reasons and be wrong about the third, so |
| 2747 |
# the condition asks the image whether the account exists. A declaration naming a |
| 2748 |
# user that is not there fails at boot, and one missing for a user that is there |
| 2749 |
# fails the lint, so both directions are asserted rather than one. |
| 2750 |
RUN set -eux; \ |
| 2751 |
greeter=/usr/lib/sysusers.d/50-alloy-greeter.conf; \ |
| 2752 |
common=/usr/lib/tmpfiles.d/50-alloy-var.conf; \ |
| 2753 |
client=/usr/lib/tmpfiles.d/50-alloy-var-client.conf; \ |
| 2754 |
[ -f "$common" ] || { echo "$common did not land; every /var directory in this image is undeclared" >&2; exit 1; }; \ |
| 2755 |
systemd-tmpfiles --dry-run --create "$common" >/dev/null \ |
| 2756 |
|| { echo "systemd-tmpfiles rejects $common" >&2; exit 1; }; \ |
| 2757 |
if [ "$PROFILE" = client ]; then \ |
| 2758 |
[ -f "$client" ] || { echo "$client did not land" >&2; exit 1; }; \ |
| 2759 |
systemd-tmpfiles --dry-run --create "$client" >/dev/null \ |
| 2760 |
|| { echo "systemd-tmpfiles rejects $client" >&2; exit 1; }; \ |
| 2761 |
grep -q '^L .*/xdg-desktop-portal\.service .*/dev/null$' "$client" \ |
| 2762 |
|| { echo "$client no longer masks the greeter's xdg-desktop-portal; greetd's %post is the only thing writing that symlink and it runs on the build host" >&2; exit 1; }; \ |
| 2763 |
[ -f "$greeter" ] || { echo "$greeter did not land; the greeter account has no definition" >&2; exit 1; }; \ |
| 2764 |
systemd-sysusers "$greeter"; \ |
| 2765 |
getent passwd greeter >/dev/null \ |
| 2766 |
|| { echo "systemd-sysusers accepted $greeter but created no greeter account; greetd would exit with 'configured default session user not found'" >&2; exit 1; }; \ |
| 2767 |
else \ |
| 2768 |
rm -f "$client" "$greeter"; \ |
| 2769 |
[ ! -e "$client" ] && [ ! -e "$greeter" ] \ |
| 2770 |
|| { echo "profile=server still carries the client /var declarations or the greeter account definition, neither of which has packages here" >&2; exit 1; }; \ |
| 2771 |
getent passwd greeter >/dev/null \ |
| 2772 |
&& { echo "profile=server has a greeter account but no greeter" >&2; exit 1; }; \ |
| 2773 |
echo "greeter: no account on this profile"; \ |
| 2774 |
fi; \ |
| 2775 |
geoclue=/usr/lib/tmpfiles.d/50-alloy-var-geoclue.conf; \ |
| 2776 |
if getent passwd geoclue >/dev/null; then \ |
| 2777 |
[ -f "$geoclue" ] || { echo "$geoclue did not land and this image has geoclue; /var/lib/geoclue would be undeclared and bootc lint refuses that" >&2; exit 1; }; \ |
| 2778 |
systemd-tmpfiles --dry-run --create "$geoclue" >/dev/null \ |
| 2779 |
|| { echo "systemd-tmpfiles rejects $geoclue" >&2; exit 1; }; \ |
| 2780 |
echo "var: geoclue is installed, so its declaration ships"; \ |
| 2781 |
else \ |
| 2782 |
rm -f "$geoclue"; \ |
| 2783 |
[ ! -e "$geoclue" ] \ |
| 2784 |
|| { echo "this image has no geoclue account and still carries its /var declaration, whose user systemd-tmpfiles cannot resolve at boot" >&2; exit 1; }; \ |
| 2785 |
echo "var: no geoclue on this image, so no declaration for it"; \ |
| 2786 |
fi; \ |
| 2787 |
echo "var: tmpfiles declarations present and accepted for profile=$PROFILE" |
| 2788 |
|
| 2789 |
# The database's /var, on the same rule as the client half above. |
| 2790 |
# |
| 2791 |
# Separate from that block because it keys on $DB rather than $PROFILE: fw13 is |
| 2792 |
# a client with a database and astra is a server with one, so the two dials do |
| 2793 |
# not nest. Same shape otherwise — the file is deleted rather than shipped inert |
| 2794 |
# on DB=none, because every line in it names the `postgres` user and |
| 2795 |
# systemd-tmpfiles fails a line whose user does not resolve. |
| 2796 |
# |
| 2797 |
# The --dry-run --create is the check that matters, for the reason the block |
| 2798 |
# above gives: a typo in a tmpfiles line is accepted everywhere except the boot |
| 2799 |
# it silently does nothing on. Here it also proves the `postgres` user actually |
| 2800 |
# resolves in this image, which is the half that would otherwise only fail on a |
| 2801 |
# machine. |
| 2802 |
RUN set -eux; \ |
| 2803 |
pg=/usr/lib/tmpfiles.d/50-alloy-var-postgres.conf; \ |
| 2804 |
if [ "$DB" = none ]; then \ |
| 2805 |
rm -f "$pg"; \ |
| 2806 |
[ ! -e "$pg" ] \ |
| 2807 |
|| { echo "DB=none still carries the postgres /var declarations, whose user this image has no packages to create" >&2; exit 1; }; \ |
| 2808 |
echo "var: no database declarations on DB=none"; \ |
| 2809 |
else \ |
| 2810 |
[ -f "$pg" ] || { echo "$pg did not land; postgres /var would be undeclared and bootc lint refuses that" >&2; exit 1; }; \ |
| 2811 |
getent passwd postgres >/dev/null \ |
| 2812 |
|| { echo "$pg names the postgres user and this image has no such account" >&2; exit 1; }; \ |
| 2813 |
systemd-tmpfiles --dry-run --create "$pg" >/dev/null \ |
| 2814 |
|| { echo "systemd-tmpfiles rejects $pg" >&2; exit 1; }; \ |
| 2815 |
test -f /usr/share/alloy/pgsql/bash_profile \ |
| 2816 |
|| { echo "$pg copies a bash_profile out of /usr and it is not there" >&2; exit 1; }; \ |
| 2817 |
echo "var: postgres declarations present and accepted for db=$DB"; \ |
| 2818 |
fi |
| 2819 |
|
| 2820 |
# ===================================================================== |
| 2821 |
# The Firefox configuration is what pays for picking Firefox. |
| 2822 |
# ===================================================================== |
| 2823 |
# Alloy picks a browser and defends it (wiki `alloy-byo-principle`), and a |
| 2824 |
# default has to earn that under rule 3 of `alloy-packaging-policy`. Stock |
| 2825 |
# Firefox does not earn it: telemetry, sponsored placements and tracking |
| 2826 |
# protection at "standard" all serve someone other than the person running |
| 2827 |
# it. /etc/firefox/pref/alloy.js is the payment, and without it this image |
| 2828 |
# ships the exact thing docs/STACK.md calls indefensible. |
| 2829 |
# |
| 2830 |
# Fedora's firefox rpm owns /etc/firefox/pref and /etc is writable on a bootc |
| 2831 |
# deployment, so this is one file and no write to /usr. |
| 2832 |
# |
| 2833 |
# THE THIRD CHECK IS THE INTERESTING ONE. The guard on that file is that it |
| 2834 |
# only removes anti-features and restores hidden controls, never sets Alloy's |
| 2835 |
# taste. `browser.uidensity` is the named example of the taste it may not |
| 2836 |
# set, and `browser.compactmode.show` is the honest version of the same |
| 2837 |
# want: put the control back and let the user choose. A guard nobody can |
| 2838 |
# enforce is a comment, so it is asserted here instead. If this fails, read |
| 2839 |
# the ladder in the wiki note before deleting the check. |
| 2840 |
RUN set -eu; \ |
| 2841 |
conf=/etc/firefox/pref/alloy.js; \ |
| 2842 |
if [ "$PROFILE" = client ]; then \ |
| 2843 |
if [ "$BROWSER" = firefox ]; then \ |
| 2844 |
test -f "$conf" \ |
| 2845 |
|| { echo "browser=firefox and $conf is not in the image; the pick is unpaid for and this image ships stock Firefox" >&2; exit 1; }; \ |
| 2846 |
grep -q '^pref("browser.ml.enable", false);' "$conf" \ |
| 2847 |
|| { echo "$conf no longer turns off browser.ml.enable; the AI block was the substantive half of this file" >&2; exit 1; }; \ |
| 2848 |
grep -q '^pref("browser.compactmode.show", true);' "$conf" \ |
| 2849 |
|| { echo "$conf no longer restores the density control; rung 1 of the ladder is the cheapest thing this file does" >&2; exit 1; }; \ |
| 2850 |
echo "firefox: $(grep -c '^pref(' "$conf") prefs, anti-features and one restored control"; \ |
| 2851 |
else \ |
| 2852 |
rpm -q firefox >/dev/null 2>&1 \ |
| 2853 |
&& { echo "browser=none and firefox is installed anyway; something pulled in a browser nobody chose" >&2; exit 1; }; \ |
| 2854 |
echo "firefox: not in this image, by choice; the config file rides along inert"; \ |
| 2855 |
fi; \ |
| 2856 |
else \ |
| 2857 |
rpm -q firefox >/dev/null 2>&1 \ |
| 2858 |
&& { echo "profile=server carries firefox; the profile split leaked a browser onto an image with no session to run one in" >&2; exit 1; }; \ |
| 2859 |
echo "firefox: correctly absent on a profile with no session"; \ |
| 2860 |
fi; \ |
| 2861 |
if [ -f "$conf" ]; then \ |
| 2862 |
if grep -q '^pref("browser.uidensity"' "$conf"; then \ |
| 2863 |
echo "$conf sets browser.uidensity; that is rung 3, Alloy's taste in an app it configures only to remove anti-features. Restore the control, do not pick the value." >&2; exit 1; \ |
| 2864 |
fi; \ |
| 2865 |
if grep -qE '^[[:space:]]*(lockPref|defaultPref)\(' "$conf"; then \ |
| 2866 |
echo "$conf locks a pref; every line in it is meant to be a starting position the user can change, and a lock is Alloy hiding a control while objecting to hidden controls" >&2; exit 1; \ |
| 2867 |
fi; \ |
| 2868 |
echo "firefox config: no taste, no locks"; \ |
| 2869 |
fi |
| 2870 |
|
| 2871 |
# ===================================================================== |
| 2872 |
# The cursor theme skel names has to be the one the prune kept. |
| 2873 |
# ===================================================================== |
| 2874 |
# bibata-cursor-theme ships fourteen themes and the package block above |
| 2875 |
# deletes thirteen of them, 159 MB, keeping the one string spelled there. |
| 2876 |
# That prune runs 600 lines before skel exists, so it cannot read the name |
| 2877 |
# it is keeping; this is the other end of that, and it is the only thing |
| 2878 |
# standing between a rename and a session that starts with the X11 default |
| 2879 |
# cursor because its theme directory was thrown away at build time. |
| 2880 |
# |
| 2881 |
# Read from skel rather than compared against a constant. Five files declare |
| 2882 |
# the cursor and each has its own syntax, so a rename that reaches four of |
| 2883 |
# them and not the fifth is the realistic failure, and it is one this catches |
| 2884 |
# where a single hardcoded name would not. |
| 2885 |
# |
| 2886 |
# The second loop is the mirror: every theme still on disk must be one skel |
| 2887 |
# asked for. It fails if the prune silently stopped running, which is the |
| 2888 |
# case that costs 159 MB rather than a broken cursor, and which nothing else |
| 2889 |
# here would notice. |
| 2890 |
RUN set -eu; \ |
| 2891 |
if [ "$PROFILE" = client ]; then \ |
| 2892 |
names="$( { \ |
| 2893 |
sed -n 's/^[[:space:]]*Inherits[[:space:]]*=[[:space:]]*//p' \ |
| 2894 |
/etc/skel/.icons/default/index.theme; \ |
| 2895 |
sed -n 's/^[[:space:]]*gtk-cursor-theme-name[[:space:]]*=[[:space:]]*//p' \ |
| 2896 |
/etc/skel/.config/gtk-3.0/settings.ini \ |
| 2897 |
/etc/skel/.config/gtk-4.0/settings.ini; \ |
| 2898 |
sed -n 's/^[[:space:]]*seat[[:space:]]\+[^[:space:]]\+[[:space:]]\+xcursor_theme[[:space:]]\+\([^[:space:]]\+\).*/\1/p' \ |
| 2899 |
/etc/skel/.config/sway/config; \ |
| 2900 |
sed -n 's/^.*XCURSOR_THEME[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' \ |
| 2901 |
/etc/skel/.config/nushell/env.nu; \ |
| 2902 |
} | sed 's/[[:space:]]*$//' | sort -u )"; \ |
| 2903 |
[ -n "$names" ] \ |
| 2904 |
|| { echo "no cursor theme is declared anywhere in /etc/skel; the five declarations moved and the prune in the package block is now keeping a directory nothing asks for" >&2; exit 1; }; \ |
| 2905 |
for n in $names; do \ |
| 2906 |
test -d "/usr/share/icons/$n" \ |
| 2907 |
|| { echo "skel asks for cursor theme '$n' and /usr/share/icons/$n is not in the image; the prune in the package block kept a different name" >&2; exit 1; }; \ |
| 2908 |
done; \ |
| 2909 |
for theme in /usr/share/icons/Bibata-*; do \ |
| 2910 |
[ -d "$theme" ] || continue; \ |
| 2911 |
echo "$names" | grep -qx "$(basename "$theme")" \ |
| 2912 |
|| { echo "$(basename "$theme") survived and skel does not name it; the prune in the package block stopped running and the image is carrying ~12-27 MB per unused theme" >&2; exit 1; }; \ |
| 2913 |
done; \ |
| 2914 |
echo "cursor: $(echo "$names" | tr '\n' ' ')kept, every other Bibata theme pruned"; \ |
| 2915 |
else \ |
| 2916 |
# The asserting else, same rule as the package block: a check that runs |
| 2917 |
# on one profile and silently passes on the other is not a check. The |
| 2918 |
# server claim is the stronger one — bibata-cursor-theme is named only |
| 2919 |
# inside the client branch, so a server image should carry no Bibata |
| 2920 |
# directory at all. If one is here, the graphical group leaked across |
| 2921 |
# the profile split and 179 MB of cursors came with it, on an image with |
| 2922 |
# no session to draw a cursor in. |
| 2923 |
for theme in /usr/share/icons/Bibata-*; do \ |
| 2924 |
[ -d "$theme" ] || continue; \ |
| 2925 |
echo "$(basename "$theme") is in the server image; bibata-cursor-theme is named only in the client branch, so the profile split leaked" >&2; \ |
| 2926 |
exit 1; \ |
| 2927 |
done; \ |
| 2928 |
echo "cursor: none, as the server profile draws none"; \ |
| 2929 |
fi |
| 2930 |
|
| 2931 |
# ===================================================================== |
| 2932 |
# The default web handler has to name something the image contains. |
| 2933 |
# ===================================================================== |
| 2934 |
# /etc/xdg/mimeapps.list points http, https and text/html at alloy-open, |
| 2935 |
# which finds whatever browser is installed and, when there is none, says so |
| 2936 |
# rather than failing to stderr where nobody is looking. Three ways that can |
| 2937 |
# rot silently, so all three are checked. |
| 2938 |
# |
| 2939 |
# The vendor file is the cautionary tale this replaces: shared-mime-info ships |
| 2940 |
# /usr/share/applications/mimeapps.list naming `org.mozilla.firefox.desktop`, |
| 2941 |
# which this image has never contained under any BROWSER value. It resolved |
| 2942 |
# correctly anyway by falling through to mimeinfo.cache, where exactly one |
| 2943 |
# candidate declared the scheme. A default that is wrong and works because |
| 2944 |
# only one alternative exists is precisely what stops working the day the |
| 2945 |
# browser becomes the user's choice. |
| 2946 |
# |
| 2947 |
# NoDisplay is asserted rather than assumed: without it alloy-open.desktop is |
| 2948 |
# a browser candidate to alloy-open's own scan, and the script would exec |
| 2949 |
# itself forever behind a keybind. The script excludes itself by filename too, |
| 2950 |
# so this is the second of two guards rather than the only one. |
| 2951 |
RUN set -eu; \ |
| 2952 |
test -x /usr/bin/alloy-open \ |
| 2953 |
|| { echo "/etc/xdg/mimeapps.list points at alloy-open and it is not executable in the image" >&2; exit 1; }; \ |
| 2954 |
entry=/usr/share/applications/alloy-open.desktop; \ |
| 2955 |
test -f "$entry" \ |
| 2956 |
|| { echo "alloy-open has no desktop entry, so nothing can be registered against it" >&2; exit 1; }; \ |
| 2957 |
grep -q '^NoDisplay=true' "$entry" \ |
| 2958 |
|| { echo "alloy-open.desktop lacks NoDisplay=true; it would advertise itself as a browser to its own scan and to alloy-menu" >&2; exit 1; }; \ |
| 2959 |
for t in x-scheme-handler/http x-scheme-handler/https text/html; do \ |
| 2960 |
named="$(sed -n "s|^$t=||p" /etc/xdg/mimeapps.list | head -n 1)"; \ |
| 2961 |
[ -n "$named" ] \ |
| 2962 |
|| { echo "/etc/xdg/mimeapps.list has no default for $t" >&2; exit 1; }; \ |
| 2963 |
test -f "/usr/share/applications/$named" \ |
| 2964 |
|| { echo "/etc/xdg/mimeapps.list points $t at $named, which is not in the image" >&2; exit 1; }; \ |
| 2965 |
done; \ |
| 2966 |
echo "web handler: $(sed -n 's|^x-scheme-handler/https=||p' /etc/xdg/mimeapps.list | head -n 1), registered for http, https and text/html" |
| 2967 |
|
| 2968 |
# ===================================================================== |
| 2969 |
# The mesh sign-in helper — assert the client, the unit and the script agree. |
| 2970 |
# |
| 2971 |
# crates/alloy/src/mesh.rs runs `run0 alloy-mesh-up` rather than |
| 2972 |
# `run0 tailscale up`, because the preset below ships tailscaled disabled and a |
| 2973 |
# sign-in that starts no daemon spends the user's password before it fails. All |
| 2974 |
# three pieces have to be in the image for that to hold, and each one goes |
| 2975 |
# missing in a different way: the script by a COPY that stopped matching, the |
| 2976 |
# unit by a tailscale package that stopped shipping it, the client by the |
| 2977 |
# package being dropped from the client profile. |
| 2978 |
# |
| 2979 |
# Asserted here rather than trusted because the failure is invisible at build |
| 2980 |
# time and lands on a first boot, which is the one run where a user has no other |
| 2981 |
# way in and no reason to suspect the image. |
| 2982 |
RUN set -eu; \ |
| 2983 |
test -x /usr/bin/alloy-mesh-up \ |
| 2984 |
|| { echo "mesh.rs runs alloy-mesh-up under run0 and it is not executable in the image" >&2; exit 1; }; \ |
| 2985 |
command -v tailscale >/dev/null \ |
| 2986 |
|| { echo "alloy-mesh-up execs tailscale and the client is not in the image" >&2; exit 1; }; \ |
| 2987 |
unit=/usr/lib/systemd/system/tailscaled.service; \ |
| 2988 |
test -f "$unit" \ |
| 2989 |
|| { echo "alloy-mesh-up enables tailscaled.service and $unit is not in the image" >&2; exit 1; }; \ |
| 2990 |
grep -q 'systemctl enable --now tailscaled.service' /usr/bin/alloy-mesh-up \ |
| 2991 |
|| { echo "alloy-mesh-up no longer enables tailscaled; the sign-in would run against a stopped daemon" >&2; exit 1; }; \ |
| 2992 |
echo "mesh: alloy-mesh-up enables tailscaled.service, then signs in with $(tailscale version | head -n 1)" |
| 2993 |
|
| 2994 |
# ===================================================================== |
| 2995 |
# The installer's ssh door — assert both halves of the gate are intact. |
| 2996 |
# ===================================================================== |
| 2997 |
# `Match User installer` + `ForceCommand /usr/bin/alloy install` is a |
| 2998 |
# remote path to a program that writes disks. What keeps it off an |
| 2999 |
# installed machine is that the account does not exist there, and the |
| 3000 |
# account is created by alloy-installer-ssh.service under the same |
| 3001 |
# `alloy.installer` kernel flag alloy-installer.service uses. Neither |
| 3002 |
# file can state that on its own, so the agreement between them is |
| 3003 |
# checked here — the failure would otherwise be silent and remote. |
| 3004 |
# |
| 3005 |
# Three things, each of which has a way of going wrong quietly. |
| 3006 |
# |
| 3007 |
# 1. THE CONDITION. A unit that lost it would create the account on every |
| 3008 |
# install, which is the whole hazard rather than a degradation of it. |
| 3009 |
# |
| 3010 |
# 2. THE NAME. The sshd Match and the useradd have to agree, and nothing |
| 3011 |
# links them but the string. Disagreeing fails in the safe direction |
| 3012 |
# (nobody logs in) but breaks the flow with no diagnostic anywhere. |
| 3013 |
# |
| 3014 |
# 3. THE SCOPE TERMINATOR. sshd applies a Match to everything that |
| 3015 |
# follows it, and Include does not end that scope, so a drop-in whose |
| 3016 |
# Match is left open swallows the global policy of every file after |
| 3017 |
# it, this image's own `PasswordAuthentication no` included. The |
| 3018 |
# symptom is not a parse error; it is a machine whose ssh policy has |
| 3019 |
# quietly stopped applying to anyone but the installer. Two `Match` |
| 3020 |
# lines and `Match all` last is the shape that cannot do that. |
| 3021 |
# |
| 3022 |
# 4. THE PRIVILEGE. `alloy install` does not escalate, so the wizard runs |
| 3023 |
# under run0 and run0 asks polkit for manage-units. Three ways for |
| 3024 |
# that to break quietly, and the door is remote in all of them: the |
| 3025 |
# ForceCommand loses run0 and the wizard fails on the first |
| 3026 |
# privileged command (measured 2026-08-26, GO alloy 2cf04f20); the |
| 3027 |
# rules file names a different account than the one that logs in, and |
| 3028 |
# run0 asks a human who is not there; or the grant widens past the |
| 3029 |
# one action run0 needs. The first two look identical from outside — |
| 3030 |
# an install that stops — and the third looks like nothing at all. |
| 3031 |
# |
| 3032 |
# Comments stay out of the RUN below: a `#` line inside a line |
| 3033 |
# continuation is handled differently by different parsers, and one that |
| 3034 |
# reaches the shell ends the command at that point rather than being |
| 3035 |
# ignored, which would silently skip every check under it. |
| 3036 |
RUN set -eu; \ |
| 3037 |
conf=/etc/ssh/sshd_config.d/20-alloy-installer.conf; \ |
| 3038 |
unit=/etc/systemd/system/alloy-installer-ssh.service; \ |
| 3039 |
grep -q '^ConditionKernelCommandLine=alloy.installer$' "$unit" \ |
| 3040 |
|| { echo "$unit does not gate on alloy.installer, so the installer account would exist on installed machines" >&2; exit 1; }; \ |
| 3041 |
grep -q '^Match User installer$' "$conf" \ |
| 3042 |
|| { echo "$conf no longer matches the account $unit creates" >&2; exit 1; }; \ |
| 3043 |
grep -q 'useradd .*installer' "$unit" \ |
| 3044 |
|| { echo "$unit no longer creates the account $conf matches" >&2; exit 1; }; \ |
| 3045 |
[ "$(grep -c '^Match ' "$conf")" = 2 ] \ |
| 3046 |
|| { echo "$conf must hold exactly two Match lines: the block and its terminator" >&2; exit 1; }; \ |
| 3047 |
tail -n 1 "$conf" | grep -q '^Match all$' \ |
| 3048 |
|| { echo "$conf must end with 'Match all' or its block leaks into every later sshd drop-in" >&2; exit 1; }; \ |
| 3049 |
rules=/usr/share/polkit-1/rules.d/50-alloy-installer.rules; \ |
| 3050 |
grep -q '^ForceCommand run0 .*/usr/bin/alloy install$' "$conf" \ |
| 3051 |
|| { echo "$conf does not run the installer under run0; the wizard would land unprivileged and fail on the first command that writes a disk" >&2; exit 1; }; \ |
| 3052 |
test -f "$rules" \ |
| 3053 |
|| { echo "$rules is missing, so run0 would ask a human for a password on a headless install" >&2; exit 1; }; \ |
| 3054 |
grep -q '"installer"' "$rules" \ |
| 3055 |
|| { echo "$rules does not name the account $unit creates, so the grant reaches nobody" >&2; exit 1; }; \ |
| 3056 |
grep -q '"org.freedesktop.systemd1.manage-units"' "$rules" \ |
| 3057 |
|| { echo "$rules does not grant manage-units, which is the action run0 asks for" >&2; exit 1; }; \ |
| 3058 |
[ "$(grep -c 'polkit.Result' "$rules")" = 1 ] \ |
| 3059 |
|| { echo "$rules returns more than one polkit result; this grant is one action for one user and widening it is a decision, not an edit" >&2; exit 1; }; \ |
| 3060 |
echo "installer ssh: gated on alloy.installer, scope closed, run0 granted manage-units" |
| 3061 |
|
| 3062 |
# ===================================================================== |
| 3063 |
# The installer's mDNS unit, gated by the same flag as the two above. |
| 3064 |
# |
| 3065 |
# The headless flow is `ssh installer@<hostname>.local`, and the server |
| 3066 |
# profile's firewall zone does not allow mDNS on purpose, because that |
| 3067 |
# profile also runs on a public address. The live medium is where that |
| 3068 |
# reasoning inverts: it is on a LAN, it has joined no mesh, and it exists |
| 3069 |
# for the length of an install. |
| 3070 |
# |
| 3071 |
# Three ways this unit could ship and do nothing, all silent, and each is |
| 3072 |
# checked: the wrong gate (so it would run on installed machines, which is |
| 3073 |
# the direction that matters), `--permanent` (which would write the opening |
| 3074 |
# into /etc and hand it to the machine being installed), and an ordering |
| 3075 |
# that lets it run before firewalld is up, where firewall-cmd fails and the |
| 3076 |
# medium is unreachable by name for reasons nobody can see from outside. |
| 3077 |
# ===================================================================== |
| 3078 |
RUN set -eu; \ |
| 3079 |
unit=/etc/systemd/system/alloy-installer-firewall.service; \ |
| 3080 |
test -f "$unit" \ |
| 3081 |
|| { echo "$unit did not land; a server-profile medium would boot undiscoverable" >&2; exit 1; }; \ |
| 3082 |
grep -q '^ConditionKernelCommandLine=alloy.installer$' "$unit" \ |
| 3083 |
|| { echo "$unit does not gate on alloy.installer, so installed machines would open mDNS too" >&2; exit 1; }; \ |
| 3084 |
! grep '^ExecStart=' "$unit" | grep -q -- '--permanent' \ |
| 3085 |
|| { echo "$unit uses --permanent; the opening would survive into the installed system" >&2; exit 1; }; \ |
| 3086 |
grep -q '^After=firewalld.service$' "$unit" \ |
| 3087 |
|| { echo "$unit is not ordered after firewalld; firewall-cmd would fail with no daemon" >&2; exit 1; }; \ |
| 3088 |
echo "installer mdns: gated on alloy.installer, runtime only" |
| 3089 |
|
| 3090 |
# ===================================================================== |
| 3091 |
# The encrypted install path — assert bootc will actually permit it. |
| 3092 |
# |
| 3093 |
# `bootc install --block-setup tpm2-luks` is gated by the image's own |
| 3094 |
# install config, not by the hardware: with no `block` key bootc allows |
| 3095 |
# `direct` alone and refuses the flag with "tpm2-luks not enabled in |
| 3096 |
# installation config". That is exactly what happened, on a machine with |
| 3097 |
# a working TPM, because the config declared a root filesystem and |
| 3098 |
# nothing else. The installer offers encryption on step 4 and defaults |
| 3099 |
# it to on, so every user taking the default hit a dead path. |
| 3100 |
# |
| 3101 |
# Nothing failed at build time, which is why this check exists: the flag |
| 3102 |
# lives in crates/alloy/src/install.rs and the permission lives in a |
| 3103 |
# TOML file, and neither knows the other exists. The coupling is only |
| 3104 |
# observable at the moment a disk is being erased. |
| 3105 |
# |
| 3106 |
# The order check is not pedantry. bootc uses the FIRST entry as the |
| 3107 |
# default for an install that passes no --block-setup, and the installer |
| 3108 |
# omits the flag precisely when the user declined encryption. Put |
| 3109 |
# tpm2-luks first and declining encryption would hand back an encrypted |
| 3110 |
# disk whose passphrase nobody was asked for. |
| 3111 |
# ===================================================================== |
| 3112 |
RUN set -eu; \ |
| 3113 |
conf=/usr/lib/bootc/install/00-alloy.toml; \ |
| 3114 |
test -f "$conf" \ |
| 3115 |
|| { echo "$conf is missing; bootc would lose both the rootfs type and the block allowlist" >&2; exit 1; }; \ |
| 3116 |
block="$(sed -n 's/^[[:space:]]*block[[:space:]]*=[[:space:]]*//p' "$conf")"; \ |
| 3117 |
test -n "$block" \ |
| 3118 |
|| { echo "$conf declares no 'block' key, so bootc permits 'direct' only and the installer's encryption step cannot work" >&2; exit 1; }; \ |
| 3119 |
echo "$block" | grep -q 'tpm2-luks' \ |
| 3120 |
|| { echo "$conf does not enable tpm2-luks: $block" >&2; exit 1; }; \ |
| 3121 |
echo "$block" | grep -q '^\[[[:space:]]*"direct"' \ |
| 3122 |
|| { echo "$conf must list \"direct\" first; bootc takes the first entry as the default and an unencrypted install passes no --block-setup: $block" >&2; exit 1; }; \ |
| 3123 |
echo "bootc install config: block = $block" |
| 3124 |
|
| 3125 |
# ===================================================================== |
| 3126 |
# Fingerprint unlock — turn it on through authselect, not by hand. |
| 3127 |
# |
| 3128 |
# /etc/pam.d/system-auth is generated: on this base /etc/nsswitch.conf is |
| 3129 |
# already a symlink into authselect's tree, and hand-editing a PAM file |
| 3130 |
# authselect owns means the next `authselect apply-changes` silently |
| 3131 |
# discards it. The feature switch is the supported edit and the only one |
| 3132 |
# that survives. |
| 3133 |
# |
| 3134 |
# `sufficient` is what the feature installs, which is the property worth |
| 3135 |
# stating: a finger that does not match, a reader that is busy, or a user |
| 3136 |
# with nothing enrolled all fall through to the password prompt rather |
| 3137 |
# than locking anyone out. That matters here more than usual, because |
| 3138 |
# nothing enrolls a finger at install time — `fprintd-enroll` is a thing |
| 3139 |
# the user runs later, and until they do this changes nothing at all. |
| 3140 |
# |
| 3141 |
# Asserted rather than trusted, twice. authselect reports success for a |
| 3142 |
# feature it did not apply if the profile does not offer it, and the |
| 3143 |
# generated file is the only place the answer is visible. |
| 3144 |
# |
| 3145 |
# authselect prints "make sure fprintd service is configured and enabled" |
| 3146 |
# here. Nothing to do: fprintd is Type=dbus with BusName and a |
| 3147 |
# system-services activation file, so the first PAM call starts it. No |
| 3148 |
# preset line, for the same reason gnome-keyring does not get one. |
| 3149 |
# ===================================================================== |
| 3150 |
# |
| 3151 |
# Client-only. A fingerprint reader is a laptop part, the two consumers named |
| 3152 |
# above are the lock screen and the greeter, and neither exists on `server`. |
| 3153 |
# A headless machine authenticates with a key. |
| 3154 |
RUN set -eu; \ |
| 3155 |
if [ "$PROFILE" = client ]; then \ |
| 3156 |
authselect enable-feature with-fingerprint; \ |
| 3157 |
authselect apply-changes; \ |
| 3158 |
grep -q 'pam_fprintd\.so' /etc/pam.d/system-auth \ |
| 3159 |
|| { echo "with-fingerprint did not reach system-auth; the lock screen would never ask the reader" >&2; exit 1; }; \ |
| 3160 |
grep -q '^auth.*sufficient.*pam_fprintd\.so' /etc/pam.d/system-auth \ |
| 3161 |
|| { echo "pam_fprintd is in system-auth but not as 'sufficient'; a failed or absent finger would not fall through to the password" >&2; exit 1; }; \ |
| 3162 |
test -f /etc/pam.d/swaylock \ |
| 3163 |
|| { echo "swaylock ships no PAM file; the include chain this relies on is gone" >&2; exit 1; }; \ |
| 3164 |
echo "fingerprint: $(grep -c pam_fprintd /etc/pam.d/system-auth) pam_fprintd line(s) in system-auth"; \ |
| 3165 |
else \ |
| 3166 |
grep -q 'pam_fprintd\.so' /etc/pam.d/system-auth \ |
| 3167 |
&& { echo "profile=server but system-auth calls pam_fprintd; the feature was enabled anyway" >&2; exit 1; }; \ |
| 3168 |
echo "fingerprint: not on this profile"; \ |
| 3169 |
fi |
| 3170 |
|
| 3171 |
# ===================================================================== |
| 3172 |
# The session wrapper — assert it can actually run. |
| 3173 |
# |
| 3174 |
# etc/greetd/config.toml points --cmd at alloy-session rather than at |
| 3175 |
# sway, which puts this one script on the boot path: missing, |
| 3176 |
# non-executable, or carrying a bad shebang, and nobody can log into the |
| 3177 |
# machine at all, with the virtual console as the only recovery. Both |
| 3178 |
# failures are silent at build time and total at runtime, so they are |
| 3179 |
# checked here for the same reason the polkit and setvtrgb steps check |
| 3180 |
# theirs. |
| 3181 |
# |
| 3182 |
# `sh -n` parses without running, which is the whole check worth making: |
| 3183 |
# the script's job is one alloy call and one exec, and a syntax error is |
| 3184 |
# the only way that fails before it has a chance to matter. |
| 3185 |
# ===================================================================== |
| 3186 |
# |
| 3187 |
# The wrapper and the greetd config both arrive from the repo's `usr/` and |
| 3188 |
# `etc/` trees, so on `server` they would be present and asserted correct |
| 3189 |
# while nothing could ever run them. That is worse than absent: a machine |
| 3190 |
# carrying a login path it cannot take. So `server` removes both and proves |
| 3191 |
# they are gone, which is a check rather than a skip. |
| 3192 |
RUN set -eu; \ |
| 3193 |
if [ "$PROFILE" = client ]; then \ |
| 3194 |
test -x /usr/bin/alloy-session \ |
| 3195 |
|| { echo "alloy-session is missing or not executable; greetd --cmd would fail" >&2; exit 1; }; \ |
| 3196 |
sh -n /usr/bin/alloy-session \ |
| 3197 |
|| { echo "alloy-session does not parse; no user could log in" >&2; exit 1; }; \ |
| 3198 |
grep -q -- '--cmd alloy-session' /etc/greetd/config.toml \ |
| 3199 |
|| { echo "greetd does not launch the session wrapper; the skeleton would never be applied" >&2; exit 1; }; \ |
| 3200 |
else \ |
| 3201 |
rm -f /usr/bin/alloy-session; \ |
| 3202 |
rm -rf /etc/greetd; \ |
| 3203 |
test ! -e /usr/bin/alloy-session && test ! -e /etc/greetd \ |
| 3204 |
|| { echo "profile=server still carries the session wrapper or greetd config" >&2; exit 1; }; \ |
| 3205 |
echo "session: none on this profile; boot lands on a getty"; \ |
| 3206 |
fi |
| 3207 |
|
| 3208 |
# ===================================================================== |
| 3209 |
# The Secret Service — assert the provider is reachable and its unlock |
| 3210 |
# is not inert. |
| 3211 |
# |
| 3212 |
# Three things have to hold together for an app to store a credential |
| 3213 |
# without prompting, and every one of them fails quietly on its own. |
| 3214 |
# |
| 3215 |
# 1. Something has to claim org.freedesktop.secrets. That is a D-Bus |
| 3216 |
# activation file, not a running daemon: the bus starts |
| 3217 |
# gnome-keyring-daemon on the first call. Absent, the `keyring` |
| 3218 |
# crate returns an error from inside the app, which is where this |
| 3219 |
# whole task started. |
| 3220 |
# 2. pam_gnome_keyring.so has to be loadable, or the `-` prefixed |
| 3221 |
# stanzas in greetd's PAM stack are skipped in silence and the |
| 3222 |
# keyring stays locked behind a second prompt. The module is in |
| 3223 |
# gnome-keyring-pam, a separate package; see the install group. |
| 3224 |
# 3. greetd's PAM stack has to still carry those stanzas. They are |
| 3225 |
# Fedora's, not Alloy's, so nothing in this repo would notice them |
| 3226 |
# leaving. Alloy ships no /etc/pam.d file, which is deliberate — |
| 3227 |
# copying greetd's stack in to add two lines it already has means |
| 3228 |
# owning a file that upstream keeps changing — and the cost of not |
| 3229 |
# owning it is exactly this check. |
| 3230 |
# |
| 3231 |
# The `auto_start` on the session line is named rather than matched |
| 3232 |
# loosely: without it the module authenticates against the keyring and |
| 3233 |
# never starts the daemon, which reads as the unlock working right up |
| 3234 |
# until an app asks for a secret. |
| 3235 |
# ===================================================================== |
| 3236 |
# |
| 3237 |
# Client-only, and note the whole check hangs off greetd's PAM file, which |
| 3238 |
# `server` deleted above. There is no session bus on a headless box to hold |
| 3239 |
# the provider on, and gopass — which is base — is the answer there instead. |
| 3240 |
# The `else` proves the provider really did not arrive, since gnome-keyring |
| 3241 |
# is exactly the kind of package that turns up as a weak dependency. |
| 3242 |
RUN set -eu; \ |
| 3243 |
service=/usr/share/dbus-1/services/org.freedesktop.secrets.service; \ |
| 3244 |
if [ "$PROFILE" = client ]; then \ |
| 3245 |
set -x; \ |
| 3246 |
[ -f "$service" ] \ |
| 3247 |
|| { echo "nothing claims org.freedesktop.secrets; every app using the keyring crate fails at runtime" >&2; exit 1; }; \ |
| 3248 |
grep -q '^Name=org.freedesktop.secrets$' "$service" \ |
| 3249 |
|| { echo "$service no longer claims the org.freedesktop.secrets name" >&2; exit 1; }; \ |
| 3250 |
module=$(find /usr/lib64/security /usr/lib/security -name pam_gnome_keyring.so 2>/dev/null | head -n 1); \ |
| 3251 |
[ -n "$module" ] \ |
| 3252 |
|| { echo "pam_gnome_keyring.so is absent; greetd's '-' prefixed stanzas would be skipped silently and the keyring would prompt separately" >&2; exit 1; }; \ |
| 3253 |
grep -q '^-*auth .*pam_gnome_keyring\.so' /etc/pam.d/greetd \ |
| 3254 |
|| { echo "greetd's PAM stack no longer unlocks the keyring on auth; Alloy would have to ship its own /etc/pam.d/greetd" >&2; exit 1; }; \ |
| 3255 |
grep -q '^-*session .*pam_gnome_keyring\.so.*auto_start' /etc/pam.d/greetd \ |
| 3256 |
|| { echo "greetd's PAM stack no longer starts the keyring daemon at session open; secrets would fail after a login that looked fine" >&2; exit 1; }; \ |
| 3257 |
echo "secret service: provider present, $module loadable, greetd unlocks it"; \ |
| 3258 |
else \ |
| 3259 |
[ ! -f "$service" ] \ |
| 3260 |
|| { echo "profile=server but something claims org.freedesktop.secrets; gnome-keyring got installed" >&2; exit 1; }; \ |
| 3261 |
echo "secret service: none on this profile, and no gopass either since 2026-08-17"; \ |
| 3262 |
fi |
| 3263 |
|
| 3264 |
# gopass, and the one thing about it that can be wrong without saying so. |
| 3265 |
# |
| 3266 |
# The store is age-backed rather than GPG-backed (docs/STACK.md#secrets), |
| 3267 |
# and age support is compiled into gopass rather than shipped as a plugin, |
| 3268 |
# so a Fedora build with the feature dropped would still install, still |
| 3269 |
# run, and only fail when a store is initialized. `gopass age --help` is |
| 3270 |
# the cheapest question that distinguishes the two. |
| 3271 |
# |
| 3272 |
# HOME is redirected at a temporary directory and the directory removed, |
| 3273 |
# which is not tidiness. gopass writes a config file on any invocation, |
| 3274 |
# including one that only asks for help, so the plain form of this check |
| 3275 |
# bakes /root/.config/gopass/config into the shipped image: a config for |
| 3276 |
# an account with no store, authored by a build step, sitting where the |
| 3277 |
# real one would go the first time anyone runs gopass as root. A guard |
| 3278 |
# that leaves state behind is a guard that changes what it was checking. |
| 3279 |
# CONDITIONAL SINCE 2026-08-17, when gopass moved to the client block. Both |
| 3280 |
# of these ran unconditionally and both failed the first server build after |
| 3281 |
# the move, which is the third instance of the shape subtask 825465e0 named: |
| 3282 |
# not a conditional with a silent half, but an assertion with no conditional |
| 3283 |
# at all, written when every profile carried the thing it checks. Kept as two |
| 3284 |
# RUNs rather than merged, because the second is the expensive one and the |
| 3285 |
# first is what makes its failure legible. |
| 3286 |
RUN if [ "$PROFILE" = client ]; then \ |
| 3287 |
command -v gopass >/dev/null \ |
| 3288 |
|| { echo "gopass is missing; docs/STACK.md names it as the password manager" >&2; exit 1; }; \ |
| 3289 |
else \ |
| 3290 |
! command -v gopass >/dev/null \ |
| 3291 |
|| { echo "profile=server but gopass is installed; it moved to the client block and takes fish with it" >&2; exit 1; }; \ |
| 3292 |
! command -v fish >/dev/null \ |
| 3293 |
|| { echo "profile=server but fish is installed; nothing here runs it and only gopass wanted it" >&2; exit 1; }; \ |
| 3294 |
echo "password store: none on this profile, and no fish behind it"; \ |
| 3295 |
fi |
| 3296 |
RUN if [ "$PROFILE" = client ]; then \ |
| 3297 |
set -eux; \ |
| 3298 |
probe=$(mktemp -d); \ |
| 3299 |
HOME="$probe" gopass age --help >/dev/null 2>&1 \ |
| 3300 |
|| { rm -rf "$probe"; echo "this gopass build has no age backend; the whole reason it was picked over pass is gone" >&2; exit 1; }; \ |
| 3301 |
rm -rf "$probe"; \ |
| 3302 |
[ ! -e /root/.config/gopass ] \ |
| 3303 |
|| { echo "the gopass probe wrote into /root anyway; it would ship in the image" >&2; exit 1; }; \ |
| 3304 |
else \ |
| 3305 |
[ ! -e /root/.config/gopass ] \ |
| 3306 |
|| { echo "profile=server has a gopass config; something ran it during the build" >&2; exit 1; }; \ |
| 3307 |
echo "no gopass on this profile, so no age-backend probe and nothing to leave behind"; \ |
| 3308 |
fi |
| 3309 |
|
| 3310 |
# ===================================================================== |
| 3311 |
# The screenshot helper — assert the binds reach it. |
| 3312 |
# |
| 3313 |
# Not the boot path, so a lighter case than alloy-session above, but the |
| 3314 |
# same silence: the four Print binds `exec alloy-shot <mode>`, and sway's |
| 3315 |
# exec reports a command it cannot run to its own log and nowhere the |
| 3316 |
# person pressing the key will see. Missing, non-executable, or a mode |
| 3317 |
# named in the config that the script does not answer to, and the keys go |
| 3318 |
# back to doing nothing, which is the state this whole helper exists to |
| 3319 |
# end. |
| 3320 |
# |
| 3321 |
# The modes are read back out of the config's binds and asked of the |
| 3322 |
# script, so the two cannot drift: a renamed mode fails here rather than |
| 3323 |
# on a keypress. Every dependency the script shells out to is checked in |
| 3324 |
# the same pass, since each one is a bind that silently stops working. |
| 3325 |
# ===================================================================== |
| 3326 |
# |
| 3327 |
# Client-only: six of the tools it needs are client packages and the modes |
| 3328 |
# are read out of the sway config. Removed on `server` for the same reason |
| 3329 |
# as alloy-session and alloy-dim — a Print-screen helper on a machine with |
| 3330 |
# no screen is a script that can only fail. |
| 3331 |
RUN set -eu; \ |
| 3332 |
if [ "$PROFILE" = client ]; then \ |
| 3333 |
set -x; \ |
| 3334 |
test -x /usr/bin/alloy-shot \ |
| 3335 |
|| { echo "alloy-shot is missing or not executable; every Print bind would do nothing" >&2; exit 1; }; \ |
| 3336 |
sh -n /usr/bin/alloy-shot \ |
| 3337 |
|| { echo "alloy-shot does not parse; every Print bind would do nothing" >&2; exit 1; }; \ |
| 3338 |
for tool in grim slurp jq swaymsg satty notify-send; do \ |
| 3339 |
command -v "$tool" >/dev/null \ |
| 3340 |
|| { echo "alloy-shot needs $tool and it is not in the image" >&2; exit 1; }; \ |
| 3341 |
done; \ |
| 3342 |
modes=$(sed -n 's/^bindsym [^ ]*Print *exec alloy-shot \([a-z]*\).*/\1/p' /etc/skel/.config/sway/config); \ |
| 3343 |
[ -n "$modes" ] \ |
| 3344 |
|| { echo "no Print bind in the shipped sway config calls alloy-shot" >&2; exit 1; }; \ |
| 3345 |
for mode in $modes; do \ |
| 3346 |
grep -q "^ $mode)" /usr/bin/alloy-shot \ |
| 3347 |
|| { echo "the sway config binds alloy-shot $mode, which the script does not handle" >&2; exit 1; }; \ |
| 3348 |
done; \ |
| 3349 |
echo "alloy-shot: $(echo "$modes" | wc -l) bound modes, all handled"; \ |
| 3350 |
else \ |
| 3351 |
rm -f /usr/bin/alloy-shot /usr/bin/alloy-menu /usr/bin/alloy-clipmenu \ |
| 3352 |
/usr/bin/alloy-clipstore /usr/bin/alloy-secret-copy /usr/bin/alloy-secret-clear; \ |
| 3353 |
test ! -e /usr/bin/alloy-shot \ |
| 3354 |
|| { echo "profile=server still carries alloy-shot" >&2; exit 1; }; \ |
| 3355 |
test ! -e /usr/bin/alloy-clipstore \ |
| 3356 |
|| { echo "profile=server still carries alloy-clipstore; there is no clipboard on a headless box" >&2; exit 1; }; \ |
| 3357 |
fi |
| 3358 |
|
| 3359 |
# ===================================================================== |
| 3360 |
# The private clipboard — assert the leak stays closed. |
| 3361 |
# |
| 3362 |
# Alloy ships a password manager and a clipboard history, and until |
| 3363 |
# 2026-08-17 the second recorded the output of the first: `gopass show -c` |
| 3364 |
# put a password on the clipboard and the sway config's watchers wrote it |
| 3365 |
# into cliphist's database, where $mod+Shift+v read it back. GO alloy |
| 3366 |
# problem e7a9e38c. |
| 3367 |
# |
| 3368 |
# Three parts have to agree or the leak is silently back, and no part |
| 3369 |
# reports its own absence: |
| 3370 |
# |
| 3371 |
# 1. the scripts exist and parse |
| 3372 |
# 2. the sway config's watchers call alloy-clipstore, not `cliphist store` |
| 3373 |
# 3. gopass is pointed at alloy-secret-copy through environment.d |
| 3374 |
# |
| 3375 |
# Miss (2) and the watcher stores everything. Miss (3) and gopass finds |
| 3376 |
# wl-copy by itself. Both look exactly like a working system: the password |
| 3377 |
# is on the clipboard, the paste works, and the only difference is a row in |
| 3378 |
# a database nobody opens. That is the same shape as the emoji alias and the |
| 3379 |
# swayosd unit path, which is why it is asserted rather than trusted. |
| 3380 |
RUN set -eu; \ |
| 3381 |
if [ "$PROFILE" = client ]; then \ |
| 3382 |
set -x; \ |
| 3383 |
for script in alloy-clipstore alloy-secret-copy alloy-secret-clear; do \ |
| 3384 |
test -x "/usr/bin/$script" \ |
| 3385 |
|| { echo "$script is missing or not executable; every copied password would be archived" >&2; exit 1; }; \ |
| 3386 |
sh -n "/usr/bin/$script" \ |
| 3387 |
|| { echo "$script does not parse; the private clipboard would fail open" >&2; exit 1; }; \ |
| 3388 |
done; \ |
| 3389 |
watchers=$(grep -c '^exec wl-paste .*--watch alloy-clipstore' /etc/skel/.config/sway/config); \ |
| 3390 |
[ "$watchers" = 2 ] \ |
| 3391 |
|| { echo "expected 2 clipboard watchers through alloy-clipstore, found $watchers; a password would reach cliphist" >&2; exit 1; }; \ |
| 3392 |
! grep -q '^exec wl-paste .*--watch cliphist store' /etc/skel/.config/sway/config \ |
| 3393 |
|| { echo "a watcher still calls cliphist store directly, so it records secrets too" >&2; exit 1; }; \ |
| 3394 |
env=/etc/skel/.config/environment.d/alloy.conf; \ |
| 3395 |
grep -q '^GOPASS_CLIPBOARD_COPY_CMD=/usr/bin/alloy-secret-copy$' "$env" \ |
| 3396 |
|| { echo "gopass is not pointed at alloy-secret-copy; it would call wl-copy itself and the leak is back" >&2; exit 1; }; \ |
| 3397 |
grep -q '^GOPASS_CLIPBOARD_CLEAR_CMD=/usr/bin/alloy-secret-clear$' "$env" \ |
| 3398 |
|| { echo "gopass has no clear command, so clipboard history would stay paused until the backstop" >&2; exit 1; }; \ |
| 3399 |
echo "private clipboard: 2 watchers gated, gopass routed through it"; \ |
| 3400 |
else \ |
| 3401 |
for script in alloy-clipstore alloy-secret-copy alloy-secret-clear; do \ |
| 3402 |
test ! -e "/usr/bin/$script" \ |
| 3403 |
|| { echo "profile=server still carries $script; there is no clipboard and no gopass on this profile" >&2; exit 1; }; \ |
| 3404 |
done; \ |
| 3405 |
echo "private clipboard: nothing to gate on a headless profile"; \ |
| 3406 |
fi |
| 3407 |
|
| 3408 |
# ===================================================================== |
| 3409 |
# alloy-backdrop — the desktop background, and the reference it is. |
| 3410 |
# |
| 3411 |
# An installed machine ships no keybinding reference at all: the only copy is |
| 3412 |
# docs/manual/05-keybindings.md and no COPY puts docs/ into the image. This |
| 3413 |
# panel is it. So a build where the script is absent, unparseable, or simply |
| 3414 |
# not started leaves a machine whose keys are documented nowhere the person |
| 3415 |
# using it can reach. |
| 3416 |
# |
| 3417 |
# Two halves, and each is silent on its own. A missing script means shop exits |
| 3418 |
# immediately and the desktop falls back to the flat colour, which looks like a |
| 3419 |
# deliberate plain background rather than a failure. A missing exec line means |
| 3420 |
# the script is present, correct and never run. |
| 3421 |
# |
| 3422 |
# The flat colour is asserted too. It is the fallback for a compositor with no |
| 3423 |
# wlr-layer-shell, and losing it would turn that case from a plain background |
| 3424 |
# into no background at all. |
| 3425 |
# |
| 3426 |
# THE BIND COUNT IS THE ONE CHECKED HERE, and the verbs deliberately are not. |
| 3427 |
# The console is not in /usr/bin at build time: it travels as an uninstalled |
| 3428 |
# RPM and alloy-layer-components.service lays it down on the first boot (see |
| 3429 |
# "The component repo" above). So `alloy --help` answers nothing here, which is |
| 3430 |
# also true of the first boot before layering finishes. Running the panel under |
| 3431 |
# exactly that condition is the point: it must still draw every key, because a |
| 3432 |
# backdrop that needed the console would be blank on the one boot where a |
| 3433 |
# person most needs to know which keys exist. The verb half is covered by |
| 3434 |
# crates/alloy/tests/backdrop.rs against a stub. |
| 3435 |
RUN set -eu; \ |
| 3436 |
if [ "$PROFILE" = client ]; then \ |
| 3437 |
test -x /usr/bin/alloy-backdrop \ |
| 3438 |
|| { echo "alloy-backdrop is missing or not executable; the desktop would be a flat colour and the keybindings documented nowhere on the machine" >&2; exit 1; }; \ |
| 3439 |
sh -n /usr/bin/alloy-backdrop \ |
| 3440 |
|| { echo "alloy-backdrop does not parse; shop would exit at once and the background would silently fall back" >&2; exit 1; }; \ |
| 3441 |
test -x /usr/bin/alloy-drift \ |
| 3442 |
|| { echo "alloy-drift is missing or not executable; nothing would run the panel and the desktop would be a flat colour" >&2; exit 1; }; \ |
| 3443 |
/usr/bin/alloy-drift --help | grep -q -- '--pattern' \ |
| 3444 |
|| { echo "alloy-drift does not answer --help; the loop that draws the backdrop is not the binary that was built" >&2; exit 1; }; \ |
| 3445 |
conf=/etc/skel/.config/sway/config; \ |
| 3446 |
grep -q '^exec \$term --layer background -e /usr/bin/alloy-drift$' "$conf" \ |
| 3447 |
|| { echo "the session does not start alloy-drift; the backdrop would ship and never run" >&2; exit 1; }; \ |
| 3448 |
grep -q '^bindsym .* exec pkill -USR1 -x alloy-drift$' "$conf" \ |
| 3449 |
|| { echo "no bind toggles the backdrop; the panel could never be put away and, because the key list is derived from these binds, nothing on the machine would say so" >&2; exit 1; }; \ |
| 3450 |
grep -q '^output \* bg .* solid_color$' "$conf" \ |
| 3451 |
|| { echo "the solid colour fallback is gone; a compositor without wlr-layer-shell would have no background at all" >&2; exit 1; }; \ |
| 3452 |
binds=$(grep -c '^[[:space:]]*bindsym[[:space:]]' "$conf"); \ |
| 3453 |
drawn=$(ALLOY_BACKDROP_CONFIG="$conf" ALLOY_BACKDROP_SIZE="500 1" \ |
| 3454 |
/usr/bin/alloy-backdrop --once \ |
| 3455 |
| sed -e 's/\x1b\[[0-9;]*m//g' \ |
| 3456 |
| awk '/^ keys$/{k=1;next} k && NF {n++} END{print n+0}'); \ |
| 3457 |
[ "$binds" = "$drawn" ] \ |
| 3458 |
|| { echo "the config has $binds binds and the panel draws $drawn; a bind the parser does not recognise is missing from the only reference the image ships" >&2; exit 1; }; \ |
| 3459 |
echo "backdrop: $drawn of $binds binds drawn with no console present"; \ |
| 3460 |
else \ |
| 3461 |
rm -f /usr/bin/alloy-backdrop /usr/bin/alloy-drift; \ |
| 3462 |
test ! -e /usr/bin/alloy-backdrop \ |
| 3463 |
|| { echo "profile=server still carries alloy-backdrop; there is no sway session to start it" >&2; exit 1; }; \ |
| 3464 |
test ! -e /usr/bin/alloy-drift \ |
| 3465 |
|| { echo "profile=server still carries alloy-drift; there is no sway session to start it" >&2; exit 1; }; \ |
| 3466 |
echo "backdrop: none on this profile; there is no desktop to put one behind"; \ |
| 3467 |
fi |
| 3468 |
|
| 3469 |
# ===================================================================== |
| 3470 |
# alloy-dim — the lock warning, and the two ways it goes quiet. |
| 3471 |
# |
| 3472 |
# swayidle calls this at 270 seconds and again on resume. Neither call is |
| 3473 |
# visible: if the script is missing or unparseable, swayidle logs to its own |
| 3474 |
# output and the observable result is the old behaviour, full brightness |
| 3475 |
# straight to a lock, which is exactly the ambush the stage was added to |
| 3476 |
# remove. A regression here restores a bug rather than causing a new one, |
| 3477 |
# which is the kind that survives. |
| 3478 |
# |
| 3479 |
# The subcommands are read back out of the shipped sway config and asked of |
| 3480 |
# the script, the same way alloy-shot's modes are, so a renamed one fails |
| 3481 |
# here instead of on the idle chain. |
| 3482 |
# |
| 3483 |
# The `video` group is checked too, because it is the whole mechanism: the |
| 3484 |
# script writes /sys/class/backlight/<dev>/brightness, which is writable only |
| 3485 |
# through the udev rule copied above, and the installer puts the account in |
| 3486 |
# `video` (crates/alloy/src/install.rs). A missing group makes the script exit |
| 3487 |
# 0 on every call, having found no writable device — dimming would be absent |
| 3488 |
# and silent, indistinguishable from it never having been added. |
| 3489 |
# ===================================================================== |
| 3490 |
# |
| 3491 |
# Client-only: the whole check reads the shipped sway config, which is the |
| 3492 |
# thing `server` does not have. The script itself ships from the repo's |
| 3493 |
# `usr/` tree, so as with alloy-session the `else` removes it rather than |
| 3494 |
# leaving a dimmer on a machine with no backlight and no idle chain. |
| 3495 |
RUN set -eu; \ |
| 3496 |
if [ "$PROFILE" = client ]; then \ |
| 3497 |
set -x; \ |
| 3498 |
test -x /usr/bin/alloy-dim \ |
| 3499 |
|| { echo "alloy-dim is missing or not executable; the lock would arrive with no warning" >&2; exit 1; }; \ |
| 3500 |
sh -n /usr/bin/alloy-dim \ |
| 3501 |
|| { echo "alloy-dim does not parse; the lock would arrive with no warning" >&2; exit 1; }; \ |
| 3502 |
verbs=$(sed -n "s/.*alloy-dim \([a-z]*\)'.*/\1/p" /etc/skel/.config/sway/config | sort -u); \ |
| 3503 |
[ -n "$verbs" ] \ |
| 3504 |
|| { echo "the shipped sway config never calls alloy-dim; the warning stage is not wired up" >&2; exit 1; }; \ |
| 3505 |
for verb in $verbs; do \ |
| 3506 |
grep -q "^$verb)" /usr/bin/alloy-dim \ |
| 3507 |
|| { echo "the sway config calls alloy-dim $verb, which the script does not handle" >&2; exit 1; }; \ |
| 3508 |
done; \ |
| 3509 |
getent group video >/dev/null \ |
| 3510 |
|| { echo "no video group; alloy-dim would find no writable backlight and dim nothing" >&2; exit 1; }; \ |
| 3511 |
grep -q 'timeout 270' /etc/skel/.config/sway/config \ |
| 3512 |
|| { echo "the warning stage is gone from the idle chain; alloy-dim would never be called" >&2; exit 1; }; \ |
| 3513 |
echo "alloy-dim: $(echo "$verbs" | wc -l) verbs wired, video group present"; \ |
| 3514 |
else \ |
| 3515 |
rm -f /usr/bin/alloy-dim; \ |
| 3516 |
test ! -e /usr/bin/alloy-dim \ |
| 3517 |
|| { echo "profile=server still carries alloy-dim" >&2; exit 1; }; \ |
| 3518 |
fi |
| 3519 |
|
| 3520 |
# ===================================================================== |
| 3521 |
# udisks — assert the daemon `alloy disk` drives is actually here. |
| 3522 |
# |
| 3523 |
# udisks2 arrives from the base rather than from a line in this file, and an |
| 3524 |
# inherited dependency nothing states is the pattern that already went wrong |
| 3525 |
# twice: bluez was enabled by Fedora's preset with no decision recorded, and the |
| 3526 |
# emoji alias named a font the image never installed. `alloy disk` reads with |
| 3527 |
# lsblk and acts with udisksctl, so if the base ever drops it the verb becomes a |
| 3528 |
# read-only screen whose every action key refuses. That is a change worth failing |
| 3529 |
# the build over rather than discovering on a plugged-in stick. |
| 3530 |
# |
| 3531 |
# Not `dnf install`: the base already carries it, and adding the line would claim |
| 3532 |
# ownership of a dependency this file does not actually choose. The assertion is |
| 3533 |
# the honest version. |
| 3534 |
RUN set -eux; \ |
| 3535 |
command -v udisksctl >/dev/null \ |
| 3536 |
|| { echo "no udisksctl; alloy disk could list volumes but never mount one" >&2; exit 1; }; \ |
| 3537 |
test -f /usr/lib/systemd/system/udisks2.service \ |
| 3538 |
|| { echo "udisks2 ships no service unit; nothing would answer udisksctl" >&2; exit 1; }; \ |
| 3539 |
echo "udisks: present, D-Bus activated, drives alloy disk" |
| 3540 |
|
| 3541 |
# polkit rules — assert the grant is not inert, and that the vendor |
| 3542 |
# defaults it rests on are the ones it was written against. |
| 3543 |
# |
| 3544 |
# usr/share/polkit-1/rules.d/50-alloy-settings.rules turns five actions |
| 3545 |
# into silent yeses so `alloy settings` does not raise a password prompt |
| 3546 |
# to change a timezone. Three ways that file can be shipped and mean |
| 3547 |
# something other than what it says, all silent: |
| 3548 |
# |
| 3549 |
# - polkit is not in the base image, so nothing reads rules.d |
| 3550 |
# - an action id was renamed upstream, so the grant names something |
| 3551 |
# that no longer exists and the console prompts anyway |
| 3552 |
# - a vendor default moved: one that became `yes` makes the rule a |
| 3553 |
# security artifact shipped for nothing, and one that became |
| 3554 |
# `auth_admin` takes away the burst-caching the settings tab counts |
| 3555 |
# on |
| 3556 |
# |
| 3557 |
# All three are checked here rather than discovered on a booted machine, |
| 3558 |
# where the symptom is a password prompt nobody can explain. The action |
| 3559 |
# ids are read back out of the shipped rule and the defaults out of the |
| 3560 |
# image's own `.policy` files, so neither reading can drift from what it |
| 3561 |
# is checked against. Grant rationale is in the rule's own header and in |
| 3562 |
# wiki note `alloy-privilege`. |
| 3563 |
# |
| 3564 |
# The expected defaults are POLICY_TABLE in build/check-installed.sh, |
| 3565 |
# bind-mounted for the length of this step rather than copied, so the |
| 3566 |
# table has one home and the image gains no file for it. That script |
| 3567 |
# asks the same question of a running machine with `--policy`. |
| 3568 |
# ===================================================================== |
| 3569 |
RUN --mount=type=bind,source=build/check-installed.sh,target=/run/check-installed.sh \ |
| 3570 |
set -eux; \ |
| 3571 |
rules=/usr/share/polkit-1/rules.d/50-alloy-settings.rules; \ |
| 3572 |
[ -d /usr/share/polkit-1/actions ] \ |
| 3573 |
|| { echo "no polkit in the base image; $rules would never be read" >&2; exit 1; }; \ |
| 3574 |
[ -f "$rules" ] || { echo "$rules did not land" >&2; exit 1; }; \ |
| 3575 |
granted=$(sed -n '/var granted = \[/,/]/p' "$rules" | grep -o '"[a-z0-9.-]*"' | tr -d '"'); \ |
| 3576 |
[ -n "$granted" ] || { echo "read no action ids out of $rules" >&2; exit 1; }; \ |
| 3577 |
for action in $granted; do \ |
| 3578 |
grep -qr "action id=\"$action\"" /usr/share/polkit-1/actions/ \ |
| 3579 |
|| { echo "granted action $action is not one this image defines" >&2; exit 1; }; \ |
| 3580 |
done; \ |
| 3581 |
awk '/^POLICY_TABLE=/ { inside = 1; next } \ |
| 3582 |
inside && $0 !~ /^org\./ { inside = 0 } \ |
| 3583 |
inside && NF == 2 { print $1, $2 }' \ |
| 3584 |
/run/check-installed.sh > /tmp/policy-expected; \ |
| 3585 |
[ -s /tmp/policy-expected ] \ |
| 3586 |
|| { echo "read no POLICY_TABLE out of build/check-installed.sh" >&2; exit 1; }; \ |
| 3587 |
awk 'match($0, /<action id="[^"]*"/) { id = substr($0, RSTART + 12, RLENGTH - 13) } \ |
| 3588 |
match($0, /<allow_active>[^<]*/) { print id, substr($0, RSTART + 14, RLENGTH - 14) }' \ |
| 3589 |
/usr/share/polkit-1/actions/*.policy > /tmp/policy-actual; \ |
| 3590 |
awk 'NR == FNR { actual[$1] = $2; next } \ |
| 3591 |
{ rows++ } \ |
| 3592 |
!($1 in actual) { printf "%s is defined by no .policy file in this image\n", $1 > "/dev/stderr"; bad++; next } \ |
| 3593 |
actual[$1] != $2 { printf "%s reads %s, POLICY_TABLE says %s\n", $1, actual[$1], $2 > "/dev/stderr"; bad++ } \ |
| 3594 |
END { if (bad) { printf "%d polkit default(s) drifted from build/check-installed.sh; the ladder is built on that table, so correct it before the rules file\n", bad > "/dev/stderr"; exit 1 } \ |
| 3595 |
printf "polkit: %d implicit defaults match build/check-installed.sh\n", rows }' \ |
| 3596 |
/tmp/policy-actual /tmp/policy-expected; \ |
| 3597 |
rm -f /tmp/policy-expected /tmp/policy-actual; \ |
| 3598 |
echo "polkit: granted $(echo "$granted" | wc -l) actions, all defined" |
| 3599 |
|
| 3600 |
# ===================================================================== |
| 3601 |
# pkttyagent — the console's answer when the grant above does not apply. |
| 3602 |
# |
| 3603 |
# The rule grants five actions to an *active local* session in wheel. An |
| 3604 |
# ssh login is not that, which is correct and is the whole argument for |
| 3605 |
# granting them at all, so the same settings rows prompt over ssh. The |
| 3606 |
# console handles that by running the setter again with `pkttyagent` |
| 3607 |
# beside it (crates/alloy/src/shell.rs, `Flow::Authorize`), which is |
| 3608 |
# polkit's own text agent and needs no GTK or Qt. |
| 3609 |
# |
| 3610 |
# It ships in the polkit package, so the failure this checks for is not |
| 3611 |
# "someone forgot to install it" but "the base split it out" — silent on |
| 3612 |
# a built image, and discovered by a user over ssh whose timezone change |
| 3613 |
# fails twice with the same message. |
| 3614 |
# ===================================================================== |
| 3615 |
RUN set -eux; \ |
| 3616 |
command -v pkttyagent >/dev/null \ |
| 3617 |
|| { echo "no pkttyagent; the console has no way to answer a polkit prompt" >&2; exit 1; }; \ |
| 3618 |
echo "pkttyagent: present" |
| 3619 |
|
| 3620 |
# ===================================================================== |
| 3621 |
# polkit-agent-helper-1 — the same question for tier 3. |
| 3622 |
# |
| 3623 |
# The console registers an authentication agent of its own and draws the |
| 3624 |
# prompt in Akari (crates/alloy/src/polkit.rs, `Flow::AuthorizeInline`). |
| 3625 |
# What it does *not* do is run the PAM conversation: that is this setuid |
| 3626 |
# helper's, deliberately, and it is the only reason an agent drawn by us |
| 3627 |
# is not an authentication surface written by us. |
| 3628 |
# |
| 3629 |
# Checked for the same reason and against the same failure as pkttyagent |
| 3630 |
# above — it ships inside the polkit package and a base that split it out |
| 3631 |
# is silent at build time. Distinct from that check because it is a path |
| 3632 |
# rather than a command on `$PATH`, and because the two are used by two |
| 3633 |
# different tiers: losing this one leaves tier 2 working and takes away |
| 3634 |
# the only way to join a wifi network, which needs a passphrase on stdin |
| 3635 |
# that a suspended child has no pipe for. |
| 3636 |
# |
| 3637 |
# `-u` as well as `-x`: an agent helper that is not setuid root cannot |
| 3638 |
# read the shadow file, so it would run, fail every password, and say |
| 3639 |
# nothing about why. |
| 3640 |
# ===================================================================== |
| 3641 |
RUN set -eux; \ |
| 3642 |
helper=; \ |
| 3643 |
for candidate in /usr/lib/polkit-1/polkit-agent-helper-1 \ |
| 3644 |
/usr/libexec/polkit-1/polkit-agent-helper-1; do \ |
| 3645 |
[ -x "$candidate" ] && helper="$candidate" && break; \ |
| 3646 |
done; \ |
| 3647 |
[ -n "$helper" ] \ |
| 3648 |
|| { echo "no polkit-agent-helper-1; the console cannot answer polkit without leaving the TUI" >&2; exit 1; }; \ |
| 3649 |
[ -u "$helper" ] \ |
| 3650 |
|| { echo "$helper is not setuid; every password it is given would fail" >&2; exit 1; }; \ |
| 3651 |
echo "polkit-agent-helper-1: $helper, setuid" |
| 3652 |
|
| 3653 |
# ===================================================================== |
| 3654 |
# run0 — assert the way to root that Alloy documents is in the image. |
| 3655 |
# |
| 3656 |
# Alloy names `run0` and not `sudo` (docs/CONTINUITY.md, |
| 3657 |
# docs/HARDWARE-FW12.md, the installer's account pane; rationale in wiki |
| 3658 |
# note `alloy-privilege`). run0 is systemd's, arrived in 256, and is a |
| 3659 |
# symlink to systemd-run: it asks PID 1 for a transient unit running as |
| 3660 |
# root, and PID 1 asks polkit. So becoming root goes through the same |
| 3661 |
# authority the console's writing views already go through, and a |
| 3662 |
# fingerprint or FIDO2 factor configured once at polkit's PAM stack |
| 3663 |
# covers both. |
| 3664 |
# |
| 3665 |
# sudo is still in the base and still works. Nothing here removes it; |
| 3666 |
# what is asserted is only that the path the docs name is present, since |
| 3667 |
# a manual telling a user to type run0 on an image without it is worse |
| 3668 |
# than no manual. Three ways that can be wrong, all silent on a built |
| 3669 |
# image: |
| 3670 |
# |
| 3671 |
# - the base dropped run0, so every doc naming it is a lie |
| 3672 |
# - systemd is older than 256, so run0 predates its own existence |
| 3673 |
# - the action a transient-unit request authenticates against is not |
| 3674 |
# defined, so run0 has no admin to resolve and cannot authorize |
| 3675 |
# ===================================================================== |
| 3676 |
RUN set -eux; \ |
| 3677 |
command -v run0 >/dev/null \ |
| 3678 |
|| { echo "no run0 in the base image; the docs name it as the way to root" >&2; exit 1; }; \ |
| 3679 |
version=$(systemctl --version | sed -n '1s/^systemd \([0-9]*\).*/\1/p'); \ |
| 3680 |
[ -n "$version" ] || { echo "could not read a systemd version" >&2; exit 1; }; \ |
| 3681 |
[ "$version" -ge 256 ] \ |
| 3682 |
|| { echo "systemd $version predates run0, which arrived in 256" >&2; exit 1; }; \ |
| 3683 |
action=org.freedesktop.systemd1.manage-units; \ |
| 3684 |
grep -qr "action id=\"$action\"" /usr/share/polkit-1/actions/ \ |
| 3685 |
|| { echo "$action is not defined; run0 cannot ask polkit for an admin" >&2; exit 1; }; \ |
| 3686 |
echo "run0: present, systemd $version, $action defined" |
| 3687 |
|
| 3688 |
# ===================================================================== |
| 3689 |
# Hardening, part 1: the SUID set. |
| 3690 |
# |
| 3691 |
# Filed out of the 2026-08-21 secureblue review; the adopt/reject list and |
| 3692 |
# the reasoning are in wiki `alloy-hardening-posture`. |
| 3693 |
# |
| 3694 |
# A SUID binary is a program any user can run that becomes root, so each one |
| 3695 |
# is a promise that its argument parsing, its environment handling and its |
| 3696 |
# error paths are all correct. The base image ships sixteen. Six of them have |
| 3697 |
# no caller on a machine with one human account and a console that owns |
| 3698 |
# account management, and a promise nobody needs is one worth not making. |
| 3699 |
# |
| 3700 |
# WHAT IS NOT DONE HERE, deliberately: secureblue re-adds capabilities to |
| 3701 |
# some of these so they keep working for unprivileged users. Kicksecure's |
| 3702 |
# objection to that is sound — `cap_dac_read_search` on `unix_chkpwd` hands |
| 3703 |
# out a dangerous capability to preserve a rarely-used convenience — so the |
| 3704 |
# tools simply stop working without root, which is the cheaper answer. |
| 3705 |
# |
| 3706 |
# WHAT STAYS SUID, and why, because a list of removals without the |
| 3707 |
# complement reads as an oversight: |
| 3708 |
# - pkexec and polkit-agent-helper-1: the console's inline authorize flow |
| 3709 |
# depends on the helper (crates/alloy/src/polkit.rs), and the block below |
| 3710 |
# already asserts it is setuid. |
| 3711 |
# - sudo and su: Alloy names run0 as the way to root (see above) and has |
| 3712 |
# never removed sudo. Scripts across this tree call it. Removing it is a |
| 3713 |
# separate decision with its own blast radius, not a line in a hardening |
| 3714 |
# pass. |
| 3715 |
# - passwd and unix_chkpwd: a user changing their own password, and the |
| 3716 |
# lock screen checking it. |
| 3717 |
# - mount, umount, mount.nfs, fusermount3: `alloy disk` and removable media. |
| 3718 |
# - grub2-set-bootflag: the bootloader's one-shot flag. |
| 3719 |
# |
| 3720 |
# The result is asserted rather than assumed: chmod is silent about a file |
| 3721 |
# that was already not setuid, and about one this list misspelled. |
| 3722 |
# ===================================================================== |
| 3723 |
RUN set -eux; \ |
| 3724 |
stripped=""; \ |
| 3725 |
for bin in chfn chsh newgrp gpasswd chage pam_timestamp_check; do \ |
| 3726 |
path=$(command -v "$bin" 2>/dev/null || true); \ |
| 3727 |
[ -n "$path" ] || { echo "$bin is not in the image; this list names a binary the base no longer ships" >&2; exit 1; }; \ |
| 3728 |
[ -u "$path" ] || { echo "$path is already not setuid; the base changed and this line is now inert" >&2; exit 1; }; \ |
| 3729 |
chmod u-s "$path"; \ |
| 3730 |
[ -u "$path" ] && { echo "chmod u-s did not take on $path" >&2; exit 1; }; \ |
| 3731 |
stripped="$stripped $bin"; \ |
| 3732 |
done; \ |
| 3733 |
for keep in /usr/bin/sudo /usr/bin/passwd /usr/bin/mount /usr/bin/umount; do \ |
| 3734 |
[ -u "$keep" ] || { echo "$keep lost its setuid bit; the loop above is stripping more than it names" >&2; exit 1; }; \ |
| 3735 |
done; \ |
| 3736 |
echo "suid: stripped$stripped; $(find /usr/bin /usr/sbin /usr/libexec -perm -4000 -type f 2>/dev/null | wc -l) setuid binaries remain" |
| 3737 |
|
| 3738 |
# ===================================================================== |
| 3739 |
# Hardening, part 2: faillock. |
| 3740 |
# |
| 3741 |
# etc/security/faillock.conf carries the numbers (50 attempts, 24 hours). |
| 3742 |
# That file is inert on its own: what reads it is pam_faillock, and pam_faillock |
| 3743 |
# is only in the stack if authselect's `with-faillock` feature is on. Same |
| 3744 |
# shape as the fingerprint block above, and asserted the same way, because |
| 3745 |
# "the file is present" and "the file is doing something" are different |
| 3746 |
# claims and only the second one is worth making. |
| 3747 |
# |
| 3748 |
# Applies on both profiles. sshd takes keys only, so this is about the |
| 3749 |
# console, the greeter and the lock screen — all of which exist on a server |
| 3750 |
# too, in the form of whoever is standing at it. |
| 3751 |
# ===================================================================== |
| 3752 |
RUN set -eux; \ |
| 3753 |
conf=/etc/security/faillock.conf; \ |
| 3754 |
[ -f "$conf" ] || { echo "$conf did not land from the config tree" >&2; exit 1; }; \ |
| 3755 |
grep -q '^deny = 50' "$conf" \ |
| 3756 |
|| { echo "$conf does not set the deny count this image documents" >&2; exit 1; }; \ |
| 3757 |
authselect enable-feature with-faillock; \ |
| 3758 |
authselect apply-changes; \ |
| 3759 |
grep -q 'pam_faillock\.so' /etc/pam.d/system-auth \ |
| 3760 |
|| { echo "with-faillock did not reach system-auth; $conf would never be read" >&2; exit 1; }; \ |
| 3761 |
grep -q 'pam_faillock\.so' /etc/pam.d/password-auth \ |
| 3762 |
|| { echo "with-faillock reached system-auth but not password-auth; half the stack counts failures" >&2; exit 1; }; \ |
| 3763 |
echo "faillock: $(grep -c pam_faillock /etc/pam.d/system-auth) pam_faillock line(s) in system-auth, deny=50 unlock_time=86400" |
| 3764 |
|
| 3765 |
# ===================================================================== |
| 3766 |
# Hardening, part 3: the clock. |
| 3767 |
# |
| 3768 |
# etc/chrony.d/10-alloy-nts.conf names four NTS servers. Two things have to |
| 3769 |
# be true for it to matter, and neither is true of the file on its own: |
| 3770 |
# /etc/chrony.conf has to read the directory, and it has to stop preferring |
| 3771 |
# the unauthenticated pool it ships with. |
| 3772 |
# |
| 3773 |
# Both are edits to a file the chrony rpm owns. /etc is writable on a bootc |
| 3774 |
# deployment so this is legal, and it is the same arrangement as the Firefox |
| 3775 |
# pref file: the package's file, edited in place, with the edit stated here. |
| 3776 |
# The pool line is commented rather than deleted so an operator reading the |
| 3777 |
# file can see what was turned off and why. |
| 3778 |
# |
| 3779 |
# `chronyd -p` parses the configuration and exits, which is what makes this |
| 3780 |
# an assertion instead of a hope. Without it, a typo in a server name would |
| 3781 |
# be discovered by a machine that silently never synchronises. |
| 3782 |
# ===================================================================== |
| 3783 |
RUN set -eux; \ |
| 3784 |
conf=/etc/chrony.conf; \ |
| 3785 |
drop=/etc/chrony.d/10-alloy-nts.conf; \ |
| 3786 |
[ -f "$drop" ] || { echo "$drop did not land from the config tree" >&2; exit 1; }; \ |
| 3787 |
grep -q '^pool ' "$conf" \ |
| 3788 |
|| { echo "$conf has no pool line; the base changed and this edit no longer describes it" >&2; exit 1; }; \ |
| 3789 |
sed -i 's|^pool |# Commented by Alloy: unauthenticated NTP. See /etc/chrony.d/10-alloy-nts.conf.\n# pool |' "$conf"; \ |
| 3790 |
printf '\n# Added by Alloy: read /etc/chrony.d for the NTS sources.\nconfdir /etc/chrony.d\n' >> "$conf"; \ |
| 3791 |
! grep -q '^pool ' "$conf" \ |
| 3792 |
|| { echo "the pool line survived the edit; the machine would still take unauthenticated time" >&2; exit 1; }; \ |
| 3793 |
chronyd -p >/dev/null \ |
| 3794 |
|| { echo "chronyd rejects the configuration after the Alloy edits" >&2; exit 1; }; \ |
| 3795 |
sources=$(chronyd -p 2>/dev/null | grep -c '^server .* nts$' || true); \ |
| 3796 |
[ "$sources" -ge 4 ] \ |
| 3797 |
|| { echo "chronyd parsed the config and sees $sources NTS sources; the confdir is not being read" >&2; exit 1; }; \ |
| 3798 |
echo "chrony: pool commented, confdir /etc/chrony.d, $sources NTS sources" |
| 3799 |
|
| 3800 |
# ===================================================================== |
| 3801 |
# Hardening, part 4: assert the drop-in files are not inert. |
| 3802 |
# |
| 3803 |
# Six files landed from the config tree with nothing else to turn them on. |
| 3804 |
# Each one has a way of being present and doing nothing, and every one of |
| 3805 |
# those ways is silent on a built image: |
| 3806 |
# |
| 3807 |
# - a sysctl file that loses to a higher-numbered one, so its values never |
| 3808 |
# take. The numbering is the whole defence and it is checked, not trusted. |
| 3809 |
# - a modprobe file using `blacklist` where it means `install`, which stops |
| 3810 |
# autoload and permits a direct modprobe. |
| 3811 |
# - a kargs file whose new entries did not survive template rendering. |
| 3812 |
# - a sway directive in a file the compositor does not include. |
| 3813 |
# - a tmpfiles line naming a source that is not in the image, so the flatpak |
| 3814 |
# overrides never reach the only path flatpak reads. |
| 3815 |
# ===================================================================== |
| 3816 |
RUN set -eux; \ |
| 3817 |
hard=/usr/lib/sysctl.d/90-alloy-hardening.conf; \ |
| 3818 |
core=/usr/lib/sysctl.d/95-alloy-coredump.conf; \ |
| 3819 |
[ -f "$hard" ] && [ -f "$core" ] \ |
| 3820 |
|| { echo "the sysctl drop-ins did not land" >&2; exit 1; }; \ |
| 3821 |
last=$(ls /usr/lib/sysctl.d/*.conf /etc/sysctl.d/*.conf 2>/dev/null \ |
| 3822 |
| xargs -n1 basename \ |
| 3823 |
| grep -v -e '^9[05]-alloy-' -e '^99-sysctl\.conf$' \ |
| 3824 |
| sort | tail -1); \ |
| 3825 |
[ "$(printf '%s\n%s\n' "$last" 90-alloy-hardening.conf | sort | tail -1)" = 90-alloy-hardening.conf ] \ |
| 3826 |
|| { echo "$last sorts after the Alloy sysctl drop-ins and would win any key they share" >&2; exit 1; }; \ |
| 3827 |
grep -q '^kernel.core_pattern=|/bin/false' "$core" \ |
| 3828 |
|| { echo "$core does not disable cores; an empty core_pattern means 'default name', not 'off'" >&2; exit 1; }; \ |
| 3829 |
mod=/usr/lib/modprobe.d/50-alloy-blacklist.conf; \ |
| 3830 |
[ -f "$mod" ] || { echo "$mod did not land" >&2; exit 1; }; \ |
| 3831 |
! grep -q '^blacklist ' "$mod" \ |
| 3832 |
|| { echo "$mod uses 'blacklist', which a direct modprobe ignores; it must use 'install <mod> /bin/false'" >&2; exit 1; }; \ |
| 3833 |
grep -q '^install squashfs' "$mod" \ |
| 3834 |
&& { echo "$mod blacklists squashfs; the installer ISO boots a live root out of one and would stop booting" >&2; exit 1; }; \ |
| 3835 |
echo "modprobe: $(grep -c '^install ' "$mod") modules refused, squashfs left loadable for the ISO"; \ |
| 3836 |
kargs=/usr/lib/bootc/kargs.d/10-alloy.toml; \ |
| 3837 |
[ -f "$kargs" ] || { echo "$kargs did not land from the render stage" >&2; exit 1; }; \ |
| 3838 |
for karg in init_on_free=1 slab_nomerge page_alloc.shuffle=1 randomize_kstack_offset=on vsyscall=none debugfs=off; do \ |
| 3839 |
grep -q "\"$karg\"" "$kargs" \ |
| 3840 |
|| { echo "$kargs is missing $karg; the render dropped it" >&2; exit 1; }; \ |
| 3841 |
done; \ |
| 3842 |
swayconf=/etc/sway/config.d/00-alloy.conf; \ |
| 3843 |
grep -q '^xwayland disable' "$swayconf" \ |
| 3844 |
|| { echo "$swayconf does not disable Xwayland" >&2; exit 1; }; \ |
| 3845 |
tmpf=/usr/lib/tmpfiles.d/50-alloy-flatpak.conf; \ |
| 3846 |
src=/usr/share/alloy/flatpak/global; \ |
| 3847 |
[ -f "$tmpf" ] && [ -f "$src" ] \ |
| 3848 |
|| { echo "the flatpak override or its tmpfiles rule did not land" >&2; exit 1; }; \ |
| 3849 |
grep -q "^C .*$src\$" "$tmpf" \ |
| 3850 |
|| { echo "$tmpf does not copy $src; flatpak reads /var/lib/flatpak/overrides/global and nothing else" >&2; exit 1; }; \ |
| 3851 |
systemd-tmpfiles --dry-run --create "$tmpf" >/dev/null \ |
| 3852 |
|| { echo "systemd-tmpfiles rejects $tmpf" >&2; exit 1; }; \ |
| 3853 |
dns=/etc/systemd/resolved.conf.d/10-alloy-dns.conf; \ |
| 3854 |
grep -q '^DNSOverTLS=opportunistic' "$dns" \ |
| 3855 |
|| { echo "$dns does not set DNSOverTLS, or sets 'yes' before anyone measured it against MagicDNS" >&2; exit 1; }; \ |
| 3856 |
echo "hardening drop-ins: sysctl, modprobe, kargs, sway, flatpak, resolved — all present and non-inert" |
| 3857 |
|
| 3858 |
# ===================================================================== |
| 3859 |
# Systemd presets — shipped via etc/systemd/{system,user}-preset/ |
| 3860 |
# in the config tree above. Split across system-preset (greetd, |
| 3861 |
# tailscaled) and user-preset (swayosd, syncthing, gammastep). |
| 3862 |
# See docs/CONTINUITY.md for rationale. |
| 3863 |
# |
| 3864 |
# The preset files declare the intended enable/disable state; they |
| 3865 |
# do not by themselves create the wants/ symlinks. `systemctl |
| 3866 |
# preset-all` reads every preset file and applies it — this must |
| 3867 |
# run after the config tree is in place, so it lives here. |
| 3868 |
# ===================================================================== |
| 3869 |
|
| 3870 |
# The SwayOSD unit is already on the search path by the time preset-all runs: |
| 3871 |
# the copy that puts it there is up with the rest of the packaging shims, next |
| 3872 |
# to the udev rule that has the same defect. A second block used to stand here |
| 3873 |
# doing the same job with a symlink, guarded by `if [ -e "$canon" ]`. Because |
| 3874 |
# the copy had already created that path, the guard always took its first |
| 3875 |
# branch and printed "the shim is already in the search path" — which read |
| 3876 |
# like the workaround having become unnecessary, when in fact the earlier copy |
| 3877 |
# was what satisfied it. Two fixes for one bug, landed the same day, and the |
| 3878 |
# survivor is the one that fails the build loudly when Fedora corrects the |
| 3879 |
# packaging rather than the one that quietly steps aside. |
| 3880 |
# ===================================================================== |
| 3881 |
# The server profile's prune, and its preset overlay. |
| 3882 |
# ===================================================================== |
| 3883 |
# Everything above this point has run, including every client-only |
| 3884 |
# assertion, so this is the last safe place to remove the desktop |
| 3885 |
# skeleton: the alloy-shot and alloy-dim checks read the shipped sway |
| 3886 |
# config, and pruning it earlier would have made them unverifiable rather |
| 3887 |
# than merely inapplicable. Order is the whole reason this is one block at |
| 3888 |
# the end instead of a branch inside the COPY layers. |
| 3889 |
# |
| 3890 |
# What survives the prune is deliberate. `alloy-vtrgb` and its two tables |
| 3891 |
# stay, and they matter more here than on a client: the bare console |
| 3892 |
# palette is the only themed surface a headless machine has, so it stops |
| 3893 |
# being first-impression polish and becomes the whole of the design |
| 3894 |
# system on that box (wiki `alloy-server-variant`). |
| 3895 |
# |
| 3896 |
# The preset overlay is a separate file rather than an edit to |
| 3897 |
# 50-alloy.preset, and it is numbered 40 so it sorts first — systemd |
| 3898 |
# takes the first matching line, so a `disable` here beats the `enable` |
| 3899 |
# below it without either file having to know about the other. Two units |
| 3900 |
# need it. greetd and swayosd would match nothing on this profile, which |
| 3901 |
# is the silent no-op this repo documents elsewhere; bluetooth is the |
| 3902 |
# live one, because bluez can arrive from the base and Fedora's |
| 3903 |
# 90-default.preset enables it, so without a line here a headless box |
| 3904 |
# would run a Bluetooth daemon by inheritance. Max ruled both bluetooth |
| 3905 |
# and cups off this profile on 2026-08-01. |
| 3906 |
RUN set -eu; \ |
| 3907 |
if [ "$PROFILE" = server ]; then \ |
| 3908 |
rm -rf /etc/skel/.config/sway \ |
| 3909 |
/etc/skel/.config/shop \ |
| 3910 |
/etc/skel/.config/mako \ |
| 3911 |
/etc/skel/.config/swaylock \ |
| 3912 |
/etc/skel/.config/satty \ |
| 3913 |
/etc/skel/.config/swayosd \ |
| 3914 |
/etc/skel/.config/imv \ |
| 3915 |
/etc/skel/.config/mpv \ |
| 3916 |
/etc/skel/.config/zathura \ |
| 3917 |
/etc/skel/.config/gtk-3.0 \ |
| 3918 |
/etc/skel/.config/gtk-4.0 \ |
| 3919 |
/etc/skel/.config/fontconfig \ |
| 3920 |
/usr/share/alloy/skel-night; \ |
| 3921 |
test ! -e /etc/skel/.config/sway \ |
| 3922 |
|| { echo "profile=server still carries the sway skeleton" >&2; exit 1; }; \ |
| 3923 |
test -s /usr/share/alloy/vtrgb \ |
| 3924 |
|| { echo "the prune took the console palette with it; vtrgb is the one themed surface this profile has" >&2; exit 1; }; \ |
| 3925 |
mkdir -p /etc/systemd/system-preset; \ |
| 3926 |
{ echo "# Alloy server profile: units the client enables that this profile must not."; \ |
| 3927 |
echo "# Sorts before 50-alloy.preset, and systemd takes the first match."; \ |
| 3928 |
echo "disable bluetooth.service"; \ |
| 3929 |
echo "disable cups.socket"; \ |
| 3930 |
echo "disable cups.path"; \ |
| 3931 |
echo "disable greetd.service"; \ |
| 3932 |
echo "disable swayosd-libinput-backend.service"; \ |
| 3933 |
} > /etc/systemd/system-preset/40-alloy-server.preset; \ |
| 3934 |
echo "server: pruned the desktop skeleton, wrote the preset overlay"; \ |
| 3935 |
else \ |
| 3936 |
test -e /etc/skel/.config/sway/config \ |
| 3937 |
|| { echo "profile=client is missing the sway skeleton" >&2; exit 1; }; \ |
| 3938 |
test ! -e /etc/systemd/system-preset/40-alloy-server.preset \ |
| 3939 |
|| { echo "profile=client picked up the server preset overlay" >&2; exit 1; }; \ |
| 3940 |
fi |
| 3941 |
|
| 3942 |
# ===================================================================== |
| 3943 |
# The firewall's default zone, which is where the two profiles differ. |
| 3944 |
# |
| 3945 |
# The pick and its rejected alternatives are docs/STACK.md `## Firewall`. |
| 3946 |
# Both profiles run firewalld; only the zone changes, and the zone is the |
| 3947 |
# whole of the policy. |
| 3948 |
# |
| 3949 |
# CLIENT: Fedora's stock `public`, untouched. It allows ssh, mdns and |
| 3950 |
# dhcpv6-client, which is exactly what 50-alloy.preset enables, so there is |
| 3951 |
# no Alloy-authored policy to drift from the preset. `FedoraWorkstation` is |
| 3952 |
# the zone this is NOT: it opens 1025-65535 on tcp and udp, and it is the |
| 3953 |
# desktop spin's default, so leaving the zone unstated would have been a |
| 3954 |
# plausible way to ship almost nothing. The client branch asserts the |
| 3955 |
# default is still `public` rather than assuming Fedora keeps it there. |
| 3956 |
# |
| 3957 |
# SERVER: `alloy-server`, installed from usr/share/alloy/firewalld/. Same |
| 3958 |
# set minus mdns, because this profile also runs on a public address. |
| 3959 |
# |
| 3960 |
# `firewall-offline-cmd` rather than `firewall-cmd`: there is no daemon in |
| 3961 |
# a build, and the offline tool writes /etc/firewalld/firewalld.conf |
| 3962 |
# directly. It also refuses a zone that does not parse, which is what makes |
| 3963 |
# this a check on the shipped XML rather than a file copy. |
| 3964 |
# ===================================================================== |
| 3965 |
RUN set -eu; \ |
| 3966 |
if [ "$PROFILE" = server ]; then \ |
| 3967 |
src=/usr/share/alloy/firewalld/alloy-server.xml; \ |
| 3968 |
test -f "$src" \ |
| 3969 |
|| { echo "$src did not land from the config tree" >&2; exit 1; }; \ |
| 3970 |
install -m 0644 "$src" /etc/firewalld/zones/alloy-server.xml; \ |
| 3971 |
firewall-offline-cmd --set-default-zone=alloy-server >/dev/null; \ |
| 3972 |
[ "$(firewall-offline-cmd --get-default-zone)" = alloy-server ] \ |
| 3973 |
|| { echo "the default zone did not take; the server would run the client's policy" >&2; exit 1; }; \ |
| 3974 |
firewall-offline-cmd --zone=alloy-server --query-service=ssh >/dev/null \ |
| 3975 |
|| { echo "the server zone does not permit ssh; the profile with no console would be unreachable" >&2; exit 1; }; \ |
| 3976 |
! firewall-offline-cmd --zone=alloy-server --query-service=mdns >/dev/null \ |
| 3977 |
|| { echo "the server zone permits mdns; that subtraction is the reason this zone exists" >&2; exit 1; }; \ |
| 3978 |
echo "firewall: default zone alloy-server (ssh, dhcpv6-client)"; \ |
| 3979 |
else \ |
| 3980 |
test ! -e /etc/firewalld/zones/alloy-server.xml \ |
| 3981 |
|| { echo "profile=client installed the server zone" >&2; exit 1; }; \ |
| 3982 |
[ "$(firewall-offline-cmd --get-default-zone)" = public ] \ |
| 3983 |
|| { echo "the default zone is not public; Fedora moved it and the client policy is now whatever it moved to" >&2; exit 1; }; \ |
| 3984 |
firewall-offline-cmd --zone=public --query-service=mdns >/dev/null \ |
| 3985 |
|| { echo "the public zone no longer permits mdns; the .local install flow would break" >&2; exit 1; }; \ |
| 3986 |
echo "firewall: default zone public (ssh, mdns, dhcpv6-client)"; \ |
| 3987 |
fi |
| 3988 |
|
| 3989 |
# ===================================================================== |
| 3990 |
# The firewall's one exempt interface, asserted on both profiles. |
| 3991 |
# |
| 3992 |
# etc/firewalld/zones/trusted.xml overrides the package's copy and adds |
| 3993 |
# `<interface name="tailscale0"/>`. Without it, firewalld drops every |
| 3994 |
# inbound tailnet connection the moment it starts: tailscaled writes its own |
| 3995 |
# nftables tables, and a base chain's accept in one table does not survive |
| 3996 |
# another table's drop on the same hook. That is measured, in a network |
| 3997 |
# namespace, and the file carries the detail. |
| 3998 |
# |
| 3999 |
# The failure this asserts against is silent and specific: a zone file that |
| 4000 |
# is present, parses, and binds nothing. Reading the file back through |
| 4001 |
# firewalld is the only way to tell that from a working one. |
| 4002 |
# ===================================================================== |
| 4003 |
RUN set -eu; \ |
| 4004 |
zone=/etc/firewalld/zones/trusted.xml; \ |
| 4005 |
test -f "$zone" \ |
| 4006 |
|| { echo "$zone did not land from the config tree" >&2; exit 1; }; \ |
| 4007 |
firewall-offline-cmd --zone=trusted --query-interface=tailscale0 >/dev/null \ |
| 4008 |
|| { echo "tailscale0 is not bound to the trusted zone; enabling tailscaled would kill inbound tailnet traffic" >&2; exit 1; }; \ |
| 4009 |
[ "$(firewall-offline-cmd --zone=trusted --get-target)" = ACCEPT ] \ |
| 4010 |
|| { echo "the trusted zone no longer accepts; the binding above would bind to a zone that drops" >&2; exit 1; }; \ |
| 4011 |
echo "firewall: tailscale0 bound to the trusted zone" |
| 4012 |
|
| 4013 |
# ===================================================================== |
| 4014 |
# USBGuard is present and is NOT armed, asserted on both profiles. |
| 4015 |
# |
| 4016 |
# This is the unusual case where the assertion's job is to hold a feature |
| 4017 |
# OFF. The ruling (GoingsOn alloy 63de3d4c) is enforcement on by default, |
| 4018 |
# and the daemon is one preset line away from that — which is exactly the |
| 4019 |
# problem, because the policy it would enforce today is the package's |
| 4020 |
# stock one and the package's stock policy denies everything. |
| 4021 |
# |
| 4022 |
# Four facts, all read off the fedora-43 package on 2026-08-22 rather |
| 4023 |
# than out of its documentation, and each is why one line below exists: |
| 4024 |
# |
| 4025 |
# ImplicitPolicyTarget=block a device matching no rule is blocked |
| 4026 |
# PresentDevicePolicy=apply-policy including devices already attached |
| 4027 |
# InsertedDevicePolicy=apply-policy and ones plugged in later |
| 4028 |
# /etc/usbguard/rules.conf ships EMPTY, so nothing matches a rule |
| 4029 |
# |
| 4030 |
# Together those four are "deauthorize every USB device at boot". On a |
| 4031 |
# desktop that is the keyboard, and the machine is then unrecoverable |
| 4032 |
# without another one. So the shape of this check is: the package must be |
| 4033 |
# here (the console's action half depends on it), the knobs must still |
| 4034 |
# read the way they were measured (if Fedora changes one, the reasoning |
| 4035 |
# above stops holding and someone must look again), and the unit must not |
| 4036 |
# be enabled until the policy and the keyboard gate exist. |
| 4037 |
# |
| 4038 |
# The fourth line of that check — that the unit is not enabled — cannot |
| 4039 |
# live here: `systemctl preset-all` runs several hundred lines further |
| 4040 |
# down, so an assertion at this point would read the state before anything |
| 4041 |
# had a chance to arm the daemon and would pass on an image that boots |
| 4042 |
# armed. It sits immediately after preset-all instead. |
| 4043 |
# ===================================================================== |
| 4044 |
RUN set -eu; \ |
| 4045 |
conf=/etc/usbguard/usbguard-daemon.conf; \ |
| 4046 |
test -f "$conf" \ |
| 4047 |
|| { echo "$conf is missing; the usbguard package did not land" >&2; exit 1; }; \ |
| 4048 |
command -v usbguard >/dev/null \ |
| 4049 |
|| { echo "the usbguard CLI is missing; alloy usb has nothing to front for its action half" >&2; exit 1; }; \ |
| 4050 |
for knob in ImplicitPolicyTarget=block PresentDevicePolicy=apply-policy InsertedDevicePolicy=apply-policy; do \ |
| 4051 |
grep -qx "$knob" "$conf" \ |
| 4052 |
|| { echo "$conf no longer reads $knob; the default-deny reasoning in the package block was measured against it and needs re-reading" >&2; exit 1; }; \ |
| 4053 |
done; \ |
| 4054 |
test ! -s /etc/usbguard/rules.conf \ |
| 4055 |
|| { echo "/etc/usbguard/rules.conf is no longer empty; Fedora shipped a policy and this image would enforce someone else's" >&2; exit 1; }; \ |
| 4056 |
echo "usbguard: installed, stock deny-all policy (the disarmed check runs after preset-all)" |
| 4057 |
|
| 4058 |
# ===================================================================== |
| 4059 |
# The USB keyboard gate: on the client, and deliberately absent on the server. |
| 4060 |
# |
| 4061 |
# Step 4 of the `alloy usb` work, and the clause that makes deny-unknown safe |
| 4062 |
# to arm. It drops USB enforcement whenever the machine has zero usable |
| 4063 |
# keyboards and restores it when one appears. The reasoning, and the two |
| 4064 |
# measurements it rests on, are in usr/bin/alloy-usb-gate; the short version is |
| 4065 |
# that suspending enforcement means stopping the daemon AND re-authorizing the |
| 4066 |
# bus, because usbguard restores nothing on its way out. |
| 4067 |
# |
| 4068 |
# THE SPLIT IS THE RULING'S, and it is the opposite of what "the client is the |
| 4069 |
# careful profile" would suggest. A server-profile machine has no keyboard by |
| 4070 |
# design, so a gate counting keyboards would find zero on a healthy box and hold |
| 4071 |
# enforcement off forever, on exactly the machines that are physically exposed |
| 4072 |
# and hardest to visit. That profile enforces unconditionally and its recovery |
| 4073 |
# path is the provider console or a KVM. |
| 4074 |
# |
| 4075 |
# So the three files ride in from the COPY layers on both profiles, and the |
| 4076 |
# server branch takes them out again. Both branches assert, per the rule this |
| 4077 |
# file follows throughout and tests/profile_split.rs enforces: the client proves |
| 4078 |
# the gate is really there, and the server proves it is really gone rather than |
| 4079 |
# trusting that a branch it did not take was the right one. |
| 4080 |
# |
| 4081 |
# The client branch also checks the udev rule's number, which is load-bearing |
| 4082 |
# rather than cosmetic. The property the script reads is ID_INPUT_KEYBOARD, and |
| 4083 |
# udev's input_id builtin is what sets it; a rule sorting ahead of that reads |
| 4084 |
# the property before anything wrote it, counts zero keyboards on a machine full |
| 4085 |
# of them, and opens the gate on every boot. |
| 4086 |
# ===================================================================== |
| 4087 |
RUN set -eu; \ |
| 4088 |
gate=/usr/bin/alloy-usb-gate; \ |
| 4089 |
unit=/etc/systemd/system/alloy-usb-gate.service; \ |
| 4090 |
rule=/etc/udev/rules.d/70-alloy-usb-gate.rules; \ |
| 4091 |
if [ "$PROFILE" = client ]; then \ |
| 4092 |
test -x "$gate" \ |
| 4093 |
|| { echo "$gate is missing or not executable; deny-unknown has no release valve on the profile that has a keyboard to lose" >&2; exit 1; }; \ |
| 4094 |
sh -n "$gate" \ |
| 4095 |
|| { echo "$gate does not parse; the release valve is a syntax error and nobody finds out until a machine has no keyboard" >&2; exit 1; }; \ |
| 4096 |
test -f "$unit" \ |
| 4097 |
|| { echo "$unit is missing; 50-alloy.preset enables a unit that does not exist and the gate never runs" >&2; exit 1; }; \ |
| 4098 |
test -f "$rule" \ |
| 4099 |
|| { echo "$rule is missing; the gate would be boot-only, and a keyboard that dies mid-session is the same lockout" >&2; exit 1; }; \ |
| 4100 |
grep -q 'ID_INPUT_KEYBOARD' "$gate" \ |
| 4101 |
|| { echo "$gate no longer reads ID_INPUT_KEYBOARD; ID_INPUT_KEY is set by power buttons and consumer controls, and counting those keeps the gate shut on a machine with no keyboard" >&2; exit 1; }; \ |
| 4102 |
case "$(basename "$rule")" in \ |
| 4103 |
[7-9][0-9]-*) : ;; \ |
| 4104 |
*) echo "$rule sorts before udev's input_id builtin, so ID_INPUT_KEYBOARD is unset when the script reads it and every boot counts zero keyboards" >&2; exit 1 ;; \ |
| 4105 |
esac; \ |
| 4106 |
echo "usb gate: installed on the client profile"; \ |
| 4107 |
else \ |
| 4108 |
rm -f "$gate" "$unit" "$rule"; \ |
| 4109 |
test ! -e "$unit" \ |
| 4110 |
|| { echo "profile=server still carries the USB keyboard gate; a machine with no keyboard by design would hold enforcement off forever" >&2; exit 1; }; \ |
| 4111 |
test ! -e "$gate" \ |
| 4112 |
|| { echo "profile=server still carries $gate" >&2; exit 1; }; \ |
| 4113 |
test ! -e "$rule" \ |
| 4114 |
|| { echo "profile=server still carries the gate's udev rule, which would start a unit that is no longer there on every input event" >&2; exit 1; }; \ |
| 4115 |
echo "usb gate: correctly absent on a profile with no keyboard to lock out"; \ |
| 4116 |
fi |
| 4117 |
|
| 4118 |
# ===================================================================== |
| 4119 |
# Machine identity, from the builder: hostname and ssh pubkey. |
| 4120 |
# ===================================================================== |
| 4121 |
# The minting half of `alloy image` (wiki `alloy-image-minting`, folded |
| 4122 |
# into the builder by GO alloy 1372b159). Both values are PUBLIC, which |
| 4123 |
# is the property the whole design rests on: the artifact holds no |
| 4124 |
# secrets, so it can be kept, copied or rebuilt without care and a leak |
| 4125 |
# of it costs nothing. Do not add anything here that changes that. |
| 4126 |
# |
| 4127 |
# The hostname is baked because it is how a headless box is found. avahi |
| 4128 |
# publishes `<name>.local` and NetworkManager sends the same name in DHCP |
| 4129 |
# option 12, so the machine appears in the router's lease table too. Known |
| 4130 |
# in advance means the instruction to reach it is exact rather than a |
| 4131 |
# hunt, and it means two minted machines never collide. |
| 4132 |
# |
| 4133 |
# The pubkey is the installer's only credential. No password auth, no |
| 4134 |
# one-time code on a console the machine does not have, no pairing |
| 4135 |
# window in which a box on the LAN is takeoverable. Explicitly NOT the |
| 4136 |
# subiquity pattern. |
| 4137 |
# |
| 4138 |
# Both default empty, which is a real state: an image built without them |
| 4139 |
# is the ordinary desktop install where a person is sitting at the |
| 4140 |
# machine and types their own answers. |
| 4141 |
ARG ALLOY_HOSTNAME= |
| 4142 |
ARG ALLOY_SSH_KEY= |
| 4143 |
|
| 4144 |
# The hostname is baked by rewriting DEFAULT_HOSTNAME in os-release, and |
| 4145 |
# NOT by writing /etc/hostname. Two measurements, 2026-08-09, forced that: |
| 4146 |
# |
| 4147 |
# 1. podman bind mounts /etc/hostname into the container for the duration |
| 4148 |
# of every RUN, so a write there lands on a throwaway file and is |
| 4149 |
# discarded at commit. The step printed "identity: hostname alloytest", |
| 4150 |
# exited 0, and shipped an image that still said `alloy`. Silent, |
| 4151 |
# because nothing reads the value back at build time. |
| 4152 |
# |
| 4153 |
# 2. Writing /usr/lib/hostname instead — systemd's vendor default — does |
| 4154 |
# not help either, and this is the part that is easy to get wrong twice. |
| 4155 |
# systemd consults it only when /etc/hostname is ABSENT, and podman |
| 4156 |
# leaves its mount target behind as an empty 0700 file in the layer. An |
| 4157 |
# empty /etc/hostname is not a hostname, so systemd falls through to the |
| 4158 |
# default; it does not go on to read /usr/lib/hostname. `rm` cannot |
| 4159 |
# clear it either: the file is a live mount during the RUN and unlink |
| 4160 |
# fails with EBUSY. A container build cannot produce an image whose |
| 4161 |
# /etc/hostname is missing. |
| 4162 |
# |
| 4163 |
# DEFAULT_HOSTNAME is what systemd fell through TO, so it is the one lever |
| 4164 |
# a RUN can still reach: /usr/lib/os-release arrives by `COPY usr/ /usr/` |
| 4165 |
# above and no bind mount covers it. It is also the right meaning — a |
| 4166 |
# fallback for a machine with no static hostname set, which is exactly what |
| 4167 |
# a freshly minted medium is. The installer still wins on the machine it |
| 4168 |
# installs: `systemd-firstboot --hostname` writes a real /etc/hostname |
| 4169 |
# there. Everything downstream (avahi's `<name>.local`, NetworkManager's |
| 4170 |
# DHCP option 12) asks hostnamed, so it sees this. |
| 4171 |
# |
| 4172 |
# `etc/hostname` stays out of this repo's `etc/` tree for the same reason: |
| 4173 |
# `COPY etc/ /etc/` is not a RUN and does land, so shipping one would put a |
| 4174 |
# real static hostname on top of the baked fallback and undo all of this. |
| 4175 |
# ------------------------------------------------------------------ the name |
| 4176 |
# the firmware shows ----------------------------------------------------- |
| 4177 |
# |
| 4178 |
# The third place the image said Fedora, and the only one a user meets before |
| 4179 |
# anything of ours has run. alloy@a558726 gave the system one identity at |
| 4180 |
# /usr/lib/os-release and alloy@a558726's successor fixed GRUB's own menu; this |
| 4181 |
# is one layer earlier than either, in the firmware's boot menu. |
| 4182 |
# |
| 4183 |
# HOW THE NAME GETS THERE. shim ships BOOTX64.CSV beside itself. When the |
| 4184 |
# machine has no NVRAM entry pointing at shim, the firmware runs |
| 4185 |
# \EFI\BOOT\BOOTX64.EFI, which is shim, which chain-loads fbx64.efi, which reads |
| 4186 |
# this CSV and creates the entry. Field 1 is the binary to point at and field 2 |
| 4187 |
# is the description the boot menu shows. Stock, it reads |
| 4188 |
# `shimx64.efi,Fedora,,This is the boot entry for Fedora`. |
| 4189 |
# |
| 4190 |
# THIS IS NOT SUFFICIENT ON ITS OWN, and this comment said it was until |
| 4191 |
# 2026-09-07. It claimed the no-entry case was "every freshly installed machine, |
| 4192 |
# because bootupd writes the ESP and calls no efibootmgr". Measured on a fresh |
| 4193 |
# fw12 install that day: the installed ESP's CSV reads `shimx64.efi,Alloy,,...` |
| 4194 |
# and `efibootmgr` reports two entries named Fedora. Something on the install |
| 4195 |
# path makes them, so fbx64 never runs and this file is never read. |
| 4196 |
# |
| 4197 |
# What this RUN is still for: the machine whose NVRAM is cleared, which then |
| 4198 |
# rebuilds its entry from the right CSV. The ordinary install is named by |
| 4199 |
# usr/bin/alloy-boot-entry, which crates/alloy/src/install/plan.rs runs after |
| 4200 |
# the deploy. The two are not redundant -- they cover different moments -- and |
| 4201 |
# neither covers the other's. |
| 4202 |
# |
| 4203 |
# WHAT IS NOT TOUCHED, deliberately. The vendor directory stays EFI/fedora: the |
| 4204 |
# path is baked into the signed shim, and renaming it is a Secure Boot question |
| 4205 |
# rather than a branding one. Field 1 stays shimx64.efi for the same reason. |
| 4206 |
# Only the description changes, and it is not signed content -- fbx64 reads it |
| 4207 |
# as data. |
| 4208 |
# |
| 4209 |
# UTF-16LE with a BOM, because that is what fbx64 parses. Written with iconv |
| 4210 |
# rather than by hand so the encoding is produced rather than hoped for, and |
| 4211 |
# read back below, because a CSV that fails to parse leaves a machine whose |
| 4212 |
# firmware entry is whatever the firmware invents. |
| 4213 |
# |
| 4214 |
# This does not reach the installer medium's own entry in a firmware menu. That |
| 4215 |
# name is built from the USB device's descriptor -- measured 2026-09-07 under |
| 4216 |
# OVMF, which showed `UEFI QEMU QEMU USB HARDDRIVE 1-0000:00:03.0-1` for our |
| 4217 |
# medium -- and nothing written to a stick changes it. |
| 4218 |
RUN set -eu; \ |
| 4219 |
csv=/usr/lib/bootupd/updates/EFI/fedora/BOOTX64.CSV; \ |
| 4220 |
[ -f "$csv" ] || { echo "no BOOTX64.CSV in the bootupd payload" >&2; exit 1; }; \ |
| 4221 |
printf 'shimx64.efi,Alloy,,This is the boot entry for Alloy\r\n' \ |
| 4222 |
| iconv -f UTF-8 -t UTF-16LE > /tmp/csv.body; \ |
| 4223 |
printf '\xff\xfe' > "$csv"; \ |
| 4224 |
cat /tmp/csv.body >> "$csv"; \ |
| 4225 |
rm -f /tmp/csv.body; \ |
| 4226 |
iconv -f UTF-16LE -t UTF-8 < "$csv" | grep -q 'shimx64\.efi,Alloy,,' \ |
| 4227 |
|| { echo "the boot entry description did not land in BOOTX64.CSV" >&2; exit 1; }; \ |
| 4228 |
if grep -q Fedora "$csv"; then \ |
| 4229 |
echo "BOOTX64.CSV still names Fedora" >&2; exit 1; \ |
| 4230 |
fi; \ |
| 4231 |
echo "identity: firmware boot entry says Alloy" |
| 4232 |
|
| 4233 |
RUN set -eu; \ |
| 4234 |
mkdir -p /usr/lib/alloy; \ |
| 4235 |
if [ -n "$ALLOY_HOSTNAME" ]; then \ |
| 4236 |
echo "$ALLOY_HOSTNAME" | grep -qE '^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$' \ |
| 4237 |
|| { echo "ALLOY_HOSTNAME '$ALLOY_HOSTNAME' is not a hostname" >&2; exit 1; }; \ |
| 4238 |
sed -i "s/^DEFAULT_HOSTNAME=.*/DEFAULT_HOSTNAME=$ALLOY_HOSTNAME/" /usr/lib/os-release; \ |
| 4239 |
echo "identity: hostname $ALLOY_HOSTNAME"; \ |
| 4240 |
else \ |
| 4241 |
echo "identity: no hostname baked in"; \ |
| 4242 |
fi; \ |
| 4243 |
grep -q "^DEFAULT_HOSTNAME=${ALLOY_HOSTNAME:-alloy}$" /usr/lib/os-release \ |
| 4244 |
|| { echo "the baked hostname did not land in os-release" >&2; exit 1; }; \ |
| 4245 |
if [ -n "$ALLOY_SSH_KEY" ]; then \ |
| 4246 |
case "$ALLOY_SSH_KEY" in \ |
| 4247 |
ssh-*|ecdsa-*|sk-*) ;; \ |
| 4248 |
*) echo "ALLOY_SSH_KEY is not an SSH public key. If this is a PRIVATE key, do not bake it in." >&2; exit 1 ;; \ |
| 4249 |
esac; \ |
| 4250 |
case "$ALLOY_SSH_KEY" in \ |
| 4251 |
*PRIVATE*) echo "ALLOY_SSH_KEY looks like a private key; refusing" >&2; exit 1 ;; \ |
| 4252 |
esac; \ |
| 4253 |
printf '%s\n' "$ALLOY_SSH_KEY" > /usr/lib/alloy/authorized_keys; \ |
| 4254 |
chmod 0644 /usr/lib/alloy/authorized_keys; \ |
| 4255 |
echo "identity: one public key baked in"; \ |
| 4256 |
else \ |
| 4257 |
echo "identity: no key baked in"; \ |
| 4258 |
fi |
| 4259 |
|
| 4260 |
# ===================================================================== |
| 4261 |
# The answer sheet — the install questions the builder already answered. |
| 4262 |
# ===================================================================== |
| 4263 |
# `alloy install` asks five questions, and on a machine the builder already |
| 4264 |
# knows about most of them have one right answer, decided when the medium was |
| 4265 |
# minted. These arguments are those answers, and the RUN below writes them to |
| 4266 |
# /usr/lib/alloy/answers.toml, which crates/alloy/src/preseed.rs reads once when |
| 4267 |
# the wizard opens. A step the sheet answers in full is skipped. |
| 4268 |
# |
| 4269 |
# This is the same idea as the pubkey above and lives beside it deliberately: |
| 4270 |
# what the builder knew, carried on the medium so the installer does not ask |
| 4271 |
# again. Alloy is distributed as a builder rather than as an image (wiki |
| 4272 |
# `alloy-distribution`), so the person minting is the person installing, and |
| 4273 |
# their recipe is their answer sheet. Per-host recipes live in build/hosts/. |
| 4274 |
# |
| 4275 |
# **No secrets, and this is the line that must not move.** Not the account |
| 4276 |
# password, not the LUKS passphrase. The identity block above states the |
| 4277 |
# invariant: the image "can be kept, copied or rebuilt without care and a leak |
| 4278 |
# of it costs nothing". A passphrase written here would be in every layer cache, |
| 4279 |
# every `podman save` and on every stick written from the medium. So the account |
| 4280 |
# and encryption steps are still asked, with everything except the secret |
| 4281 |
# already filled in, and `ALLOY_ENCRYPT=yes` answers only the checkbox. |
| 4282 |
# |
| 4283 |
# ALLOY_DISK is a rule and never a device path. A medium that erases |
| 4284 |
# /dev/nvme0n1 without asking is one wrong laptop away from erasing the wrong |
| 4285 |
# machine; a rule that stops matching falls back to asking, which is the |
| 4286 |
# behaviour the installer's tests pin. |
| 4287 |
ARG ALLOY_USERNAME= |
| 4288 |
ARG ALLOY_ENCRYPT= |
| 4289 |
ARG ALLOY_LOCATE_TIMEZONE= |
| 4290 |
ARG ALLOY_DISK= |
| 4291 |
|
| 4292 |
RUN set -eu; \ |
| 4293 |
mkdir -p /usr/lib/alloy; \ |
| 4294 |
sheet=/usr/lib/alloy/answers.toml; \ |
| 4295 |
rm -f "$sheet"; \ |
| 4296 |
# A yes/no argument that is neither is a typo, and a typo that defaulted |
| 4297 |
# quietly would decide encryption for a machine nobody asked. Refuse it. |
| 4298 |
bool() { \ |
| 4299 |
case "$2" in \ |
| 4300 |
yes|true|on) echo "$1 = true" >> "$sheet" ;; \ |
| 4301 |
no|false|off) echo "$1 = false" >> "$sheet" ;; \ |
| 4302 |
*) echo "$1 is '$2'; expected yes or no" >&2; exit 1 ;; \ |
| 4303 |
esac; \ |
| 4304 |
}; \ |
| 4305 |
if [ -n "$ALLOY_HOSTNAME" ]; then \ |
| 4306 |
echo "hostname = \"$ALLOY_HOSTNAME\"" >> "$sheet"; \ |
| 4307 |
fi; \ |
| 4308 |
if [ -n "$ALLOY_USERNAME" ]; then \ |
| 4309 |
echo "$ALLOY_USERNAME" | grep -qE '^[a-z_][a-z0-9_-]{0,31}$' \ |
| 4310 |
|| { echo "ALLOY_USERNAME '$ALLOY_USERNAME' is not a username" >&2; exit 1; }; \ |
| 4311 |
echo "username = \"$ALLOY_USERNAME\"" >> "$sheet"; \ |
| 4312 |
fi; \ |
| 4313 |
if [ -n "$ALLOY_DISK" ]; then \ |
| 4314 |
case "$ALLOY_DISK" in \ |
| 4315 |
single-internal|single-internal-nvme) ;; \ |
| 4316 |
*) echo "ALLOY_DISK '$ALLOY_DISK' is not a rule; expected single-internal or single-internal-nvme. A device path is deliberately not accepted here" >&2; exit 1 ;; \ |
| 4317 |
esac; \ |
| 4318 |
echo "disk = \"$ALLOY_DISK\"" >> "$sheet"; \ |
| 4319 |
fi; \ |
| 4320 |
[ -z "$ALLOY_ENCRYPT" ] || bool encrypt "$ALLOY_ENCRYPT"; \ |
| 4321 |
[ -z "$ALLOY_LOCATE_TIMEZONE" ] || bool locate_timezone "$ALLOY_LOCATE_TIMEZONE"; \ |
| 4322 |
if [ -f "$sheet" ]; then \ |
| 4323 |
chmod 0644 "$sheet"; \ |
| 4324 |
# The hostname is written twice from one argument, here and into |
| 4325 |
# DEFAULT_HOSTNAME above, so the installer's default and its statement |
| 4326 |
# that the question is answered cannot disagree. Asserted rather than |
| 4327 |
# trusted, because they are two `sed`-shaped writes in two steps. |
| 4328 |
if [ -n "$ALLOY_HOSTNAME" ]; then \ |
| 4329 |
grep -q "^hostname = \"$ALLOY_HOSTNAME\"$" "$sheet" \ |
| 4330 |
&& grep -q "^DEFAULT_HOSTNAME=$ALLOY_HOSTNAME$" /usr/lib/os-release \ |
| 4331 |
|| { echo "the answer sheet and os-release disagree about the hostname" >&2; exit 1; }; \ |
| 4332 |
fi; \ |
| 4333 |
echo "answers: $(wc -l < "$sheet") prefilled"; \ |
| 4334 |
sed 's/^/answers: /' "$sheet"; \ |
| 4335 |
else \ |
| 4336 |
echo "answers: none, every question will be asked"; \ |
| 4337 |
fi |
| 4338 |
|
| 4339 |
# ===================================================================== |
| 4340 |
# The build stamp — which build of the product this image is. |
| 4341 |
# ===================================================================== |
| 4342 |
# Three numbers describe an Alloy machine and they move on three different |
| 4343 |
# clocks, which is why they are three fields and not one: |
| 4344 |
# |
| 4345 |
# VERSION_ID the product. Moves on a release, by hand. |
| 4346 |
# IMAGE_VERSION the build. Moves every build, stamped here. |
| 4347 |
# ALLOY_BASE the Fedora base. Moves when the FROM line does. |
| 4348 |
# |
| 4349 |
# VERSION and PRETTY_NAME are freeform display and carry all three, which |
| 4350 |
# is what a support conversation reads back. |
| 4351 |
# |
| 4352 |
# The stamp is not in the committed os-release. A placeholder there would |
| 4353 |
# be a lie on any machine where this step silently stopped working, and |
| 4354 |
# `usr/lib/os-release` ships `(build <n>, ...)` precisely so that a literal |
| 4355 |
# `<n>` reaching an installed machine is a visible failure rather than a |
| 4356 |
# plausible number. Both branches below rewrite it and the grep at the end |
| 4357 |
# proves it is gone. |
| 4358 |
# |
| 4359 |
# Empty default is a real state: a bare `podman build` with no wrapper |
| 4360 |
# produces an honestly unstamped image, and keeps its layer cache, since an |
| 4361 |
# always-changing value here would invalidate every step after it. The |
| 4362 |
# wrapper scripts (build/build-image.sh, build/build-iso.sh) always pass |
| 4363 |
# one, so anything that can become an artifact is stamped. |
| 4364 |
# |
| 4365 |
# Format is <date>.<serial>: a commit count was considered and rejected |
| 4366 |
# because it is identical across rebuilds of one commit, which is exactly |
| 4367 |
# the pair this field exists to tell apart. The wrappers use the UTC time |
| 4368 |
# of day as the serial, so same-day rebuilds differ with no state kept |
| 4369 |
# anywhere; the validation below accepts any digits, so a builder that |
| 4370 |
# wants a plain `.1` can pass one. |
| 4371 |
ARG ALLOY_BUILD_STAMP= |
| 4372 |
|
| 4373 |
RUN set -eu; \ |
| 4374 |
if [ -n "$ALLOY_BUILD_STAMP" ]; then \ |
| 4375 |
echo "$ALLOY_BUILD_STAMP" | grep -qE '^[0-9]{8}\.[0-9]+$' \ |
| 4376 |
|| { echo "ALLOY_BUILD_STAMP '$ALLOY_BUILD_STAMP' is not <YYYYMMDD>.<serial>" >&2; exit 1; }; \ |
| 4377 |
sed -i "s/^VERSION=\"\\(.*\\)build <n>\\(.*\\)\"$/VERSION=\"\\1build $ALLOY_BUILD_STAMP\\2\"/; \ |
| 4378 |
s/^PRETTY_NAME=\"\\(.*\\)build <n>\\(.*\\)\"$/PRETTY_NAME=\"\\1build $ALLOY_BUILD_STAMP\\2\"/" \ |
| 4379 |
/usr/lib/os-release; \ |
| 4380 |
sed -i "/^VERSION_ID=/a IMAGE_VERSION=\"$ALLOY_BUILD_STAMP\"" /usr/lib/os-release; \ |
| 4381 |
grep -q "^IMAGE_VERSION=\"$ALLOY_BUILD_STAMP\"$" /usr/lib/os-release \ |
| 4382 |
|| { echo "the build stamp did not land in os-release" >&2; exit 1; }; \ |
| 4383 |
echo "version: build $ALLOY_BUILD_STAMP"; \ |
| 4384 |
else \ |
| 4385 |
sed -i 's/^\(VERSION\|PRETTY_NAME\)="\(.*\)build <n>, \(.*\)"$/\1="\2\3"/' \ |
| 4386 |
/usr/lib/os-release; \ |
| 4387 |
echo "version: unstamped build"; \ |
| 4388 |
fi; \ |
| 4389 |
if grep -q '<n>' /usr/lib/os-release; then \ |
| 4390 |
echo "the build placeholder is still in os-release" >&2; exit 1; \ |
| 4391 |
fi; \ |
| 4392 |
grep -q "^VERSION_ID=" /usr/lib/os-release \ |
| 4393 |
|| { echo "os-release lost its VERSION_ID" >&2; exit 1; }; \ |
| 4394 |
grep -q "^ALLOY_BASE=" /usr/lib/os-release \ |
| 4395 |
|| { echo "os-release lost its ALLOY_BASE" >&2; exit 1; } |
| 4396 |
|
| 4397 |
# ===================================================================== |
| 4398 |
# The build record — the choices that made this image. |
| 4399 |
# ===================================================================== |
| 4400 |
# The other half of `alloy image`. The builder writes its choices to |
| 4401 |
# build/alloy-build.toml in the checkout; this writes the same document |
| 4402 |
# into the image, so a rebuild started on the machine itself begins from |
| 4403 |
# what that machine already is rather than from defaults. Wiki |
| 4404 |
# `alloy-distribution`: "choices recorded into the built image, read back |
| 4405 |
# from the previous one". |
| 4406 |
# |
| 4407 |
# Written from the ARGs rather than copied from the checkout, and that is |
| 4408 |
# the point: the record then describes what was actually built. A copy |
| 4409 |
# would describe what the TUI last saved, which is the same thing right |
| 4410 |
# up until someone passes `--build-arg` by hand or edits the file between |
| 4411 |
# saving and building. |
| 4412 |
# |
| 4413 |
# THIS IS THE CHOICES HALF AND NOT A LOCKFILE. It does not pin the |
| 4414 |
# resolved RPM set, so rebuilding from it reproduces the same decisions |
| 4415 |
# against today's packages, not the same image. The resolutions half is |
| 4416 |
# still open, and the reason is on the task: Fedora keeps only the newest |
| 4417 |
# build of each package, so a NEVRA list expires on its own schedule. |
| 4418 |
# Saying so in the file itself, because a file called a record invites |
| 4419 |
# being read as a lockfile. |
| 4420 |
# |
| 4421 |
# crates/alloy/tests/build_record.rs parses what this writes with the |
| 4422 |
# console's own parser, so the shell here and the Rust there cannot |
| 4423 |
# drift into disagreeing about the format. |
| 4424 |
RUN set -eu; \ |
| 4425 |
mkdir -p /usr/lib/alloy; \ |
| 4426 |
langs=""; \ |
| 4427 |
for lang in $(echo "$LANGS" | tr ',' ' '); do \ |
| 4428 |
langs="$langs, \"$lang\""; \ |
| 4429 |
done; \ |
| 4430 |
langs="[${langs#, }]"; \ |
| 4431 |
{ echo "# Alloy build record — the choices that made this image."; \ |
| 4432 |
echo "# Written by the build, read back by \`alloy image\` so a rebuild starts"; \ |
| 4433 |
echo "# from what this machine already is rather than from defaults."; \ |
| 4434 |
echo "#"; \ |
| 4435 |
echo "# This is the CHOICES half. It is not a lockfile: it does not pin the"; \ |
| 4436 |
echo "# resolved RPM set, so rebuilding from it gives you the same decisions"; \ |
| 4437 |
echo "# against today's packages, not the same image. See wiki"; \ |
| 4438 |
echo "# \`alloy-distribution\` for why the resolutions half is still open."; \ |
| 4439 |
echo ""; \ |
| 4440 |
echo "profile = \"$PROFILE\""; \ |
| 4441 |
echo "browser = \"$BROWSER\""; \ |
| 4442 |
echo "langs = $langs"; \ |
| 4443 |
echo "trim = \"$TRIM\""; \ |
| 4444 |
echo "db = \"$DB\""; \ |
| 4445 |
echo "hostname = \"$ALLOY_HOSTNAME\""; \ |
| 4446 |
echo "pubkey = \"\""; \ |
| 4447 |
} > /usr/lib/alloy/build.toml; \ |
| 4448 |
grep -q "^profile = \"$PROFILE\"$" /usr/lib/alloy/build.toml \ |
| 4449 |
|| { echo "the build record does not name the profile it was built with" >&2; exit 1; }; \ |
| 4450 |
echo "record: /usr/lib/alloy/build.toml" |
| 4451 |
|
| 4452 |
# `artifact` is deliberately absent from the record above, and `pubkey` is |
| 4453 |
# deliberately empty rather than omitted. |
| 4454 |
# |
| 4455 |
# An image does not know which artifact it was packed into. The same image |
| 4456 |
# becomes an ISO, a raw disk or a qcow2, and a record claiming one of them |
| 4457 |
# would be asserting something the build genuinely cannot see. The parser |
| 4458 |
# treats a missing key as the default, which is the honest answer. |
| 4459 |
# |
| 4460 |
# `pubkey` is a path on whichever machine ran the builder, and that path |
| 4461 |
# means nothing here — the key itself is at authorized_keys, and the path |
| 4462 |
# it came from is not a fact about this image. Empty rather than absent so |
| 4463 |
# the shape of the document is the same either way, and so nobody reads a |
| 4464 |
# missing key as a key that was lost. |
| 4465 |
|
| 4466 |
# The profile, recorded where the console can read it. |
| 4467 |
# |
| 4468 |
# `alloy` hides the verbs that need a compositor (`display`, and the bar half |
| 4469 |
# of `status`) when this says `server`. Build-time knowledge rather than a |
| 4470 |
# runtime probe: the image already knows what it is, and probing for a |
| 4471 |
# compositor would answer "no" on a client machine that is merely at the |
| 4472 |
# greeter, which is a different thing entirely. |
| 4473 |
# |
| 4474 |
# /usr/lib rather than /etc: it describes the image, not the machine's |
| 4475 |
# configuration, and nothing should edit it after the build. Absent means |
| 4476 |
# client, which is what a `cargo run` on a dev box gets. |
| 4477 |
RUN set -eu; \ |
| 4478 |
mkdir -p /usr/lib/alloy; \ |
| 4479 |
printf '%s\n' "$PROFILE" > /usr/lib/alloy/profile; \ |
| 4480 |
test "$(cat /usr/lib/alloy/profile)" = "$PROFILE" \ |
| 4481 |
|| { echo "the profile marker did not land" >&2; exit 1; } |
| 4482 |
|
| 4483 |
# The profile, recorded where every os-release reader can see it. |
| 4484 |
# |
| 4485 |
# The committed file carries `<profile>` on both fields, the same |
| 4486 |
# placeholder idiom as `build <n>`: a value that never got rewritten reads |
| 4487 |
# as obviously broken rather than as a plausible wrong answer. What stood |
| 4488 |
# there before was `VARIANT_ID=base`, which every image of both profiles |
| 4489 |
# shipped, so anything keying off it (a dotfile, a support question, a |
| 4490 |
# `systemd-analyze` dump) was told nothing. |
| 4491 |
# |
| 4492 |
# Downstream of the PROFILE `case` near the top, so the value is already |
| 4493 |
# known to be one of the two words and the display half can be written by |
| 4494 |
# hand rather than derived. |
| 4495 |
# |
| 4496 |
# The profile alone, never the tag variants. `firewall-server` and |
| 4497 |
# `usbgate-server` are image tags chosen by whoever runs the build; the |
| 4498 |
# Containerfile is never told which one it is making, so there is nothing |
| 4499 |
# here to stamp them from. |
| 4500 |
RUN set -eu; \ |
| 4501 |
case "$PROFILE" in \ |
| 4502 |
client) variant="Client" ;; \ |
| 4503 |
server) variant="Server" ;; \ |
| 4504 |
*) echo "unknown PROFILE '$PROFILE' reached the os-release stamp" >&2; exit 1 ;; \ |
| 4505 |
esac; \ |
| 4506 |
sed -i "s/^VARIANT=.*/VARIANT=\"$variant\"/; \ |
| 4507 |
s/^VARIANT_ID=.*/VARIANT_ID=$PROFILE/" /usr/lib/os-release; \ |
| 4508 |
grep -q "^VARIANT_ID=$PROFILE$" /usr/lib/os-release \ |
| 4509 |
|| { echo "the profile did not land in os-release" >&2; exit 1; }; \ |
| 4510 |
grep -q "^VARIANT=\"$variant\"$" /usr/lib/os-release \ |
| 4511 |
|| { echo "the profile display name did not land in os-release" >&2; exit 1; }; \ |
| 4512 |
if grep -q '<profile>' /usr/lib/os-release; then \ |
| 4513 |
echo "the profile placeholder is still in os-release" >&2; exit 1; \ |
| 4514 |
fi |
| 4515 |
|
| 4516 |
RUN systemctl preset-all |
| 4517 |
|
| 4518 |
# ===================================================================== |
| 4519 |
# ...and usbguard is still disarmed, which only this side of preset-all |
| 4520 |
# can say. |
| 4521 |
# |
| 4522 |
# The package block and the config assertion above establish that the |
| 4523 |
# daemon would enforce a deny-everything policy if it ran. This is the |
| 4524 |
# line that says it does not run. It has to be here rather than beside |
| 4525 |
# them: `preset-all` is what turns a preset line into an enable symlink, |
| 4526 |
# so before it every unit in the image reads disabled and the check would |
| 4527 |
# pass without meaning anything. |
| 4528 |
# |
| 4529 |
# Two ways it could arm, and this catches both. A line added to |
| 4530 |
# 50-alloy.preset — the step-3-and-4 work will eventually add exactly |
| 4531 |
# that, and this assertion is the thing it has to consciously delete. |
| 4532 |
# And Fedora's own 90-default.preset, if the usbguard package ever starts |
| 4533 |
# shipping an enable line of its own; today it does not, and that is a |
| 4534 |
# fact about someone else's file rather than one about ours. |
| 4535 |
# ===================================================================== |
| 4536 |
RUN set -eu; \ |
| 4537 |
! systemctl is-enabled usbguard.service >/dev/null 2>&1 \ |
| 4538 |
|| { echo "usbguard.service is enabled, and /etc/usbguard/rules.conf is empty with ImplicitPolicyTarget=block: this image deauthorizes its own keyboard at boot" >&2; exit 1; }; \ |
| 4539 |
echo "usbguard: disarmed after preset-all" |
| 4540 |
|
| 4541 |
# Template instances have to be enabled by name. `preset-all` iterates over |
| 4542 |
# the unit *files* that exist, and `alloy-debug-shell@.service` is a template |
| 4543 |
# with no instance of its own, so a preset line naming `@tty9` matches nothing |
| 4544 |
# and is silently ignored: no symlink, no unit, and a kernel flag that appears |
| 4545 |
# to do nothing at all. Found by booting with `alloy.debug` set and getting a |
| 4546 |
# plain login prompt. |
| 4547 |
# |
| 4548 |
# Both instances are inert unless the kernel command line carries |
| 4549 |
# `alloy.debug` (see the unit's ConditionKernelCommandLine), which only the |
| 4550 |
# installer ISO's verbose entries set, so enabling them in every image is |
| 4551 |
# safe. |
| 4552 |
RUN systemctl enable alloy-debug-shell@tty9.service alloy-debug-shell@ttyS0.service |
| 4553 |
|
| 4554 |
# ===================================================================== |
| 4555 |
# Branding |
| 4556 |
# ===================================================================== |
| 4557 |
# plymouth splash and wallpapers ship via the config tree above. |
| 4558 |
# |
| 4559 |
# os-release ships at /usr/lib/os-release, not /etc/os-release, and this |
| 4560 |
# link is what makes the two agree. It shipped at /etc for a while, which |
| 4561 |
# left the image carrying two identities: /etc said Alloy and |
| 4562 |
# /usr/lib said Fedora, because `COPY etc/ /etc/` replaced the base's |
| 4563 |
# symlink with a regular file. Which name a consumer saw then depended on |
| 4564 |
# which path it happened to read, and the ones reading /usr/lib were told |
| 4565 |
# they were running Fedora. ostree is one of them: it builds the boot |
| 4566 |
# menu entry from the deployment's /usr/lib/os-release, so every installed |
| 4567 |
# machine offered "Fedora Linux 43 (Forty Three)" at GRUB, which is the |
| 4568 |
# last surface after the installer that still said Fedora. See GO task |
| 4569 |
# 7869c992. |
| 4570 |
# |
| 4571 |
# The link direction follows the spec and the base: /usr/lib holds the |
| 4572 |
# file, /etc points at it, the same arrangement fedora-release, issue and |
| 4573 |
# redhat-release already use. Relative, not absolute, so it resolves |
| 4574 |
# inside a mounted deployment rather than against the host's /usr. |
| 4575 |
RUN ln -sfn ../usr/lib/os-release /etc/os-release |
| 4576 |
|
| 4577 |
# ===================================================================== |
| 4578 |
# Disable third-party repos post-install. |
| 4579 |
# ===================================================================== |
| 4580 |
# terra, the Rust-Wayland COPRs, and Tailscale are needed only at |
| 4581 |
# build time to layer packages Fedora doesn't carry. Left enabled they |
| 4582 |
# earn nothing at runtime (a bootc image updates by whole-image pull, |
| 4583 |
# not per-package dnf) and actively break bootc-image-builder: its |
| 4584 |
# installer depsolve reads every enabled repo but runs in its own |
| 4585 |
# environment, where these repos' file:// GPG keys and metalinks can't |
| 4586 |
# be resolved. Disable them so only the Fedora repos remain live; the |
| 4587 |
# already-installed packages are unaffected. |
| 4588 |
RUN sed -i 's/^enabled=1/enabled=0/; s/^enabled_metadata=1/enabled_metadata=0/' \ |
| 4589 |
/etc/yum.repos.d/terra.repo \ |
| 4590 |
/etc/yum.repos.d/tailscale.repo \ |
| 4591 |
/etc/yum.repos.d/_copr:*.repo |
| 4592 |
|
| 4593 |
# ===================================================================== |
| 4594 |
# Pin dnf's $releasever to the Fedora base version. |
| 4595 |
# ===================================================================== |
| 4596 |
# Alloy's os-release carries its own product VERSION_ID, so dnf/librepo |
| 4597 |
# would otherwise expand $releasever to it and request a nonexistent |
| 4598 |
# fedora-<product> repo (404). Every consumer of the Fedora repos — |
| 4599 |
# rpm-ostree package layering on the installed system, and |
| 4600 |
# bootc-image-builder's installer depsolve — needs this pinned to the |
| 4601 |
# actual base version, independent of Alloy's product version. |
| 4602 |
# |
| 4603 |
# Deliberately not derived from ALLOY_BASE, which states the same number |
| 4604 |
# in os-release. This is a file and overrides $releasever expansion |
| 4605 |
# whatever os-release says, which is the property wanted: a pin that can |
| 4606 |
# be read out of the thing it exists to override is not a pin. The two |
| 4607 |
# move together by hand when the FROM line moves. |
| 4608 |
RUN echo 43 > /etc/dnf/vars/releasever |
| 4609 |
|
| 4610 |
# ===================================================================== |
| 4611 |
# The Alloy console and terminal, as carried packages. |
| 4612 |
# ===================================================================== |
| 4613 |
# Neither binary is in this image. Until 2026-08-14 both were COPYed into |
| 4614 |
# /usr/bin, and that is the one shape in which a component can never be |
| 4615 |
# fixed on a machine somebody already installed: a layer over an unowned |
| 4616 |
# file does not fail to depsolve, it dies in checkout with "File exists". |
| 4617 |
# The rust-build stage's "component repo" block above carries the reasoning |
| 4618 |
# and build/layertest holds the measurements. |
| 4619 |
# |
| 4620 |
# So the image carries the packages and installs neither. |
| 4621 |
# alloy-layer-components.service lays them down on the first boot after an |
| 4622 |
# install, from this repo, with no network. That unit has shipped inert |
| 4623 |
# since alloy@0e9d9c5, gated on the repo being present and /usr/bin/alloy |
| 4624 |
# being absent; this is the commit that makes the second condition true. |
| 4625 |
# |
| 4626 |
# Last before the lint on purpose, and later than the config tree the |
| 4627 |
# header calls the most-changed layer — the console changes with every |
| 4628 |
# commit to crates/, which is more often still. Nothing below it means a |
| 4629 |
# console-only rebuild reuses every package and config layer above. |
| 4630 |
COPY --from=rust-build /staged-rpm /usr/share/alloy/rpm |
| 4631 |
|
| 4632 |
# enabled=1, and it is forced rather than chosen. `rpm-ostree install` on a |
| 4633 |
# booted system talks to rpm-ostreed over D-Bus, and its --enablerepo is |
| 4634 |
# "only supported in a container build" — so a repo shipped disabled is a |
| 4635 |
# repo the first-boot unit cannot turn on for its own transaction, and the |
| 4636 |
# machine comes up with no console. The "enabled by default or opt-in" |
| 4637 |
# question was about the future NETWORK repo, where the answer was a real |
| 4638 |
# choice, and it is answered: opt-in, `enabled=0`, ruled 2026-08-17. This one |
| 4639 |
# reaches no network and answers to nothing but the local filesystem, so the |
| 4640 |
# ruling does not reach it. |
| 4641 |
# |
| 4642 |
# gpgcheck=0 is unfinished work rather than a position, and the position is |
| 4643 |
# now decided: this repo gets signed too. Max ruled it 2026-08-17 over the |
| 4644 |
# recommendation, which was to leave the carried repo explicitly unsigned — |
| 4645 |
# these files came in with the image and are only as trustworthy as it is, so |
| 4646 |
# a signature defends nothing the image's own integrity does not already |
| 4647 |
# defend. What the ruling buys is one code path with the network repo, so |
| 4648 |
# there is no branch only that path exercises and the key exists before it is |
| 4649 |
# needed under pressure. "The image is the trust boundary" is a claim signing |
| 4650 |
# should make explicitly rather than one this file should assume. |
| 4651 |
# |
| 4652 |
# What is left: the key, then `gpgcheck=1` and a `gpgkey` here, the public key |
| 4653 |
# shipped in the image, and a test that fails the build if this repo does not |
| 4654 |
# verify. GoingsOn alloy d866e125. |
| 4655 |
RUN printf '[alloy-local]\nname=Alloy components, carried with the image\nbaseurl=file:///usr/share/alloy/rpm\nenabled=1\ngpgcheck=0\n' \ |
| 4656 |
> /etc/yum.repos.d/alloy-local.repo |
| 4657 |
|
| 4658 |
# What the first-boot unit layers, as a list the image writes rather than a |
| 4659 |
# package set the unit hardcodes. That is what makes the profile split work |
| 4660 |
# at all: `server` has no compositor, so a Wayland terminal has nothing to |
| 4661 |
# connect to and must not be laid down, and the unit is one static file |
| 4662 |
# shared by both profiles. Writing the list here keeps the decision where |
| 4663 |
# every other profile decision in this file already lives. |
| 4664 |
# |
| 4665 |
# Both RPMs are carried on both profiles even so. Dropping shop's file on |
| 4666 |
# `server` would leave the repo metadata describing a package that is not |
| 4667 |
# there, which is a landmine for anyone who later resolves against it, and |
| 4668 |
# regenerating metadata would want createrepo_c in the runtime image for |
| 4669 |
# the sake of deleting three megabytes. A server operator who wants the |
| 4670 |
# terminal can install it; nothing lays it down for them. |
| 4671 |
# |
| 4672 |
# THAT INSTALL RESOLVES OVER THE NETWORK, and it is worth saying because the |
| 4673 |
# carried repo is a file:// one and reads as though it were self-contained. |
| 4674 |
# `build/rpm/shop.spec` names its dependencies by package (`fontconfig`, |
| 4675 |
# `libxkbcommon`, `libwayland-client`, `libwayland-egl`, `vulkan-loader`, |
| 4676 |
# `libglvnd-egl`), and every one of those arrives on `client` through the |
| 4677 |
# graphical stack this profile does not install. `fontconfig` joined that list |
| 4678 |
# on 2026-08-19, when the font block above went client-only. So the affordance |
| 4679 |
# is "the package is here and dnf can resolve the rest", not "this installs |
| 4680 |
# offline". Which of the six the base itself already carries was not measured: |
| 4681 |
# it needs a built server image and this change was made without one. |
| 4682 |
RUN if [ "$PROFILE" = client ]; then \ |
| 4683 |
printf 'alloy\nshop\n' > /usr/share/alloy/components; \ |
| 4684 |
grep -qx shop /usr/share/alloy/components \ |
| 4685 |
|| { echo "profile=client would come up with no terminal" >&2; exit 1; }; \ |
| 4686 |
else \ |
| 4687 |
printf 'alloy\n' > /usr/share/alloy/components; \ |
| 4688 |
! grep -qx shop /usr/share/alloy/components \ |
| 4689 |
|| { echo "profile=server would layer a Wayland terminal on a machine with no compositor" >&2; exit 1; }; \ |
| 4690 |
fi |
| 4691 |
|
| 4692 |
# The carried repo is complete, and neither binary leaked into the image. |
| 4693 |
# |
| 4694 |
# Unconditional, and both halves matter. A repo that lost its metadata |
| 4695 |
# resolves nothing, and the unit's failure mode is a machine that boots to a |
| 4696 |
# greeter, opens a session with no console and no terminal, and offers no way |
| 4697 |
# to fix either from inside it. A /usr/bin/alloy that came back — a restored |
| 4698 |
# COPY, a `dnf install` somewhere above — is quieter and worse: the unit's |
| 4699 |
# second condition goes false, it never runs, the machine works, and the |
| 4700 |
# console it carries can never be replaced. That is the whole failure this |
| 4701 |
# design exists to prevent, and it would ship looking healthy. |
| 4702 |
RUN set -eux; \ |
| 4703 |
test -f /usr/share/alloy/rpm/repodata/repomd.xml \ |
| 4704 |
|| { echo "the carried repo has no metadata; first boot would layer nothing" >&2; exit 1; }; \ |
| 4705 |
test -n "$(ls /usr/share/alloy/rpm/*.rpm)" \ |
| 4706 |
|| { echo "the carried repo holds no packages" >&2; exit 1; }; \ |
| 4707 |
test ! -e /usr/bin/alloy \ |
| 4708 |
|| { echo "the image carries /usr/bin/alloy; alloy-layer-components.service will never fire and the console can never be fixed" >&2; exit 1; }; \ |
| 4709 |
test ! -e /usr/bin/shop \ |
| 4710 |
|| { echo "the image carries /usr/bin/shop; an unowned file cannot be layered over" >&2; exit 1; }; \ |
| 4711 |
test -x /usr/bin/alloy-layer-notice \ |
| 4712 |
|| { echo "alloy-layer-notice is missing or not executable; the layering boot is a blank screen again" >&2; exit 1; }; \ |
| 4713 |
sh -n /usr/bin/alloy-layer-notice \ |
| 4714 |
|| { echo "alloy-layer-notice does not parse; the layering boot is a blank screen again" >&2; exit 1; }; \ |
| 4715 |
test -x /usr/bin/alloy-layer-repos \ |
| 4716 |
|| { 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; }; \ |
| 4717 |
sh -n /usr/bin/alloy-layer-repos \ |
| 4718 |
|| { echo "alloy-layer-repos does not parse; an offline first boot would come up with no console" >&2; exit 1; }; \ |
| 4719 |
grep -q '^enabled=1$' /etc/yum.repos.d/alloy-local.repo \ |
| 4720 |
|| { echo "the carried repo is not enabled; the fenced transaction would have no source at all" >&2; exit 1; } |
| 4721 |
|
| 4722 |
# The themes the console refuses to run without: with no theme file on any |
| 4723 |
# search path it does not fall back, it exits. |
| 4724 |
# |
| 4725 |
# These stay in the base image while the binary that reads them travels as a |
| 4726 |
# package, which is a deliberate split rather than an oversight. They are data |
| 4727 |
# the whole image shares — the rendered skeleton above came out of the same two |
| 4728 |
# files — so packaging them with the console would put one copy under rpm and |
| 4729 |
# another under ostree and let them disagree. |
| 4730 |
# |
| 4731 |
# What it costs, and it is worth knowing before writing a hotfix: a console fix |
| 4732 |
# that needs a NEW theme token cannot ship through the RPM channel, because the |
| 4733 |
# token would have to arrive in the base. That makes it an image change, which |
| 4734 |
# is a release rather than a hotfix (wiki `hotfix-policy`), so the constraint |
| 4735 |
# and the policy already agree. |
| 4736 |
COPY --from=rust-build /staged-themes /usr/share/alloy/themes |
| 4737 |
|
| 4738 |
# The house faces: Quasi Mono for everything monospaced, Quasi Body for UI text. |
| 4739 |
# |
| 4740 |
# Cut in the rust stage rather than downloaded, because there is nothing to |
| 4741 |
# download: a built face is not committed anywhere, so the pipeline is the only |
| 4742 |
# source of truth for what a face contains (wiki `typography-standard`). What it |
| 4743 |
# replaced was a 60 MB IosevkaTerm Nerd Font zip and an upstream Atkinson |
| 4744 |
# tarball, and it retired the Private Use Area from the image entirely. |
| 4745 |
# |
| 4746 |
# Down here with the other staged copies rather than up in the old font layer: |
| 4747 |
# this depends on the rust stage, so above the package installs it would rebuild |
| 4748 |
# 7 GB of them every time a console edit changed the stage it comes from. |
| 4749 |
# |
| 4750 |
# The OFL text travels with the faces, in the same directory, because OFL 1.1 |
| 4751 |
# requires it to travel with a modified build and both of these are one. |
| 4752 |
# |
| 4753 |
# STAGED FIRST, THEN INSTALLED ON CLIENT ONLY. A `COPY` cannot be conditional, |
| 4754 |
# so the faces land at a path outside /usr and the `RUN` below decides what |
| 4755 |
# becomes of them: installed and cached on `client`, deleted on `server`, which |
| 4756 |
# has nothing that can draw them (the fontconfig block above holds the whole |
| 4757 |
# argument). /faces-staged is removed on both branches, because bootc lints the |
| 4758 |
# root and a leftover directory there is a finding rather than a nuisance. |
| 4759 |
# |
| 4760 |
# Neither branch asserts the removal afterwards. Under `set -eu` a `test ! -e` |
| 4761 |
# on the line after `rm -rf` can only fail if `rm` returned 0 without removing |
| 4762 |
# anything, so it would be a line that cannot fail rather than a check. What |
| 4763 |
# does hold the removal in place is `font_profile.rs`, which runs both branches |
| 4764 |
# against a fake root and looks: delete the `rm` and the test says so. |
| 4765 |
# |
| 4766 |
# What the staging costs on `server` is 208 KiB in one layer of the image |
| 4767 |
# tarball. It costs nothing in the installed system: the deployment is built |
| 4768 |
# from the merged tree, and these files are not in it. |
| 4769 |
COPY --from=rust-build /faces/ /faces-staged/ |
| 4770 |
RUN set -eu; \ |
| 4771 |
if [ "$PROFILE" = client ]; then \ |
| 4772 |
mkdir -p /usr/share/fonts/quasi; \ |
| 4773 |
cp /faces-staged/* /usr/share/fonts/quasi/; \ |
| 4774 |
rm -rf /faces-staged; \ |
| 4775 |
fc-cache -fv; \ |
| 4776 |
[ "$(fc-list ':family=Quasi Mono' family | wc -l)" -ge 1 ] \ |
| 4777 |
|| { echo "the faces were copied and fontconfig cannot see Quasi Mono; the cache did not take" >&2; exit 1; }; \ |
| 4778 |
else \ |
| 4779 |
rm -rf /faces-staged; \ |
| 4780 |
test ! -e /usr/share/fonts/quasi \ |
| 4781 |
|| { echo "profile=server installed the house faces; nothing here can rasterise one, and a face a machine cannot draw is payload with no reader" >&2; exit 1; }; \ |
| 4782 |
echo "fonts: the house faces are staged and discarded on this profile"; \ |
| 4783 |
fi |
| 4784 |
|
| 4785 |
# The schemas the settings view's Applications tab is built from. Straight |
| 4786 |
# from the build context: they are hand-authored TOML that nothing compiles, |
| 4787 |
# so there is no staged copy to take them from. |
| 4788 |
# |
| 4789 |
# Missing until 2026-08-01, which meant `alloy settings` opened its |
| 4790 |
# Applications tab on "no schemas found" for every installed machine. It was |
| 4791 |
# invisible from a source checkout because the search path ends in a |
| 4792 |
# CARGO_MANIFEST_DIR fallback, so a `cargo run` finds the repo's copies and an |
| 4793 |
# image finds nothing. |
| 4794 |
COPY schemas /usr/share/alloy/schemas |
| 4795 |
|
| 4796 |
RUN test -n "$(ls -A /usr/share/alloy/schemas)" \ |
| 4797 |
|| { echo "no config schemas shipped; the settings Applications tab would be empty" >&2; exit 1; } |
| 4798 |
|
| 4799 |
# The tool that applies the greeter's console palette. The table itself arrives |
| 4800 |
# with the rest of the rendered tree above, at /usr/share/alloy/vtrgb. |
| 4801 |
# alloy-vtrgb.service reads it; setvtrgb ships in kbd, which systemd's vconsole |
| 4802 |
# setup already pulls in. Assert it here rather than let the unit's |
| 4803 |
# ConditionPathExists turn a missing binary into a silent no-op that drops the |
| 4804 |
# greeter back to the stock console palette. |
| 4805 |
RUN command -v setvtrgb >/dev/null \ |
| 4806 |
|| { echo "setvtrgb (kbd) is missing; alloy-vtrgb.service would no-op" >&2; exit 1; } |
| 4807 |
RUN test -s /usr/share/alloy/vtrgb \ |
| 4808 |
|| { echo "the console palette did not arrive with the rendered tree" >&2; exit 1; } |
| 4809 |
|
| 4810 |
# vtrgb.night is the dark console table, and nothing reads it yet. It is here so |
| 4811 |
# that switching the greeter's palette is a unit change rather than a render |
| 4812 |
# change: alloy-vtrgb.service runs before any user exists, so the per-user mode |
| 4813 |
# file cannot reach it, and the system-scoped setting that will choose between |
| 4814 |
# these two tables is still to come. Asserted anyway, because an unread file is |
| 4815 |
# exactly the kind that quietly stops being produced. The note lives here rather |
| 4816 |
# than in the template: templates/usr/share/alloy/vtrgb.in renders to nothing but |
| 4817 |
# the table, and setvtrgb rejects any line that is not sixteen values. |
| 4818 |
RUN test -s /usr/share/alloy/vtrgb.night \ |
| 4819 |
|| { echo "the dark console palette did not arrive; the greeter would have nothing to switch to" >&2; exit 1; } |
| 4820 |
|
| 4821 |
# The dark half of the skeleton, which `alloy theme apply` reads its whole |
| 4822 |
# manifest from. An empty or missing tree makes the verb a no-op that says so |
| 4823 |
# once per login and leaves every user on the light render forever, so it is |
| 4824 |
# worth one test here rather than a report from a booted machine. |
| 4825 |
# |
| 4826 |
# The pairing was already proved in the rust-build stage; what this repeats it |
| 4827 |
# for is the finished image, where /etc/skel is also written by every package |
| 4828 |
# that ships a skeleton file and by every COPY above. A night render whose day |
| 4829 |
# counterpart went missing between there and here is a file the pristine test |
| 4830 |
# can only decide by keeping, so the user stays on whatever they have. |
| 4831 |
# |
| 4832 |
# Conditional since 2026-08-03, and it had to become one: the server prune |
| 4833 |
# above deletes /usr/share/alloy/skel-night, so this test asserted the presence |
| 4834 |
# of something the same file had already removed and `PROFILE=server` could not |
| 4835 |
# build at all. Found by the first server build ever run, which is the whole |
| 4836 |
# argument for running one. The server branch asserts the absence rather than |
| 4837 |
# skipping, per the rule the split lives by — a branch that checks nothing is |
| 4838 |
# the failure mode conditionals were supposed to be worth risking. |
| 4839 |
RUN set -eux; \ |
| 4840 |
if [ "$PROFILE" = client ]; then \ |
| 4841 |
test -n "$(find /usr/share/alloy/skel-night -type f -print -quit)" \ |
| 4842 |
|| { echo "the night skeleton did not arrive; alloy theme apply would have nothing to apply" >&2; exit 1; }; \ |
| 4843 |
for applied in $(find /usr/share/alloy/skel-night -type f); do \ |
| 4844 |
rel="${applied#/usr/share/alloy/skel-night/}"; \ |
| 4845 |
[ -f "/etc/skel/$rel" ] \ |
| 4846 |
|| { echo "/etc/skel/$rel is gone; the night render of it has nothing to switch back to" >&2; exit 1; }; \ |
| 4847 |
done; \ |
| 4848 |
else \ |
| 4849 |
test ! -e /usr/share/alloy/skel-night \ |
| 4850 |
|| { echo "profile=server still carries the night skeleton the prune removes" >&2; exit 1; }; \ |
| 4851 |
echo "skel-night: pruned with the rest of the desktop skeleton"; \ |
| 4852 |
fi |
| 4853 |
|
| 4854 |
# Script coverage, and the two claims that come with it. |
| 4855 |
# |
| 4856 |
# This is the third arrival of one defect class, so it gets an assertion rather |
| 4857 |
# than a comment. The emoji alias named a font the image never installed. The |
| 4858 |
# swayosd unit sat at a path udev does not read. Both were configuration that was |
| 4859 |
# correct about a thing that was absent, and in every case the build was green and |
| 4860 |
# the failure was a user staring at missing glyphs or a dead key. `dnf install` |
| 4861 |
# only proves a package name still resolves; it does not prove a metapackage still |
| 4862 |
# pulls the faces it pulled last release, and that is the part that can rot |
| 4863 |
# quietly across a Fedora bump. |
| 4864 |
# |
| 4865 |
# Ten languages rather than two, because the failure mode of a short list is that |
| 4866 |
# the script left out is the one nobody here reads and therefore nobody notices. |
| 4867 |
# The counts are deliberately not pinned: `-ge 1` is the claim that matters, and a |
| 4868 |
# fixed number would fail on a Fedora rebuild that merely repackages a family. |
| 4869 |
# |
| 4870 |
# `HOME=/etc/skel` is load-bearing and not decoration. Alloy's aliases live in |
| 4871 |
# etc/skel/.config/fontconfig/fonts.conf, which is per-user, so the build's root |
| 4872 |
# context does not see them: measured on this image, a plain `fc-match sans-serif` |
| 4873 |
# answers "Noto Sans" while the same query under the skeleton answers Alloy's own |
| 4874 |
# sans. A guard written without it would have asserted the base's opinion |
| 4875 |
# and passed while claiming to check Alloy's. |
| 4876 |
# |
| 4877 |
# Conditional since 2026-08-03, for the reason the skel-night guard above it |
| 4878 |
# is: the coverage metapackages are installed in the client-only block, since |
| 4879 |
# they are there for the browser and a headless box has none, while this test |
| 4880 |
# was written before the split and asserts them on both. `PROFILE=server` |
| 4881 |
# failed here on the first server build. |
| 4882 |
# |
| 4883 |
# The server branch is not a skip, and since 2026-08-19 it asserts the opposite |
| 4884 |
# of what it used to. It said "Quasi Mono is missing; the font layers are |
| 4885 |
# unconditional and this profile has a console", which was wrong about where a |
| 4886 |
# TUI's glyphs come from: a session reached over ssh is rasterised by the |
| 4887 |
# CLIENT's font stack, and the server end opens no font file at all. That |
| 4888 |
# sentence was also the thing holding the payload in place, since the faces |
| 4889 |
# could not be dropped while a check demanded them. So the branch now proves |
| 4890 |
# the faces are ABSENT, which is the pattern every other `$PROFILE` conditional |
| 4891 |
# in this file follows: client proves presence, server proves absence. |
| 4892 |
# |
| 4893 |
# It proves it without fontconfig, because there is none to ask. The install is |
| 4894 |
# client-only now (see the fontconfig block above), so `fc-list` is not on this |
| 4895 |
# profile's PATH and the queries are filesystem and rpm questions instead: |
| 4896 |
# no fc-list, no /usr/share/fonts/quasi, none of the font packages the client |
| 4897 |
# block installs, no package whose name says emoji. |
| 4898 |
# |
| 4899 |
# BOTH OF THOSE PACKAGE QUERIES ARE WEAKER THAN THE CHARSET QUERY THEY REPLACE, |
| 4900 |
# and the weakness is worth stating rather than conceding on the emoji half |
| 4901 |
# alone. `fc-list ':lang=ja'` answered for coverage arriving from any source, |
| 4902 |
# including a face dropped into /usr/share/fonts by something that is not a |
| 4903 |
# package at all. A name list answers only for the three packages the client |
| 4904 |
# block installs, and a name sweep for emoji answers only for a font that says |
| 4905 |
# so in its name. What makes the list a list rather than a guess is that it is |
| 4906 |
# checked against the client's own install lines by |
| 4907 |
# `crates/alloy/tests/font_profile.rs`, so a fourth font package added up there |
| 4908 |
# fails the test rather than sliding past this branch. |
| 4909 |
# |
| 4910 |
# What would settle the charset half properly is installing fontconfig on |
| 4911 |
# `server` to ask the charset question, and that is the payload this whole |
| 4912 |
# change exists to drop, for 784 KiB of tools to answer a question about a |
| 4913 |
# machine that rasterises nothing. So it is not asked here. Only that half is |
| 4914 |
# out of reach, though: a face dropped into /usr/share/fonts by something that |
| 4915 |
# is not a package is a directory listing away and needs no fontconfig, which |
| 4916 |
# is the cheap check to reach for before wanting the tools back. The |
| 4917 |
# claim these queries do support is the one that matters, that this profile |
| 4918 |
# installs no font package at all, so the only faces on it are the base's own. |
| 4919 |
# |
| 4920 |
# `fc-match` is not asked on server either, and was not before: the prune takes |
| 4921 |
# etc/skel/.config/fontconfig with the rest of the skeleton, so there are no |
| 4922 |
# Alloy aliases left to resolve and the answer would be the base's opinion, |
| 4923 |
# which is the exact mistake the HOME=/etc/skel note above warns about. |
| 4924 |
# |
| 4925 |
# THE SERVER BRANCH SPLITS ON $GUI, and the split is a measured answer to the |
| 4926 |
# question the old assertion asked when it fired. It held that a server image |
| 4927 |
# carries no fontconfig at all, and astra is the first machine to be both |
| 4928 |
# `PROFILE=server` and `GUI=tauri`: it builds the arm64 Tauri releases, natively, |
| 4929 |
# because nothing here cross-compiles. Those builds want gtk3-devel, libsoup3, |
| 4930 |
# librsvg2 and webkit2gtk4.1, and that set drags in the GTK and WebKit runtime |
| 4931 |
# with fontconfig underneath it. Measured 2026-09-04, at step 101 of 103, on the |
| 4932 |
# first astra mint anyone had ever run. |
| 4933 |
# |
| 4934 |
# So the payload argument still holds where it was made and does not reach this |
| 4935 |
# machine. `GUI=none` keeps the original claim, unchanged and now stated as what |
| 4936 |
# it always meant. `GUI=tauri` asserts the other direction instead -- fontconfig |
| 4937 |
# has to BE there, since a build host missing it fails at link time rather than |
| 4938 |
# at boot -- and every other claim on this branch is kept: no house faces, no |
| 4939 |
# font package from the client's list, no emoji. What a headless build host may |
| 4940 |
# not have is a face. Tools that answer questions about faces are the price of |
| 4941 |
# building the apps, and the apps are why the machine exists. |
| 4942 |
RUN set -eux; \ |
| 4943 |
if [ "$PROFILE" = client ]; then \ |
| 4944 |
for lang in ja zh-cn zh-tw ko ar he hi th bn ta; do \ |
| 4945 |
[ "$(fc-list ":lang=$lang" family | wc -l)" -ge 1 ] \ |
| 4946 |
|| { echo "no font covers '$lang'; every page in that script renders as missing glyphs" >&2; exit 1; }; \ |
| 4947 |
done; \ |
| 4948 |
HOME=/etc/skel fc-match sans-serif | grep -q 'Quasi Body' \ |
| 4949 |
|| { echo "sans-serif is no longer Quasi Body; the added coverage outranked Alloy's own pick" >&2; exit 1; }; \ |
| 4950 |
HOME=/etc/skel fc-match monospace | grep -q 'Quasi Mono' \ |
| 4951 |
|| { echo "monospace is no longer Quasi Mono; every cell grid would draw its borders from a fallback face on its own baseline" >&2; exit 1; }; \ |
| 4952 |
HOME=/etc/skel fc-match monospace | grep -q 'Regular' \ |
| 4953 |
|| { echo "monospace resolves to a weight other than Regular; the face is variable and its own default instance is ExtraLight, so this is what an unnamed weight gets" >&2; exit 1; }; \ |
| 4954 |
[ "$(fc-list ':charset=1F600' family | wc -l)" -eq 0 ] \ |
| 4955 |
|| { echo "an emoji font arrived; docs/STACK.md says none is shipped, so update the decision or drop the font" >&2; exit 1; }; \ |
| 4956 |
elif [ "$GUI" = none ]; then \ |
| 4957 |
! command -v fc-list >/dev/null \ |
| 4958 |
|| { echo "profile=server with GUI=none carries fontconfig after every install; something now requires it, so find out what and decide, rather than leaving this branch asserting something untrue" >&2; exit 1; }; \ |
| 4959 |
test ! -e /usr/share/fonts/quasi \ |
| 4960 |
|| { echo "profile=server carries the house faces; nothing on it rasterises a glyph, and a TUI over ssh is drawn by the client's fonts" >&2; exit 1; }; \ |
| 4961 |
for pkg in default-fonts-cjk-sans default-fonts-other-sans google-noto-sans-mono-cjk-vf-fonts; do \ |
| 4962 |
! rpm -q --quiet "$pkg" \ |
| 4963 |
|| { echo "profile=server carries $pkg, one of the font packages the client block installs; it ships no browser and no terminal, so nothing on it would read the face" >&2; exit 1; }; \ |
| 4964 |
done; \ |
| 4965 |
! rpm -qa | grep -qi emoji \ |
| 4966 |
|| { echo "an emoji font package arrived on profile=server; docs/STACK.md says none is shipped, so update the decision or drop the font" >&2; exit 1; }; \ |
| 4967 |
echo "fonts: no font stack on this profile, which is what a headless machine draws with"; \ |
| 4968 |
else \ |
| 4969 |
command -v fc-list >/dev/null \ |
| 4970 |
|| { echo "profile=server GUI=$GUI and no fontconfig; the toolkit packages that pull it in did not land, so a Tauri build here would fail" >&2; exit 1; }; \ |
| 4971 |
test ! -e /usr/share/fonts/quasi \ |
| 4972 |
|| { echo "profile=server carries the house faces; nothing on it rasterises a glyph, and a TUI over ssh is drawn by the client's fonts" >&2; exit 1; }; \ |
| 4973 |
for pkg in default-fonts-cjk-sans default-fonts-other-sans google-noto-sans-mono-cjk-vf-fonts; do \ |
| 4974 |
! rpm -q --quiet "$pkg" \ |
| 4975 |
|| { echo "profile=server carries $pkg, one of the font packages the client block installs; it ships no browser and no terminal, so nothing on it would read the face" >&2; exit 1; }; \ |
| 4976 |
done; \ |
| 4977 |
! rpm -qa | grep -qi emoji \ |
| 4978 |
|| { echo "an emoji font package arrived on profile=server; docs/STACK.md says none is shipped, so update the decision or drop the font" >&2; exit 1; }; \ |
| 4979 |
echo "fonts: fontconfig rides in with GUI=$GUI, and no face does"; \ |
| 4980 |
fi |
| 4981 |
|
| 4982 |
# ===================================================================== |
| 4983 |
# The build host's leavings, deleted rather than declared. |
| 4984 |
# ===================================================================== |
| 4985 |
# Everything above declares the /var content this image is supposed to carry. |
| 4986 |
# This is the other half: content that is in the image only because dnf, |
| 4987 |
# semodule, ldconfig and authselect ran on the machine that built it, and that |
| 4988 |
# no running machine needs. |
| 4989 |
# |
| 4990 |
# It is last on purpose. Anything below it that reached for dnf would put the |
| 4991 |
# caches back after the sweep, and the lint that follows is the thing that |
| 4992 |
# would say so. |
| 4993 |
# |
| 4994 |
# What each line is, and why deleting it is safe rather than merely quiet: |
| 4995 |
# |
| 4996 |
# /run and /tmp are tmpfs at boot, so their content is unreachable on a |
| 4997 |
# running machine no matter what. What is in there is dnf's lock directory |
| 4998 |
# and the intermediate .cil files semodule writes while compiling policy. |
| 4999 |
# |
| 5000 |
# /var/log/dnf5.log* is the BUILD HOST's package log: which packages were |
| 5001 |
# installed when, from which repo URLs. It is not this machine's history and |
| 5002 |
# nothing reads it, but it does ship to every install. |
| 5003 |
# |
| 5004 |
# /var/lib/dnf and /var/cache/libdnf5 are dnf's own state and cache. The |
| 5005 |
# `countme` stamps in there are upstream's install-counting telemetry, and |
| 5006 |
# the pubring copies are repo keys dnf re-fetches. MEASURED 2026-08-26: dnf |
| 5007 |
# answers a query with both trees deleted and recreates what it needs. |
| 5008 |
# |
| 5009 |
# /var/cache/ldconfig/aux-cache and the appstream .xb catalogs are caches |
| 5010 |
# with a generator behind each of them. |
| 5011 |
# |
| 5012 |
# /var/lib/authselect/checksum records the state of the PAM files authselect |
| 5013 |
# generated, so it can notice a hand edit. MEASURED 2026-08-26: with it gone |
| 5014 |
# `authselect check` still answers "Current configuration is valid" and |
| 5015 |
# `authselect current` still names the profile and all four features. |
| 5016 |
# |
| 5017 |
# WHAT THIS DOES NOT DO is make the pulled image smaller. These files were |
| 5018 |
# written in layers far above, and deleting them here writes a whiteout rather |
| 5019 |
# than reclaiming the bytes. What it changes is the filesystem `bootc install` |
| 5020 |
# writes to a disk, which is the one an installed machine actually carries. |
| 5021 |
# Reclaiming the registry bytes would mean cleaning inside the layer that made |
| 5022 |
# the mess, which is a different and much larger change to this file. |
| 5023 |
# |
| 5024 |
# The sweep of /run tolerates failures, and the lint below is why that is not a |
| 5025 |
# hole. podman bind-mounts the host's /run/systemd/resolve/stub-resolv.conf into |
| 5026 |
# the build container to give it DNS, so a plain `rm -rf /run/*` dies on a busy |
| 5027 |
# mount and takes the build with it. Those mounts are not image content — they |
| 5028 |
# are absent from the lint's own reading of a built image — so the right move is |
| 5029 |
# to sweep what can be swept and let `bootc container lint --fatal-warnings` |
| 5030 |
# below be the assertion about what is left. It reads the finished filesystem, |
| 5031 |
# which is the thing being claimed about, rather than this loop's exit status. |
| 5032 |
RUN set -eux; \ |
| 5033 |
find /run /tmp -mindepth 1 -maxdepth 1 -exec rm -rf {} + 2>/dev/null || true; \ |
| 5034 |
rm -f /var/log/dnf5.log*; \ |
| 5035 |
rm -rf /var/lib/dnf /var/cache/libdnf5; \ |
| 5036 |
rm -f /var/cache/ldconfig/aux-cache; \ |
| 5037 |
rm -f /var/cache/swcatalog/cache/*.xb; \ |
| 5038 |
rm -f /var/lib/authselect/checksum; \ |
| 5039 |
authselect check >/dev/null \ |
| 5040 |
|| { echo "authselect stopped validating once its checksum was removed; keep the file and declare it instead" >&2; exit 1; }; \ |
| 5041 |
echo "sweep: build-host caches, logs and /run content removed" |
| 5042 |
|
| 5043 |
# ===================================================================== |
| 5044 |
# bootc validation — fails the build if the image isn't a valid |
| 5045 |
# bootable container. |
| 5046 |
# ===================================================================== |
| 5047 |
# --fatal-warnings since 2026-08-26. Without it this was the one validation |
| 5048 |
# step in a file otherwise dense with hard assertions that could not fail the |
| 5049 |
# build, and it spent an unknown number of builds reporting three warnings on |
| 5050 |
# `server` and four on `client` that nothing read. That is the same shape of |
| 5051 |
# problem as a sweep grid nobody opens: a detector whose output reaches no one |
| 5052 |
# is not a control. |
| 5053 |
# |
| 5054 |
# --no-truncate because the default prints five entries per lint and then a |
| 5055 |
# count, which on a failing build is the half of the list you need hidden |
| 5056 |
# behind the half you have. The output is only read when it has already |
| 5057 |
# stopped the build, so there is nothing to keep short. |
| 5058 |
# |
| 5059 |
# --skip is deliberately not used. It exists, and gating with the known |
| 5060 |
# warnings suppressed was the cheaper option; it was refused because a skip |
| 5061 |
# list is a second place to record which warnings are acceptable, and it goes |
| 5062 |
# stale silently the moment the reason for one of them is fixed. |
| 5063 |
RUN bootc container lint --fatal-warnings --no-truncate |
| 5064 |
|