max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
10 files changed,
+493 insertions,
-56 deletions
| @@ -36,12 +36,21 @@ | |||
| 36 | 36 | /output.prev | |
| 37 | 37 | /dist | |
| 38 | 38 | ||
| 39 | + | # The install-source cache build/build-iso.sh keeps between runs: an OCI layout | |
| 40 | + | # of the image itself, so ~3G that no COPY reads and that would otherwise be | |
| 41 | + | # tarred into the build that produced it. | |
| 42 | + | /.iso-cache | |
| 43 | + | ||
| 39 | 44 | # Not read by any COPY. | |
| 40 | 45 | /.git | |
| 41 | - | # Excluded except the one script build/Containerfile.iso copies in. The | |
| 42 | - | # negation has to follow the exclusion; reversed, it does nothing. | |
| 46 | + | # Excluded except the few files a COPY reads: the script | |
| 47 | + | # build/Containerfile.iso copies in, and the two RPM specs the rust-build stage | |
| 48 | + | # packages the console and the terminal with. The negations have to follow the | |
| 49 | + | # exclusion; reversed, they do nothing. | |
| 43 | 50 | /build | |
| 44 | 51 | !/build/make-iso.sh | |
| 52 | + | !/build/rpm/alloy.spec | |
| 53 | + | !/build/rpm/shop.spec | |
| 45 | 54 | /builds.disabled | |
| 46 | 55 | /docs | |
| 47 | 56 | /tools |
| @@ -61,10 +61,44 @@ | |||
| 61 | 61 | # can add a build dependency, which is what happened at shop@53551eb and broke | |
| 62 | 62 | # both profiles at this step. The runtime already carries fontconfig for the | |
| 63 | 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. | |
| 64 | 71 | RUN dnf install -y cargo rust git wayland-devel libxkbcommon-devel \ | |
| 65 | - | fontconfig-devel pkgconf \ | |
| 72 | + | fontconfig-devel pkgconf rpm-build createrepo_c \ | |
| 66 | 73 | && dnf clean all | |
| 67 | 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 | + | ||
| 68 | 102 | # ===================================================================== | |
| 69 | 103 | # shop, Alloy's terminal. | |
| 70 | 104 | # ===================================================================== | |
| @@ -350,6 +384,81 @@ | |||
| 350 | 384 | || { echo "etc/skel/$rel is also rendered from templates/; one of the two is dead" >&2; exit 1; }; \ | |
| 351 | 385 | done | |
| 352 | 386 | ||
| 387 | + | # ===================================================================== | |
| 388 | + | # The component repo: our two binaries, packaged and never installed. | |
| 389 | + | # ===================================================================== | |
| 390 | + | # The runtime stage does not copy the console and the terminal into /usr/bin. | |
| 391 | + | # It carries this repo instead, and alloy-layer-components.service lays both | |
| 392 | + | # down as rpm-ostree layers on the first boot after an install. | |
| 393 | + | # | |
| 394 | + | # The reason is the one thing measured in build/layertest that changes the | |
| 395 | + | # design: a component the base image carries cannot be replaced client-side. | |
| 396 | + | # An installed base package fails to depsolve against a layer of the same name, | |
| 397 | + | # `override replace` records a request that never activates, and a loose file | |
| 398 | + | # in /usr/bin — which is what this image shipped until 2026-08-14 — is worse | |
| 399 | + | # than either, because layering over an unowned file dies in checkout with | |
| 400 | + | # "File exists". Any of the three is a machine whose console can never be | |
| 401 | + | # fixed without rebuilding an ISO and writing a drive. | |
| 402 | + | # | |
| 403 | + | # Carrying the RPMs is not carrying the component, and that distinction is the | |
| 404 | + | # whole design. What blocks a layer is an installed package or a file at the | |
| 405 | + | # path being layered over; an uninstalled .rpm under /usr/share is neither. So | |
| 406 | + | # the packages travel with the image, which means they travel on the ISO, which | |
| 407 | + | # means an offline install still produces a working machine and a machine whose | |
| 408 | + | # owner has not consented to anything yet never reaches the network for them. | |
| 409 | + | # | |
| 410 | + | # Packaged here rather than by build/rpm/build.sh, which is the standalone | |
| 411 | + | # hotfix path and cannot be called from a build stage. The specs are shared, so | |
| 412 | + | # the two produce the same package; `build.sh --component shop --binary` is how | |
| 413 | + | # a hotfix is cut from the very binary an image shipped. | |
| 414 | + | COPY build/rpm/alloy.spec build/rpm/shop.spec /src/rpm/ | |
| 415 | + | RUN set -eux; \ | |
| 416 | + | : "The version comes off each binary, and the console's is cross-checked"; \ | |
| 417 | + | : "against its crate. An RPM claiming a version the binary inside it does"; \ | |
| 418 | + | : "not report is invisible afterwards — rpm answers one thing, --version"; \ | |
| 419 | + | : "another, and --version is what a person quotes in a bug report."; \ | |
| 420 | + | : ""; \ | |
| 421 | + | : "head -1 is load-bearing, not caution. Both binaries answer --version"; \ | |
| 422 | + | : "with a version line followed by copyright and licence lines, so an awk"; \ | |
| 423 | + | : "over the whole output returns five words rather than one and the"; \ | |
| 424 | + | : "comparison below fails on a version that was correct all along."; \ | |
| 425 | + | alloy_version="$(/src/target/release/alloy --version | head -1 | awk '{print $2}')"; \ | |
| 426 | + | crate_version="$(grep -m1 '^version' /src/crates/alloy/Cargo.toml | cut -d'"' -f2)"; \ | |
| 427 | + | [ -n "$alloy_version" ] && [ "$alloy_version" = "$crate_version" ] \ | |
| 428 | + | || { echo "console reports '$alloy_version', crates/alloy/Cargo.toml says '$crate_version'" >&2; exit 1; }; \ | |
| 429 | + | : "shop is a separate repo pinned by revision, so there is no manifest in"; \ | |
| 430 | + | : "this context to check it against. It answers --version without opening"; \ | |
| 431 | + | : "a window, which is the only reason this can ask."; \ | |
| 432 | + | shop_version="$(/shop/bin/shop --version | head -1 | awk '{print $2}')"; \ | |
| 433 | + | [ -n "$shop_version" ] \ | |
| 434 | + | || { echo "shop did not report a version; SHOP_REV may predate it" >&2; exit 1; }; \ | |
| 435 | + | mkdir -p /rpmbuild/SOURCES /staged-rpm; \ | |
| 436 | + | install -m 0755 /src/target/release/alloy /rpmbuild/SOURCES/alloy; \ | |
| 437 | + | install -m 0755 /shop/bin/shop /rpmbuild/SOURCES/shop; \ | |
| 438 | + | rpmbuild --define '_topdir /rpmbuild' \ | |
| 439 | + | --define "alloy_version $alloy_version" -bb /src/rpm/alloy.spec; \ | |
| 440 | + | rpmbuild --define '_topdir /rpmbuild' \ | |
| 441 | + | --define "shop_version $shop_version" -bb /src/rpm/shop.spec; \ | |
| 442 | + | cp /rpmbuild/RPMS/*/*.rpm /staged-rpm/; \ | |
| 443 | + | createrepo_c /staged-rpm; \ | |
| 444 | + | : "Exactly one package each, and the metadata that makes them a repo. A"; \ | |
| 445 | + | : "repo missing one is an install that reaches the greeter with no console"; \ | |
| 446 | + | : "or no terminal behind it. Globbed rather than named: the %{dist} tag and"; \ | |
| 447 | + | : "the arch come from whichever host is building, and neither is this"; \ | |
| 448 | + | : "assertion's business — one file, carrying the version asked for, is."; \ | |
| 449 | + | set -- /staged-rpm/alloy-*.rpm; \ | |
| 450 | + | [ "$#" -eq 1 ] && [ -f "$1" ] \ | |
| 451 | + | || { echo "expected one console RPM, got: $*" >&2; exit 1; }; \ | |
| 452 | + | case "$1" in */alloy-"$alloy_version"-*) ;; \ | |
| 453 | + | *) echo "console RPM $1 is not version $alloy_version" >&2; exit 1;; esac; \ | |
| 454 | + | set -- /staged-rpm/shop-*.rpm; \ | |
| 455 | + | [ "$#" -eq 1 ] && [ -f "$1" ] \ | |
| 456 | + | || { echo "expected one terminal RPM, got: $*" >&2; exit 1; }; \ | |
| 457 | + | case "$1" in */shop-"$shop_version"-*) ;; \ | |
| 458 | + | *) echo "terminal RPM $1 is not version $shop_version" >&2; exit 1;; esac; \ | |
| 459 | + | [ -f /staged-rpm/repodata/repomd.xml ] \ | |
| 460 | + | || { echo "createrepo_c wrote no metadata; the carried repo would resolve nothing" >&2; exit 1; } | |
| 461 | + | ||
| 353 | 462 | # ===================================================================== | |
| 354 | 463 | # Runtime image — the bootable container itself. | |
| 355 | 464 | # ===================================================================== | |
| @@ -422,6 +531,33 @@ | |||
| 422 | 531 | # the gate lives here so it holds even when the TUI is bypassed. | |
| 423 | 532 | ARG LANGS=rust | |
| 424 | 533 | ||
| 534 | + | # TRIM is the one builder choice about the *base* rather than about Alloy. | |
| 535 | + | # fedora-bootc is a general-purpose server base, and three of the things it | |
| 536 | + | # carries are unreachable from any Alloy install however the machine is used: | |
| 537 | + | # python3-botocore (an AWS SDK, with boto3 and s3transfer behind it), | |
| 538 | + | # qemu-user-static for eighteen foreign architectures, and toolbox, whose job | |
| 539 | + | # distrobox already does here. Measured 2026-08-15 against this image: 297 MiB | |
| 540 | + | # across 22 packages, and nothing else in the image requires any of them. | |
| 541 | + | # | |
| 542 | + | # It is a build-speed choice as much as a size one. Everything downstream of | |
| 543 | + | # the image is proportional to its size — mkfs and the squashfs on the way to | |
| 544 | + | # an ISO, the dd to a stick, the install onto a disk — so the base's dead | |
| 545 | + | # weight is paid four times by whoever builds and installs. | |
| 546 | + | # | |
| 547 | + | # WHAT THIS NEVER TOUCHES IS FIRMWARE, and that is a decision rather than an | |
| 548 | + | # oversight. `nvidia-gpu-firmware` alone is 101 MiB and is the largest single | |
| 549 | + | # thing a naive trim would take; astra needs it, and a medium built here has | |
| 550 | + | # to boot hardware nobody asked about at build time. The assertion below holds | |
| 551 | + | # firmware present on both branches so a future edit to this list cannot | |
| 552 | + | # quietly reach it. | |
| 553 | + | # | |
| 554 | + | # The accepted cost of `unused` is binfmt emulation: no `podman run` of a | |
| 555 | + | # foreign-architecture container. Every rule in ~/Code/CLAUDE.md points that | |
| 556 | + | # way already (builds are native per architecture, nothing is cross-compiled), | |
| 557 | + | # so this removes a capability the house style forbids using. `keep` is there | |
| 558 | + | # for whoever disagrees on their own machine. | |
| 559 | + | ARG TRIM=unused | |
| 560 | + | ||
| 425 | 561 | # Unconditional, and first. Everything downstream trusts that these are | |
| 426 | 562 | # words from known sets, so this is the assertion the other assertions | |
| 427 | 563 | # stand on. `PROFILE=cleint` has to die here rather than silently build a | |
| @@ -442,11 +578,15 @@ | |||
| 442 | 578 | *) echo "unknown language '$lang'; the builder offers rust, go, python and zig" >&2; exit 1 ;; \ | |
| 443 | 579 | esac; \ | |
| 444 | 580 | done; \ | |
| 581 | + | case "$TRIM" in \ | |
| 582 | + | unused|keep) ;; \ | |
| 583 | + | *) echo "unknown TRIM '$TRIM'; expected 'unused' or 'keep'" >&2; exit 1 ;; \ | |
| 584 | + | esac; \ | |
| 445 | 585 | case "$PROFILE:$BROWSER" in \ | |
| 446 | 586 | client:*|server:none) ;; \ | |
| 447 | 587 | server:*) echo "PROFILE=server ships no graphical session and cannot carry BROWSER=$BROWSER" >&2; exit 1 ;; \ | |
| 448 | 588 | esac; \ | |
| 449 | - | echo "building profile=$PROFILE browser=$BROWSER langs=$LANGS" | |
| 589 | + | echo "building profile=$PROFILE browser=$BROWSER langs=$LANGS trim=$TRIM" | |
| 450 | 590 | ||
| 451 | 591 | # ===================================================================== | |
| 452 | 592 | # Third-party repos | |
| @@ -1208,6 +1348,36 @@ | |||
| 1208 | 1348 | foot \ | |
| 1209 | 1349 | || true | |
| 1210 | 1350 | ||
| 1351 | + | # The base's own dead weight, under TRIM. The list and its cost are argued at | |
| 1352 | + | # `ARG TRIM` above; this is only the removal. | |
| 1353 | + | # | |
| 1354 | + | # Both branches assert, the same way the PROFILE conditionals do. `unused` | |
| 1355 | + | # proves the packages are really gone rather than trusting a glob that may | |
| 1356 | + | # have matched nothing, and refuses to pass silently if it found none of them | |
| 1357 | + | # to remove: an empty match means the base composition moved and this list is | |
| 1358 | + | # describing a machine that no longer exists, which is the state a comment | |
| 1359 | + | # would sit in for a year. Firmware is checked on BOTH branches, because the | |
| 1360 | + | # thing that must stay true is that no trim ever reaches it. | |
| 1361 | + | RUN set -eu; \ | |
| 1362 | + | if [ "$TRIM" = unused ]; then \ | |
| 1363 | + | list="python3-botocore toolbox qemu-user-static*"; \ | |
| 1364 | + | gone="$(rpm -qa --qf '%{NAME}\n' $list)"; \ | |
| 1365 | + | [ -n "$gone" ] \ | |
| 1366 | + | || { 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; }; \ | |
| 1367 | + | dnf remove -y $gone; \ | |
| 1368 | + | for pkg in python3-botocore toolbox; do \ | |
| 1369 | + | ! rpm -q "$pkg" >/dev/null 2>&1 \ | |
| 1370 | + | || { echo "$pkg survived the trim" >&2; exit 1; }; \ | |
| 1371 | + | done; \ | |
| 1372 | + | echo "trim: removed $(echo "$gone" | wc -l) base packages nothing in Alloy reaches"; \ | |
| 1373 | + | else \ | |
| 1374 | + | echo "trim: none, the base ships as it comes"; \ | |
| 1375 | + | fi; \ | |
| 1376 | + | for fw in linux-firmware nvidia-gpu-firmware; do \ | |
| 1377 | + | rpm -q "$fw" >/dev/null 2>&1 \ | |
| 1378 | + | || { 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; }; \ | |
| 1379 | + | done | |
| 1380 | + | ||
| 1211 | 1381 | # ===================================================================== | |
| 1212 | 1382 | # System user for greetd. greetd drops privileges to this account | |
| 1213 | 1383 | # before spawning tuigreet; without it greetd exits with | |
| @@ -1961,6 +2131,7 @@ | |||
| 1961 | 2131 | echo "profile = \"$PROFILE\""; \ | |
| 1962 | 2132 | echo "browser = \"$BROWSER\""; \ | |
| 1963 | 2133 | echo "langs = $langs"; \ | |
| 2134 | + | echo "trim = \"$TRIM\""; \ | |
| 1964 | 2135 | echo "hostname = \"$ALLOY_HOSTNAME\""; \ | |
| 1965 | 2136 | echo "pubkey = \"\""; \ | |
| 1966 | 2137 | } > /usr/lib/alloy/build.toml; \ | |
| @@ -2065,40 +2236,101 @@ | |||
| 2065 | 2236 | RUN echo 43 > /etc/dnf/vars/releasever | |
| 2066 | 2237 | ||
| 2067 | 2238 | # ===================================================================== | |
| 2068 | - | # The Alloy console. | |
| 2239 | + | # The Alloy console and terminal, as carried packages. | |
| 2069 | 2240 | # ===================================================================== | |
| 2241 | + | # Neither binary is in this image. Until 2026-08-14 both were COPYed into | |
| 2242 | + | # /usr/bin, and that is the one shape in which a component can never be | |
| 2243 | + | # fixed on a machine somebody already installed: a layer over an unowned | |
| 2244 | + | # file does not fail to depsolve, it dies in checkout with "File exists". | |
| 2245 | + | # The rust-build stage's "component repo" block above carries the reasoning | |
| 2246 | + | # and build/layertest holds the measurements. | |
| 2247 | + | # | |
| 2248 | + | # So the image carries the packages and installs neither. | |
| 2249 | + | # alloy-layer-components.service lays them down on the first boot after an | |
| 2250 | + | # install, from this repo, with no network. That unit has shipped inert | |
| 2251 | + | # since alloy@0e9d9c5, gated on the repo being present and /usr/bin/alloy | |
| 2252 | + | # being absent; this is the commit that makes the second condition true. | |
| 2253 | + | # | |
| 2070 | 2254 | # Last before the lint on purpose, and later than the config tree the | |
| 2071 | 2255 | # header calls the most-changed layer — the console changes with every | |
| 2072 | 2256 | # commit to crates/, which is more often still. Nothing below it means a | |
| 2073 | 2257 | # console-only rebuild reuses every package and config layer above. | |
| 2258 | + | COPY --from=rust-build /staged-rpm /usr/share/alloy/rpm | |
| 2259 | + | ||
| 2260 | + | # enabled=1, and it is forced rather than chosen. `rpm-ostree install` on a | |
| 2261 | + | # booted system talks to rpm-ostreed over D-Bus, and its --enablerepo is | |
| 2262 | + | # "only supported in a container build" — so a repo shipped disabled is a | |
| 2263 | + | # repo the first-boot unit cannot turn on for its own transaction, and the | |
| 2264 | + | # machine comes up with no console. The standing "repo enabled by default | |
| 2265 | + | # or opt-in" question (GO d866e125) is about the future NETWORK repo, where | |
| 2266 | + | # the answer is a real choice; this one reaches no network and answers to | |
| 2267 | + | # nothing but the local filesystem. | |
| 2074 | 2268 | # | |
| 2075 | - | # /usr/bin rather than /usr/local/bin: /usr/local is not part of a bootc | |
| 2076 | - | # image's immutable tree, and the console is shipped software, not | |
| 2077 | - | # something the operator dropped in. | |
| 2078 | - | COPY --from=rust-build /src/target/release/alloy /usr/bin/alloy | |
| 2269 | + | # gpgcheck=0 is a marker for signing being unfinished (GO d866e125 subtask | |
| 2270 | + | # 4), not a decision. It is a weaker exposure than an unsigned network repo | |
| 2271 | + | # — these files came in with the image and are only as trustworthy as it is | |
| 2272 | + | # — but "the image is the trust boundary" is a claim signing should make | |
| 2273 | + | # explicitly rather than one this file should assume. | |
| 2274 | + | RUN printf '[alloy-local]\nname=Alloy components, carried with the image\nbaseurl=file:///usr/share/alloy/rpm\nenabled=1\ngpgcheck=0\n' \ | |
| 2275 | + | > /etc/yum.repos.d/alloy-local.repo | |
| 2079 | 2276 | ||
| 2080 | - | # The terminal, from the same stage. /usr/bin for the same reason as the | |
| 2081 | - | # console: shipped software, and /usr/local is not part of a bootc image's | |
| 2082 | - | # immutable tree. | |
| 2083 | - | COPY --from=rust-build /shop/bin/shop /usr/bin/shop | |
| 2084 | - | ||
| 2085 | - | # shop is a Wayland client, so it has nothing to connect to on `server` and | |
| 2086 | - | # is removed there. It is copied first and pruned second because COPY takes | |
| 2087 | - | # no condition, and the build-stage assertion that shop installed at all | |
| 2088 | - | # stays unconditional in the rust-build stage above, where it belongs: the | |
| 2089 | - | # terminal failing to build is a bug on both profiles. | |
| 2277 | + | # What the first-boot unit layers, as a list the image writes rather than a | |
| 2278 | + | # package set the unit hardcodes. That is what makes the profile split work | |
| 2279 | + | # at all: `server` has no compositor, so a Wayland terminal has nothing to | |
| 2280 | + | # connect to and must not be laid down, and the unit is one static file | |
| 2281 | + | # shared by both profiles. Writing the list here keeps the decision where | |
| 2282 | + | # every other profile decision in this file already lives. | |
| 2283 | + | # | |
| 2284 | + | # Both RPMs are carried on both profiles even so. Dropping shop's file on | |
| 2285 | + | # `server` would leave the repo metadata describing a package that is not | |
| 2286 | + | # there, which is a landmine for anyone who later resolves against it, and | |
| 2287 | + | # regenerating metadata would want createrepo_c in the runtime image for | |
| 2288 | + | # the sake of deleting three megabytes. A server operator who wants the | |
| 2289 | + | # terminal can install it; nothing lays it down for them. | |
| 2090 | 2290 | RUN if [ "$PROFILE" = client ]; then \ | |
| 2091 | - | test -x /usr/bin/shop \ | |
| 2092 | - | || { echo "shop did not land; the session would have no terminal" >&2; exit 1; }; \ | |
| 2291 | + | printf 'alloy\nshop\n' > /usr/share/alloy/components; \ | |
| 2292 | + | grep -qx shop /usr/share/alloy/components \ | |
| 2293 | + | || { echo "profile=client would come up with no terminal" >&2; exit 1; }; \ | |
| 2093 | 2294 | else \ | |
| 2094 | - | rm -f /usr/bin/shop; \ | |
| 2095 | - | test ! -e /usr/bin/shop \ | |
| 2096 | - | || { echo "profile=server still carries the terminal" >&2; exit 1; }; \ | |
| 2295 | + | printf 'alloy\n' > /usr/share/alloy/components; \ | |
| 2296 | + | ! grep -qx shop /usr/share/alloy/components \ | |
| 2297 | + | || { echo "profile=server would layer a Wayland terminal on a machine with no compositor" >&2; exit 1; }; \ | |
| 2097 | 2298 | fi | |
| 2098 | 2299 | ||
| 2099 | - | # The themes that binary refuses to run without. Kept next to it so the | |
| 2100 | - | # two move together: shipping the console without these is not a degraded | |
| 2101 | - | # console, it is one that exits on launch. | |
| 2300 | + | # The carried repo is complete, and neither binary leaked into the image. | |
| 2301 | + | # | |
| 2302 | + | # Unconditional, and both halves matter. A repo that lost its metadata | |
| 2303 | + | # resolves nothing, and the unit's failure mode is a machine that boots to a | |
| 2304 | + | # greeter, opens a session with no console and no terminal, and offers no way | |
| 2305 | + | # to fix either from inside it. A /usr/bin/alloy that came back — a restored | |
| 2306 | + | # COPY, a `dnf install` somewhere above — is quieter and worse: the unit's | |
| 2307 | + | # second condition goes false, it never runs, the machine works, and the | |
| 2308 | + | # console it carries can never be replaced. That is the whole failure this | |
| 2309 | + | # design exists to prevent, and it would ship looking healthy. | |
| 2310 | + | RUN set -eux; \ | |
| 2311 | + | test -f /usr/share/alloy/rpm/repodata/repomd.xml \ | |
| 2312 | + | || { echo "the carried repo has no metadata; first boot would layer nothing" >&2; exit 1; }; \ | |
| 2313 | + | test -n "$(ls /usr/share/alloy/rpm/*.rpm)" \ | |
| 2314 | + | || { echo "the carried repo holds no packages" >&2; exit 1; }; \ | |
| 2315 | + | test ! -e /usr/bin/alloy \ | |
| 2316 | + | || { echo "the image carries /usr/bin/alloy; alloy-layer-components.service will never fire and the console can never be fixed" >&2; exit 1; }; \ | |
| 2317 | + | test ! -e /usr/bin/shop \ | |
| 2318 | + | || { echo "the image carries /usr/bin/shop; an unowned file cannot be layered over" >&2; exit 1; } | |
| 2319 | + | ||
| 2320 | + | # The themes the console refuses to run without: with no theme file on any | |
| 2321 | + | # search path it does not fall back, it exits. | |
| 2322 | + | # | |
| 2323 | + | # These stay in the base image while the binary that reads them travels as a | |
| 2324 | + | # package, which is a deliberate split rather than an oversight. They are data | |
| 2325 | + | # the whole image shares — the rendered skeleton above came out of the same two | |
| 2326 | + | # files — so packaging them with the console would put one copy under rpm and | |
| 2327 | + | # another under ostree and let them disagree. | |
| 2328 | + | # | |
| 2329 | + | # What it costs, and it is worth knowing before writing a hotfix: a console fix | |
| 2330 | + | # that needs a NEW theme token cannot ship through the RPM channel, because the | |
| 2331 | + | # token would have to arrive in the base. That makes it an image change, which | |
| 2332 | + | # is a release rather than a hotfix (wiki `hotfix-policy`), so the constraint | |
| 2333 | + | # and the policy already agree. | |
| 2102 | 2334 | COPY --from=rust-build /staged-themes /usr/share/alloy/themes | |
| 2103 | 2335 | ||
| 2104 | 2336 | # The schemas the settings view's Applications tab is built from. Straight |
| @@ -22,7 +22,25 @@ | |||
| 22 | 22 | WORK=/work | |
| 23 | 23 | VOLID="ALLOY" | |
| 24 | 24 | ||
| 25 | - | say() { printf '==> %s\n' "$*"; } | |
| 25 | + | # Every phase line carries the seconds since the last one, because the ISO is | |
| 26 | + | # where this build spends most of its wall clock and nobody could say on what. | |
| 27 | + | # The four minutes were assumed to be compression until they were measured; | |
| 28 | + | # they were not (mksquashfs at the fast level is 8s of it). A build that | |
| 29 | + | # reports its own shape is the difference between optimizing the slow part and | |
| 30 | + | # optimizing the part that looks slow. | |
| 31 | + | # | |
| 32 | + | # The number on a line is the time spent getting TO it, not the time the phase | |
| 33 | + | # it announces will take, because a phase cannot report its own cost before | |
| 34 | + | # doing the work. Read a line's figure as the price of the line above it. | |
| 35 | + | START_MARK=$(date +%s) | |
| 36 | + | LAST_MARK=$START_MARK | |
| 37 | + | say() { | |
| 38 | + | local now elapsed | |
| 39 | + | now=$(date +%s) | |
| 40 | + | elapsed=$((now - LAST_MARK)) | |
| 41 | + | LAST_MARK=$now | |
| 42 | + | printf '==> [+%3ds] %s\n' "$elapsed" "$*" | |
| 43 | + | } | |
| 26 | 44 | ||
| 27 | 45 | [ -d "$ROOTFS" ] || { echo "no $ROOTFS" >&2; exit 1; } | |
| 28 | 46 | [ -d "$OUTPUT" ] || { echo "no $OUTPUT" >&2; exit 1; } | |
| @@ -148,9 +166,67 @@ | |||
| 148 | 166 | truncate -s "${IMG_MB}M" "$WORK/sqroot/LiveOS/rootfs.img" | |
| 149 | 167 | mkfs.ext4 -q -L Alloy -N "$INODES" -d "$ROOTFS" "$WORK/sqroot/LiveOS/rootfs.img" | |
| 150 | 168 | ||
| 151 | - | # zstd level 19 costs minutes of saturated CPU for a few percent of size, | |
| 152 | - | # which is worth it for a release and pure waste when the question is | |
| 153 | - | # whether the thing boots at all. ALLOY_ISO_FAST picks the cheap level. | |
| 169 | + | # The console, into the live root and nowhere else. | |
| 170 | + | # | |
| 171 | + | # The image deliberately does not carry /usr/bin/alloy. A component the base | |
| 172 | + | # carries can never be replaced client-side, so the console and shop travel as | |
| 173 | + | # uninstalled RPMs at /usr/share/alloy/rpm and are layered on the first boot | |
| 174 | + | # after an install (Containerfile, "The component repo"; measurements in | |
| 175 | + | # build/layertest). | |
| 176 | + | # | |
| 177 | + | # The live environment is the one place that cannot wait for a first boot. It | |
| 178 | + | # never gets one, it is thrown away when the install finishes, and `alloy | |
| 179 | + | # install` is the entire reason the medium exists: alloy-installer.service and | |
| 180 | + | # the installer account's ForceCommand both name /usr/bin/alloy. Without this | |
| 181 | + | # the ISO boots to a machine with no installer, which is how it behaved for | |
| 182 | + | # exactly one build. | |
| 183 | + | # | |
| 184 | + | # It goes into the mounted image rather than into $ROOTFS, which is the | |
| 185 | + | # read-only mount of the shipped image and must stay exactly what installs. | |
| 186 | + | # `bootc install` reads the OCI layout under /source, not this tree, so the | |
| 187 | + | # console lands in the live session and reaches no installed machine. | |
| 188 | + | # | |
| 189 | + | # From the carried repo rather than by copying a binary in, and the difference | |
| 190 | + | # is what it proves: this is the first thing that ever resolves against | |
| 191 | + | # /usr/share/alloy/rpm, so a package that cannot install fails here, on a build | |
| 192 | + | # host, instead of at somebody's first boot where the symptom is a session that | |
| 193 | + | # will not start. rpm rather than dnf because the repo is on the read-only tree | |
| 194 | + | # and the dependencies are already in the base, so nothing needs resolving. | |
| 195 | + | say "installing the console into the live root" | |
| 196 | + | mkdir -p "$WORK/liveroot" | |
| 197 | + | mount -o loop "$WORK/sqroot/LiveOS/rootfs.img" "$WORK/liveroot" | |
| 198 | + | set -- "$ROOTFS"/usr/share/alloy/rpm/alloy-*.rpm | |
| 199 | + | [ "$#" -eq 1 ] && [ -f "$1" ] \ | |
| 200 | + | || { echo "expected one console RPM in the carried repo, got: $*" >&2; umount "$WORK/liveroot"; exit 1; } | |
| 201 | + | rpm --root "$WORK/liveroot" -i "$1" \ | |
| 202 | + | || { echo "the console RPM did not install into the live root" >&2; umount "$WORK/liveroot"; exit 1; } | |
| 203 | + | test -x "$WORK/liveroot/usr/bin/alloy" \ | |
| 204 | + | || { echo "no /usr/bin/alloy in the live root; the ISO would boot with no installer" >&2; umount "$WORK/liveroot"; exit 1; } | |
| 205 | + | umount "$WORK/liveroot" | |
| 206 | + | rmdir "$WORK/liveroot" | |
| 207 | + | ||
| 208 | + | # ALLOY_ISO_FAST picks the cheap level, for when the question is whether the | |
| 209 | + | # thing boots at all rather than how big it is. | |
| 210 | + | # | |
| 211 | + | # Measured on fw13 (12 cores) against this image, 2026-08-15, 6.4G of rootfs: | |
| 212 | + | # | |
| 213 | + | # level time squashfs.img | |
| 214 | + | # 3 8s 2.519 G | |
| 215 | + | # 9 20s 2.412 G | |
| 216 | + | # 12 29s 2.405 G | |
| 217 | + | # 15 63s 2.398 G | |
| 218 | + | # 19 192s 2.264 G | |
| 219 | + | # | |
| 220 | + | # Two things in that table, and both contradict what this comment used to say. | |
| 221 | + | # It claimed level 19 costs "about ten minutes of saturated CPU for a few | |
| 222 | + | # percent of size"; it costs three, and it is the only level that buys anything | |
| 223 | + | # after 9 — the jump at 19 is zstd's long-distance matching, which the levels | |
| 224 | + | # below it do not enable. And 12 and 15 are dominated: 15 spends 43 seconds | |
| 225 | + | # more than 9 to save 14 MB. | |
| 226 | + | # | |
| 227 | + | # So the two levels here are the two worth having. 19 for a release, where | |
| 228 | + | # three minutes is nothing against 255 MB off every stick and every install. | |
| 229 | + | # 3 for iteration, where eight seconds is close enough to free. | |
| 154 | 230 | if [ "${ALLOY_ISO_FAST:-0}" = "1" ]; then | |
| 155 | 231 | COMP_LEVEL=3 | |
| 156 | 232 | say "compressing squashfs.img (fast mode, level $COMP_LEVEL)" | |
| @@ -558,3 +634,6 @@ | |||
| 558 | 634 | ||
| 559 | 635 | chmod 0644 "$OUTPUT/install.iso" | |
| 560 | 636 | say "built $(du -h "$OUTPUT/install.iso" | cut -f1) at $OUTPUT/install.iso" | |
| 637 | + | ||
| 638 | + | TOTAL=$(( $(date +%s) - START_MARK )) | |
| 639 | + | printf '==> total %dm%02ds\n' "$((TOTAL / 60))" "$((TOTAL % 60))" |
| @@ -40,6 +40,10 @@ | |||
| 40 | 40 | ||
| 41 | 41 | **A hotfixable component cannot be in the base image.** This is measured, not chosen. A component the base carries cannot be replaced client-side: `rpm-ostree install` fails to depsolve against it, and `rpm-ostree override replace` records a request that never activates and survives a reboot as a silent no-op, so the machine reports a hotfix it is not running. A component that is a loose file owned by no package is worse still, and cannot be layered over at all. So the console and shop are layered at install time rather than built into the image. Measurements and how to re-run them: `build/layertest/README.md`. | |
| 42 | 42 | ||
| 43 | + | **They ride inside the image as packages, and the first boot lays them down.** The image carries `alloy` and `shop` as RPM files in a `file://` repo at `/usr/share/alloy/rpm` and installs neither. Carrying the RPMs is not carrying the component: what blocks a layer is an installed package or a file at the path being layered over, and an uninstalled `.rpm` under `/usr/share` is neither. Because the ISO is the image, the packages travel on the medium, so an offline install produces a working machine and a machine whose owner has not consented to anything yet never reaches the network to become usable. `alloy-layer-components.service` installs what `/usr/share/alloy/components` names on the first boot after an install and reboots once; `/usr` is a read-only ostree tree, so there is no version of this that ends with the console usable in the session it ran from. The live installer environment is the exception and installs the console from the same repo, since it never gets a first boot and `alloy install` is the reason it exists. | |
| 44 | + | ||
| 45 | + | That repo is enabled, and it is forced rather than chosen: `rpm-ostree install` on a booted system supports `--enablerepo` only in a container build, so a repo shipped disabled is one the first-boot unit could not turn on for its own transaction. It reaches no network, which is why this does not touch the position below. | |
| 46 | + | ||
| 43 | 47 | **Our layers are disposable, and `alloy update` is what makes them so.** A layered package whose version the base later overtakes wedges the machine: every subsequent update fails to depsolve, permanently, and that is the normal life of a hotfix rather than an edge case. The rule that avoids it is to carry no layer of ours across an upgrade. Drop them, upgrade, re-apply only if the base still lacks the fix. It costs one reboot rather than two, because the two transactions compose into one deployment, and it hands `bootc upgrade` back a deployment it will consent to work on. | |
| 44 | 48 | ||
| 45 | 49 | Dropping them has to be precise. A request is recorded under the string that was typed to install it, so a package layered by full name-version-release cannot be removed by its bare name, and rpm-ostree reports that as nothing-to-do rather than as a failure. `alloy update` reads `requested-packages` and removes exactly what it finds. Not `rpm-ostree reset`, which would also drop packages the user layered themselves, and those are not ours to remove. |
| @@ -10,7 +10,7 @@ | |||
| 10 | 10 | # rpm the component is an RPM installed at build time, so it is a base | |
| 11 | 11 | # package. This is what an RPM channel would want. | |
| 12 | 12 | # loose the component is a file copied into /usr/bin owned by no package. | |
| 13 | - | # This is what Alloy does today (Containerfile:2067 and :2072). | |
| 13 | + | # What Alloy did until 2026-08-14, and what this measurement ended. | |
| 14 | 14 | # none the image does not carry the component at all, so a hotfix is a | |
| 15 | 15 | # plain layered package rather than a replacement. | |
| 16 | 16 | # carry the image carries the component's RPM as a FILE, in a file:// repo |
| @@ -46,9 +46,10 @@ | |||
| 46 | 46 | Reproduce with variant `r1`. | |
| 47 | 47 | ||
| 48 | 48 | **A component that is an unowned file cannot be layered over at all.** This is | |
| 49 | - | the shape Alloy actually ships: `Containerfile:2067` and `:2072` copy the | |
| 50 | - | console and shop into `/usr/bin`, owned by no package. Layering an RPM that | |
| 51 | - | carries the same path dies in checkout rather than in depsolve: | |
| 49 | + | the shape Alloy shipped until 2026-08-14: two `COPY` lines put the console and | |
| 50 | + | shop into `/usr/bin`, owned by no package. Both are gone, and this measurement | |
| 51 | + | is why. Layering an RPM that carries the same path dies in checkout rather than | |
| 52 | + | in depsolve: | |
| 52 | 53 | ||
| 53 | 54 | error: Checkout alloy-demo-0.0.2: Hardlinking ... to alloy-demo: File exists | |
| 54 | 55 |
| @@ -1,10 +1,19 @@ | |||
| 1 | 1 | #!/usr/bin/env bash | |
| 2 | 2 | # | |
| 3 | - | # build.sh — package the console as an RPM, and put it in a repo. | |
| 3 | + | # build.sh — package a component as an RPM, and put it in a repo. | |
| 4 | 4 | # | |
| 5 | - | # build.sh build the console from the tree, package it | |
| 6 | - | # build.sh --binary PATH package a binary somebody else already built | |
| 7 | - | # build.sh --out DIR write somewhere other than build/rpm/out | |
| 5 | + | # build.sh build the console from the tree, package it | |
| 6 | + | # build.sh --binary PATH package a binary somebody else already built | |
| 7 | + | # build.sh --component shop \ | |
| 8 | + | # --binary PATH package the terminal | |
| 9 | + | # build.sh --out DIR write somewhere other than build/rpm/out | |
| 10 | + | # | |
| 11 | + | # The console is the default because it is the one component this repo can | |
| 12 | + | # build. shop lives in its own repo, so there is nothing here to compile and | |
| 13 | + | # --binary is the only way to package it — normally the binary the image | |
| 14 | + | # shipped, taken out of a build with `podman cp`, so the hotfix is byte-for-byte | |
| 15 | + | # what was tested. The image builds both itself, from these same specs, in the | |
| 16 | + | # Containerfile's rust-build stage. | |
| 8 | 17 | # | |
| 9 | 18 | # This is the supply side of the hotfix channel (GoingsOn task d866e125). Alloy | |
| 10 | 19 | # is distributed as a builder rather than as an image, so a machine that is | |
| @@ -36,22 +45,42 @@ | |||
| 36 | 45 | say() { printf '%s\n' "$*"; } | |
| 37 | 46 | ||
| 38 | 47 | BINARY="" | |
| 48 | + | COMPONENT="alloy" | |
| 39 | 49 | OUT="$HERE/out" | |
| 40 | 50 | while [ $# -gt 0 ]; do | |
| 41 | 51 | case "$1" in | |
| 42 | - | --binary) BINARY="${2:-}"; shift 2 ;; | |
| 43 | - | --out) OUT="${2:-}"; shift 2 ;; | |
| 52 | + | --binary) BINARY="${2:-}"; shift 2 ;; | |
| 53 | + | --component) COMPONENT="${2:-}"; shift 2 ;; | |
| 54 | + | --out) OUT="${2:-}"; shift 2 ;; | |
| 44 | 55 | *) die "unknown argument $1" ;; | |
| 45 | 56 | esac | |
| 46 | 57 | done | |
| 47 | 58 | ||
| 48 | - | # The version comes from the crate and from nowhere else. A packaging script | |
| 49 | - | # that took its own --version argument is how an RPM ends up claiming a version | |
| 50 | - | # the binary inside it does not report, and `alloy --version` is what a person | |
| 51 | - | # quotes in a bug report. | |
| 52 | - | VERSION="$(grep -m1 '^version' "$REPO_ROOT/crates/alloy/Cargo.toml" | cut -d'"' -f2)" | |
| 53 | - | [ -n "$VERSION" ] || die "no version in crates/alloy/Cargo.toml" | |
| 54 | - | [ "$VERSION" != "0.0.0" ] || die "the console is still at 0.0.0; see GO 8c5e2838" | |
| 59 | + | case "$COMPONENT" in | |
| 60 | + | alloy|shop) ;; | |
| 61 | + | *) die "unknown component $COMPONENT (alloy, shop)" ;; | |
| 62 | + | esac | |
| 63 | + | [ "$COMPONENT" != shop ] || [ -n "$BINARY" ] \ | |
| 64 | + | || die "shop is a separate repo; package it with --binary PATH" | |
| 65 | + | ||
| 66 | + | # Where the version comes from, and it is never an argument to this script. An | |
| 67 | + | # RPM that claims a version the binary inside it does not report is invisible | |
| 68 | + | # afterwards: it installs, rpm answers one thing, `--version` answers another, | |
| 69 | + | # and `--version` is what a person quotes in a bug report. | |
| 70 | + | # | |
| 71 | + | # The console has a crate here to read, so that is the source and the binary is | |
| 72 | + | # checked against it below. shop does not, so its own `--version` is the only | |
| 73 | + | # statement of record — which is why it is asked before anything else happens | |
| 74 | + | # rather than trusted after packaging. | |
| 75 | + | if [ "$COMPONENT" = alloy ]; then | |
| 76 | + | VERSION="$(grep -m1 '^version' "$REPO_ROOT/crates/alloy/Cargo.toml" | cut -d'"' -f2)" | |
| 77 | + | [ -n "$VERSION" ] || die "no version in crates/alloy/Cargo.toml" | |
| 78 | + | [ "$VERSION" != "0.0.0" ] || die "the console is still at 0.0.0; see GO 8c5e2838" | |
| 79 | + | else | |
| 80 | + | [ -x "$BINARY" ] || die "no shop binary at $BINARY" | |
| 81 | + | VERSION="$("$BINARY" --version 2>/dev/null | head -1 | awk '{print $2}')" | |
| 82 | + | [ -n "$VERSION" ] || die "$BINARY did not report a version" | |
| 83 | + | fi | |
| 55 | 84 | ||
| 56 | 85 | if [ -z "$BINARY" ]; then | |
| 57 | 86 | say "building the console $VERSION" | |
| @@ -69,27 +98,31 @@ | |||
| 69 | 98 | (cd "$REPO_ROOT" && cargo build --release -p alloy) | |
| 70 | 99 | BINARY="$REPO_ROOT/target/release/alloy" | |
| 71 | 100 | fi | |
| 72 | - | [ -x "$BINARY" ] || die "no console binary at $BINARY" | |
| 101 | + | [ -x "$BINARY" ] || die "no $COMPONENT binary at $BINARY" | |
| 73 | 102 | ||
| 74 | 103 | # Checked rather than trusted. Packaging a binary whose version disagrees with | |
| 75 | 104 | # the spec's is the one mistake this script exists to make impossible, and it | |
| 76 | 105 | # is invisible afterwards: the RPM installs, the machine reports the version | |
| 77 | 106 | # rpm knows, and the binary reports another. | |
| 107 | + | # | |
| 108 | + | # For shop the two came from the same place a moment ago, so this is a re-read | |
| 109 | + | # rather than a comparison. Left in the same path anyway: --binary can point at | |
| 110 | + | # one file and the version have been taken from another only if this changes. | |
| 78 | 111 | REPORTED="$("$BINARY" --version 2>/dev/null | head -1 | awk '{print $2}')" | |
| 79 | 112 | [ "$REPORTED" = "$VERSION" ] \ | |
| 80 | - | || die "binary reports $REPORTED, Cargo.toml says $VERSION" | |
| 113 | + | || die "binary reports $REPORTED, expected $VERSION" | |
| 81 | 114 | ||
| 82 | 115 | mkdir -p "$OUT" | |
| 83 | 116 | rm -rf "${OUT:?}/rpmbuild" "${OUT:?}/repo" | |
| 84 | 117 | mkdir -p "$OUT/rpmbuild/SOURCES" "$OUT/repo" | |
| 85 | - | install -m 0755 "$BINARY" "$OUT/rpmbuild/SOURCES/alloy" | |
| 118 | + | install -m 0755 "$BINARY" "$OUT/rpmbuild/SOURCES/$COMPONENT" | |
| 86 | 119 | ||
| 87 | 120 | # In a container rather than on the host: rpmbuild and createrepo_c are not on | |
| 88 | 121 | # a dev box by default, and the package has to carry the same %{dist} as the | |
| 89 | 122 | # base it installs into. fedora:43 matches the image; a package built as .fc42 | |
| 90 | 123 | # lands in a repo the machine will resolve and then refuses to be what anyone | |
| 91 | 124 | # asked for. | |
| 92 | - | say "packaging alloy-$VERSION" | |
| 125 | + | say "packaging $COMPONENT-$VERSION" | |
| 93 | 126 | privc podman run --rm \ | |
| 94 | 127 | -v "$HERE:/spec:ro,z" \ | |
| 95 | 128 | -v "$OUT:/out:z" \ | |
| @@ -97,8 +130,8 @@ | |||
| 97 | 130 | registry.fedoraproject.org/fedora:43 bash -c " | |
| 98 | 131 | set -e | |
| 99 | 132 | dnf -y install rpm-build createrepo_c >/dev/null 2>&1 | |
| 100 | - | rpmbuild --define '_topdir /out/rpmbuild' --define 'alloy_version $VERSION' \ | |
| 101 | - | -bb /spec/alloy.spec >/dev/null | |
| 133 | + | rpmbuild --define '_topdir /out/rpmbuild' --define '${COMPONENT}_version $VERSION' \ | |
| 134 | + | -bb /spec/$COMPONENT.spec >/dev/null | |
| 102 | 135 | cp /out/rpmbuild/RPMS/*/*.rpm /out/repo/ | |
| 103 | 136 | createrepo_c /out/repo >/dev/null | |
| 104 | 137 | " >/dev/null |
| @@ -37,7 +37,8 @@ | |||
| 37 | 37 | # other direction, which is why the condition lives in the unit and not here. | |
| 38 | 38 | enable alloy-installer-ssh.service | |
| 39 | 39 | # Lays the console and shop down as layered packages on a freshly installed | |
| 40 | - | # machine, from the repo the installer copied off the medium. They cannot ship | |
| 40 | + | # machine, from the repo that rides inside the image at /usr/share/alloy/rpm, | |
| 41 | + | # so it needs no network and nothing copied off the medium. They cannot ship | |
| 41 | 42 | # inside the image: a component the base carries can never be replaced | |
| 42 | 43 | # client-side, so an image carrying them is an image whose console can never be | |
| 43 | 44 | # fixed. See alloy-layer-components.service and build/layertest. |
| @@ -83,7 +83,15 @@ | |||
| 83 | 83 | # -y because rpm-ostree prompts on a tty it does not have, and a unit blocked | |
| 84 | 84 | # forever on a prompt nobody can see is indistinguishable from a hang. | |
| 85 | 85 | # --idempotent so a re-run after a partial failure is not itself an error. | |
| 86 | - | ExecStart=/usr/bin/rpm-ostree install --idempotent -y alloy shop | |
| 86 | + | # | |
| 87 | + | # The package list is read from the image rather than written here. One unit | |
| 88 | + | # file serves both profiles, and `server` has no compositor, so laying a Wayland | |
| 89 | + | # terminal down there would install something that cannot run. The image knows | |
| 90 | + | # which profile it is and writes the list at build time; this unit installs what | |
| 91 | + | # it is told. A missing or empty list leaves rpm-ostree with no arguments, which | |
| 92 | + | # fails loudly — the right outcome, since the alternative is a machine that | |
| 93 | + | # quietly comes up with no console. | |
| 94 | + | ExecStart=/bin/sh -c 'exec /usr/bin/rpm-ostree install --idempotent -y $(cat /usr/share/alloy/components)' | |
| 87 | 95 | ExecStart=/usr/bin/systemctl reboot | |
| 88 | 96 | ||
| 89 | 97 | # A failure here leaves a machine with no console, which the user cannot fix |
| @@ -1,0 +1,70 @@ | |||
| 1 | + | %global debug_package %{nil} | |
| 2 | + | ||
| 3 | + | Name: shop | |
| 4 | + | Version: %{shop_version} | |
| 5 | + | Release: 1%{?dist} | |
| 6 | + | Summary: Alloy's terminal | |
| 7 | + | License: MIT | |
| 8 | + | URL: https://makenot.work/git/max/shop | |
| 9 | + | ||
| 10 | + | # Handed in through the buildroot rather than compiled here, same as the | |
| 11 | + | # console's spec and for the same reason: the Containerfile's rust-build stage | |
| 12 | + | # is the single definition of how shop is built, holding the revision pin, the | |
| 13 | + | # `--locked` graph and the -devel packages it links against. A %%build here | |
| 14 | + | # would be a second definition, and the two would drift toward whichever one | |
| 15 | + | # ran last. See build/rpm/alloy.spec for the longer version. | |
| 16 | + | Source0: shop | |
| 17 | + | ||
| 18 | + | # Explicit, because the automatic scan is wrong in both directions here. | |
| 19 | + | # | |
| 20 | + | # Too strict on what it sees: left on, rpm requires the exact soname set of | |
| 21 | + | # whatever host built the binary, which is a package that refuses to install | |
| 22 | + | # across a Fedora minor bump. | |
| 23 | + | # | |
| 24 | + | # And blind to most of what shop actually needs. Only fontconfig and | |
| 25 | + | # libxkbcommon are linked; the Wayland and GPU libraries are opened at runtime | |
| 26 | + | # through dlopen, so they appear in no ELF header and a scan cannot see them at | |
| 27 | + | # all. Measured on the built binary rather than read off the manifest: `ldd` | |
| 28 | + | # names libfontconfig.so.1 and libxkbcommon.so.0, and the dlopen strings name | |
| 29 | + | # libwayland-client.so.0, libwayland-egl.so.1, libvulkan.so.1 and libEGL.so.1. | |
| 30 | + | # | |
| 31 | + | # By package name rather than by soname, because a soname dependency is | |
| 32 | + | # arch-qualified — `libwayland-client.so.0()(64bit)` — and this spec is built | |
| 33 | + | # on both x86_64 and aarch64 hosts. The package names are the same on both. | |
| 34 | + | # | |
| 35 | + | # Both GPU backends are required, not just Vulkan. wgpu is configured with | |
| 36 | + | # `vulkan` and `gles` together on purpose (Containerfile, SHOP_REV region): GLES | |
| 37 | + | # through libEGL is what keeps a machine with no Vulkan driver in a working | |
| 38 | + | # terminal, and a terminal is the one component whose absence has no recovery | |
| 39 | + | # path from inside the session. libwayland-egl is that path's other half. | |
| 40 | + | AutoReqProv: no | |
| 41 | + | Requires: glibc | |
| 42 | + | Requires: libgcc | |
| 43 | + | Requires: fontconfig | |
| 44 | + | Requires: libxkbcommon | |
| 45 | + | Requires: libwayland-client | |
| 46 | + | Requires: libwayland-egl | |
| 47 | + | Requires: vulkan-loader | |
| 48 | + | Requires: libglvnd-egl | |
| 49 | + | ||
| 50 | + | %description | |
| 51 | + | shop, Alloy's terminal: Wayland-only, GPU-accelerated, and the binary sway | |
| 52 | + | binds $mod+Return to. | |
| 53 | + | ||
| 54 | + | Packaged so a fix can reach an installed machine without rebuilding an ISO and | |
| 55 | + | writing a drive (GoingsOn task d866e125). | |
| 56 | + | ||
| 57 | + | This package must never be part of a base image. A component the base carries | |
| 58 | + | cannot be replaced client-side, and a file copied into /usr/bin owned by no | |
| 59 | + | package is the worst shape of all: layering over it does not fail to depsolve, | |
| 60 | + | it dies in checkout with "File exists". shop is layered at install time and it | |
| 61 | + | stays layered. Measured in build/layertest. | |
| 62 | + | ||
| 63 | + | %install | |
| 64 | + | mkdir -p %{buildroot}%{_bindir} | |
| 65 | + | install -m 0755 %{SOURCE0} %{buildroot}%{_bindir}/shop | |
| 66 | + | ||
| 67 | + | %files | |
| 68 | + | %{_bindir}/shop | |
| 69 | + | ||
| 70 | + | %changelog |