max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
13 files changed,
+1718 insertions,
-288 deletions
| @@ -27,7 +27,22 @@ | |||
| 27 | 27 | # Cargo.toml pins edition 2024 and rust-version 1.86; Fedora 43 clears | |
| 28 | 28 | # both. `--locked` so the image builds the dependency graph the repo | |
| 29 | 29 | # committed rather than whatever resolves that day. | |
| 30 | - | FROM quay.io/fedora/fedora:43 AS rust-build | |
| 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 quay.io/fedora/fedora:43@sha256:44ce208e9a7ea0da1027caecd4e846664ae5e2a2d73d1e93a969f529b9015c15 AS rust-build | |
| 31 | 46 | ||
| 32 | 47 | # No python3 any more. It was here for tools/vtrgb.py, which rendered the | |
| 33 | 48 | # greeter's console palette; skelgen emits that table now, along with the rest | |
| @@ -273,7 +288,100 @@ | |||
| 273 | 288 | # ===================================================================== | |
| 274 | 289 | # Runtime image — the bootable container itself. | |
| 275 | 290 | # ===================================================================== | |
| 276 | - | FROM quay.io/fedora/fedora-bootc:43 | |
| 291 | + | # Digest-pinned for the same reason as the build stage above, and it matters | |
| 292 | + | # more here: this one is the operating system. See that comment for why the | |
| 293 | + | # tag is kept beside the digest and why it is the multi-arch index. | |
| 294 | + | FROM quay.io/fedora/fedora-bootc:43@sha256:9f0fcfc553103b98ccc01ca02fbb43bbb38649749e613fc007399681c281ae2f | |
| 295 | + | ||
| 296 | + | # ===================================================================== | |
| 297 | + | # The profile: client or server. | |
| 298 | + | # ===================================================================== | |
| 299 | + | # `client` is Alloy as docs/STACK.md describes it — compositor, greeter, | |
| 300 | + | # session, browser, the whole desktop. `server` is the same machine with | |
| 301 | + | # every one of those removed: the console, the shell and CLI stack, the | |
| 302 | + | # hardware-health group, sshd, avahi and the themed bare console, and | |
| 303 | + | # nothing that needs a screen. | |
| 304 | + | # | |
| 305 | + | # Ruled by Max 2026-08-01: one Containerfile with a build ARG, over the | |
| 306 | + | # two-leaf-file shape and over having no server variant at all. Brief and | |
| 307 | + | # the alternatives are in wiki `alloy-server-variant`; GO alloy 1372b159 | |
| 308 | + | # item 2. | |
| 309 | + | # | |
| 310 | + | # WHY THIS DOES NOT COST THE ASSERTIONS, which was the standing objection | |
| 311 | + | # to doing it this way. This file's correctness mechanism is that it | |
| 312 | + | # asserts, at build time, things a config file merely assumes: that the | |
| 313 | + | # session wrapper parses, that the polkit grant names actions that exist, | |
| 314 | + | # that satty and jq are really there for alloy-shot. Each of those checks | |
| 315 | + | # was written because its absence had already caused a silent failure. The | |
| 316 | + | # objection was that a build ARG turns them into `if` blocks, and a | |
| 317 | + | # conditional assertion is one that can be skipped without a word. | |
| 318 | + | # | |
| 319 | + | # So no conditional here skips a check. **Every `$PROFILE` conditional in | |
| 320 | + | # this file asserts on BOTH branches**: where `client` proves a thing is | |
| 321 | + | # present and correct, `server` proves it is absent. There is no path | |
| 322 | + | # through any of them that verifies nothing, which means a branch taken by | |
| 323 | + | # mistake fails the build instead of quietly doing less. Two things hold | |
| 324 | + | # that up: | |
| 325 | + | # | |
| 326 | + | # 1. The validity assertion immediately below. It is unconditional, and | |
| 327 | + | # it runs before anything reads $PROFILE, so a typo can never fall | |
| 328 | + | # through to a branch and build something nobody asked for. | |
| 329 | + | # 2. tests/profile_split.rs, which reads this file and fails if any | |
| 330 | + | # `$PROFILE` conditional lacks an `else`. | |
| 331 | + | # | |
| 332 | + | # That is a stronger guarantee than the file had before the split, because | |
| 333 | + | # the `server` branches assert absence, which nothing used to check at all. | |
| 334 | + | ARG PROFILE=client | |
| 335 | + | ||
| 336 | + | # ===================================================================== | |
| 337 | + | # The other two builder choices: the browser, and language toolchains. | |
| 338 | + | # ===================================================================== | |
| 339 | + | # Both come from `alloy image` (crates/alloy/src/image.rs), which is the | |
| 340 | + | # builder TUI wiki `alloy-distribution` calls Alloy's whole distribution | |
| 341 | + | # mechanism. It exposes only the choices Alloy deliberately declines to | |
| 342 | + | # make, which is why these two are ARGs and the theme, terminal, editor | |
| 343 | + | # and shell are not. | |
| 344 | + | # | |
| 345 | + | # BROWSER is the case docs/STACK.md already names a non-endorsement: | |
| 346 | + | # Alloy argues for helix over vim, and cannot make the equivalent | |
| 347 | + | # argument between Blink and Gecko. `none` is a real answer, not an | |
| 348 | + | # error — someone installing their own from Flathub should not pay for | |
| 349 | + | # one they will remove. | |
| 350 | + | ARG BROWSER=helium | |
| 351 | + | ||
| 352 | + | # LANGS is asked for by name in the rust comment further down: 610 MiB | |
| 353 | + | # across 16 packages, "per-image language selection at mint time is the | |
| 354 | + | # way this stops being one number for everyone". This is that. Comma | |
| 355 | + | # separated, and validated against a curated set below rather than | |
| 356 | + | # treated as a package list — the builder is not a package manager, and | |
| 357 | + | # the gate lives here so it holds even when the TUI is bypassed. | |
| 358 | + | ARG LANGS=rust | |
| 359 | + | ||
| 360 | + | # Unconditional, and first. Everything downstream trusts that these are | |
| 361 | + | # words from known sets, so this is the assertion the other assertions | |
| 362 | + | # stand on. `PROFILE=cleint` has to die here rather than silently build a | |
| 363 | + | # server image because no `then` branch matched, and `LANGS=rust,cobol` | |
| 364 | + | # has to die here rather than at a dnf error 400 lines later. | |
| 365 | + | RUN set -eu; \ | |
| 366 | + | case "$PROFILE" in \ | |
| 367 | + | client|server) ;; \ | |
| 368 | + | *) echo "unknown PROFILE '$PROFILE'; expected 'client' or 'server'" >&2; exit 1 ;; \ | |
| 369 | + | esac; \ | |
| 370 | + | case "$BROWSER" in \ | |
| 371 | + | helium|firefox|none) ;; \ | |
| 372 | + | *) echo "unknown BROWSER '$BROWSER'; expected 'helium', 'firefox' or 'none'" >&2; exit 1 ;; \ | |
| 373 | + | esac; \ | |
| 374 | + | for lang in $(echo "$LANGS" | tr ',' ' '); do \ | |
| 375 | + | case "$lang" in \ | |
| 376 | + | rust|go|python|zig) ;; \ | |
| 377 | + | *) echo "unknown language '$lang'; the builder offers rust, go, python and zig" >&2; exit 1 ;; \ | |
| 378 | + | esac; \ | |
| 379 | + | done; \ | |
| 380 | + | case "$PROFILE:$BROWSER" in \ | |
| 381 | + | client:*|server:none) ;; \ | |
| 382 | + | server:*) echo "PROFILE=server ships no graphical session and cannot carry BROWSER=$BROWSER" >&2; exit 1 ;; \ | |
| 383 | + | esac; \ | |
| 384 | + | echo "building profile=$PROFILE browser=$BROWSER langs=$LANGS" | |
| 277 | 385 | ||
| 278 | 386 | # ===================================================================== | |
| 279 | 387 | # Third-party repos | |
| @@ -379,25 +487,15 @@ | |||
| 379 | 487 | # post-install; no Flatpaks are provisioned at build or first-boot | |
| 380 | 488 | # time. | |
| 381 | 489 | # ===================================================================== | |
| 490 | + | # --------------------------------------------------------------------- | |
| 491 | + | # Base packages — installed on every profile. | |
| 492 | + | # | |
| 493 | + | # What is left when the graphical session goes away. Read the split as a | |
| 494 | + | # question about the machine rather than about taste: a package is here | |
| 495 | + | # if a headless box still has a use for it, and in the client block below | |
| 496 | + | # if it needs a compositor, a screen, or a person sitting at one. | |
| 497 | + | # --------------------------------------------------------------------- | |
| 382 | 498 | RUN dnf install -y \ | |
| 383 | - | # Compositor and Wayland session (Fedora main) | |
| 384 | - | sway \ | |
| 385 | - | # Portals: -wlr is the wlroots backend sway needs for screencast. | |
| 386 | - | # -gnome was the wrong backend here (it drives screencast through | |
| 387 | - | # gnome-shell, which Alloy removes below). -gtk stays for the file | |
| 388 | - | # chooser. | |
| 389 | - | xdg-desktop-portal xdg-desktop-portal-gtk xdg-desktop-portal-wlr \ | |
| 390 | - | # Notifications, screenshot annotate, wallpaper (bar = sway's built-in swaybar) | |
| 391 | - | mako \ | |
| 392 | - | # notify-send, which is the *client* half. mako implements the | |
| 393 | - | # notification server and does not pull this in, so it has been arriving | |
| 394 | - | # as somebody else's transitive dependency. usr/bin/alloy-shot calls it on | |
| 395 | - | # every screenshot, and the failure mode if it goes missing is the exact | |
| 396 | - | # one that script was written to fix: a capture that happens and says | |
| 397 | - | # nothing. Same reasoning as jq below. | |
| 398 | - | libnotify \ | |
| 399 | - | satty \ | |
| 400 | - | swww \ | |
| 401 | 499 | # Editor, shell, prompt. The terminal is not here: shop is built from | |
| 402 | 500 | # source in the rust-build stage and copied in below. | |
| 403 | 501 | helix \ | |
| @@ -407,10 +505,6 @@ | |||
| 407 | 505 | direnv \ | |
| 408 | 506 | # File managers | |
| 409 | 507 | yazi \ | |
| 410 | - | # Content viewers | |
| 411 | - | mpv \ | |
| 412 | - | imv \ | |
| 413 | - | zathura zathura-pdf-mupdf \ | |
| 414 | 508 | # System introspection | |
| 415 | 509 | bottom \ | |
| 416 | 510 | dua-cli \ | |
| @@ -423,7 +517,12 @@ | |||
| 423 | 517 | # provably readable before it is trusted as a reference (GoingsOn | |
| 424 | 518 | # tailoredmachines 18af3fb3), but a laptop with no ECC at all still | |
| 425 | 519 | # wants SMART on the disk it boots from, so these live in the base | |
| 426 | - | # rather than waiting on a server variant. | |
| 520 | + | # rather than in either profile. | |
| 521 | + | # | |
| 522 | + | # This group is the one the old single-block comment already said | |
| 523 | + | # belonged "in the base rather than waiting on a server variant". | |
| 524 | + | # The variant now exists and the group did not move, which is the | |
| 525 | + | # outcome that comment was arguing for. | |
| 427 | 526 | # | |
| 428 | 527 | # rasdaemon carries ras-mc-ctl, which is the ECC readback. Note | |
| 429 | 528 | # edac-utils is NOT here: in Fedora 43 it is an empty stub package | |
| @@ -437,13 +536,12 @@ | |||
| 437 | 536 | smartmontools \ | |
| 438 | 537 | # ipmitool talks to the AST2600 BMC on server boards from the host | |
| 439 | 538 | # side, which is how a headless box reports its own sensors and | |
| 440 | - | # event log without going through the web UI. | |
| 539 | + | # event log without going through the web UI. The one package here | |
| 540 | + | # that is more useful on `server` than on `client`. | |
| 441 | 541 | ipmitool \ | |
| 442 | 542 | # lm_sensors covers everything that is not behind a BMC: laptop | |
| 443 | 543 | # thermals, and on a desktop board the CPU and fan readings. | |
| 444 | 544 | lm_sensors \ | |
| 445 | - | # Wayland session glue | |
| 446 | - | cliphist \ | |
| 447 | 545 | # The fuzzy picker behind both TUI menus in usr/bin: alloy-menu | |
| 448 | 546 | # ($mod+d, the launcher) and alloy-clipmenu ($mod+Shift+v, the read | |
| 449 | 547 | # half of the clipboard history). docs/STACK.md rejects graphical | |
| @@ -451,29 +549,11 @@ | |||
| 451 | 549 | # places; one package is the whole of that, and without it the | |
| 452 | 550 | # cliphist watchers the sway config has always run had nothing that | |
| 453 | 551 | # could read them back. | |
| 454 | - | fzf \ | |
| 455 | - | # Lock + idle. swaylock is the adopted lock surface per | |
| 456 | - | # docs/STACK.md#lock. A session-lock surface is graphical and | |
| 457 | - | # cannot be a TUI, so Alloy adopts rather than authors it. The | |
| 458 | - | # themed config in etc/skel had no package behind it until now. | |
| 459 | - | swaylock \ | |
| 460 | - | swayidle \ | |
| 461 | - | swayosd \ | |
| 462 | - | # Fingerprint unlock, which swaylock reaches without knowing it: its | |
| 463 | - | # /etc/pam.d/swaylock is `auth include login`, login includes | |
| 464 | - | # system-auth, and system-auth is what authselect rewrites when the | |
| 465 | - | # with-fingerprint feature is enabled below. So the lock screen, the | |
| 466 | - | # greeter and run0 all gain the same unlock from one switch, and none | |
| 467 | - | # of them needs an Alloy-authored PAM file. | |
| 468 | 552 | # | |
| 469 | - | # fprintd-pam is the half that matters and is not pulled in by fprintd: | |
| 470 | - | # the daemon can enroll a finger all day, but without the PAM module | |
| 471 | - | # nothing ever asks it. Both named so neither arrives as somebody | |
| 472 | - | # else's transitive dependency. | |
| 473 | - | fprintd \ | |
| 474 | - | fprintd-pam \ | |
| 475 | - | playerctl \ | |
| 476 | - | gammastep \ | |
| 553 | + | # Base rather than client despite that framing: fzf is a shell tool | |
| 554 | + | # first, and a headless box's interactive history search wants it | |
| 555 | + | # for reasons that have nothing to do with sway. | |
| 556 | + | fzf \ | |
| 477 | 557 | # Continuity | |
| 478 | 558 | tailscale \ | |
| 479 | 559 | syncthing \ | |
| @@ -492,6 +572,11 @@ | |||
| 492 | 572 | # a headless box minted with a baked hostname is reached at | |
| 493 | 573 | # `<name>.local`, so publishing is load-bearing. | |
| 494 | 574 | # | |
| 575 | + | # Note this is the group whose weak-dependency carriers are mostly | |
| 576 | + | # client-side (cups-libs, pipewire, geoclue2). On `server` those are | |
| 577 | + | # gone, so avahi arrives only because this line names it — which is | |
| 578 | + | # exactly the profile where the install flow depends on it most. | |
| 579 | + | # | |
| 495 | 580 | # nss-mdns is the half that was missing. It needs no nsswitch edit from | |
| 496 | 581 | # us: /etc/nsswitch.conf is a symlink into authselect, and the package's | |
| 497 | 582 | # own %post inserts `mdns4_minimal [NOTFOUND=return]` ahead of `resolve`, | |
| @@ -504,140 +589,20 @@ | |||
| 504 | 589 | # port. | |
| 505 | 590 | avahi \ | |
| 506 | 591 | nss-mdns \ | |
| 507 | - | # Greeter | |
| 508 | - | greetd \ | |
| 509 | - | tuigreet \ | |
| 510 | - | # No initial-setup. It was here because the bootc Anaconda flow has no | |
| 511 | - | # user-creation spoke, so an install otherwise finished with root | |
| 512 | - | # locked and no way in. `alloy install` creates the account now, and | |
| 513 | - | # keeping it made things worse rather than redundant: it ships enabled | |
| 514 | - | # in both graphical.target.wants and multi-user.target.wants, and on | |
| 515 | - | # first boot it takes the console and blocks on an Anaconda text spoke | |
| 516 | - | # reading "[!] User creation (No user will be created)", in front of a | |
| 517 | - | # perfectly good uid 1000 account. Verified in QEMU 2026-07-20. | |
| 518 | - | # Removing it is what lets first boot reach greetd. | |
| 519 | - | # Cursor, GTK theme | |
| 520 | - | bibata-cursor-theme \ | |
| 521 | - | adw-gtk3-theme \ | |
| 522 | - | # Screenshot capture + region-select (sway has no built-in grab) | |
| 523 | - | grim slurp \ | |
| 524 | - | # Browser (docs/STACK.md#browser). Helium is ungoogled-chromium with | |
| 525 | - | # the defaults Alloy used to hand-build in fifty Firefox prefs and a | |
| 526 | - | # policy file, so it ships here with no configuration at all: no | |
| 527 | - | # policy, no pref seed, no chrome CSS. That is the whole reason it is | |
| 528 | - | # the default. Terra packages it; upstream ships no Flatpak, and the | |
| 529 | - | # unofficial repackagings are worse provenance than this for the most | |
| 530 | - | # attack-exposed program on the box. | |
| 531 | - | helium-browser-bin \ | |
| 532 | - | # Script coverage for the browser. Until now the image carried | |
| 533 | - | # google-noto-sans-vf (Latin, Greek, Cyrillic) and nothing else, so | |
| 534 | - | # the browser rendered every CJK, Arabic, Hebrew, Indic and Thai page | |
| 535 | - | # as rows of missing glyphs. Shipping a browser as the default and | |
| 536 | - | # then not carrying the fonts a large share of the web is written in | |
| 537 | - | # is the same defect class as the emoji alias that named a font the | |
| 538 | - | # image never installed: the config was fine, the coverage was | |
| 539 | - | # absent, and nothing said so. The fonts outlive any one browser | |
| 540 | - | # pick, so this line does not move when the pick does. | |
| 541 | - | # | |
| 542 | - | # These are Fedora's own coverage metapackages rather than a hand- | |
| 543 | - | # picked list, because the failure mode of hand-picking is a script | |
| 544 | - | # nobody on this end reads being the one left out. Cost stated rather | |
| 545 | - | # than absorbed: -cjk-sans is 62 MiB over 4 packages (the CJK faces are | |
| 546 | - | # simply large), -other-sans is 13 MiB over 95. | |
| 547 | - | # | |
| 548 | - | # No fontconfig change goes with this. The aliases in | |
| 549 | - | # etc/skel/.config/fontconfig/fonts.conf use <prefer>, which leaves | |
| 550 | - | # fontconfig free to fall through to a font that has the glyph, so | |
| 551 | - | # Atkinson stays the sans for Latin text and Noto covers what it | |
| 552 | - | # cannot. | |
| 553 | - | # | |
| 554 | - | # Emoji is untouched and still deliberately absent (docs/STACK.md): | |
| 555 | - | # neither metapackage carries an emoji font, which is why declining | |
| 556 | - | # emoji survives this line. | |
| 557 | - | default-fonts-cjk-sans \ | |
| 558 | - | default-fonts-other-sans \ | |
| 559 | - | # Containers: the three backends behind `alloy pkg box`'s isolation | |
| 560 | - | # dial (docs/STACK.md#containers). The user picks a level, not a | |
| 561 | - | # tool. podman is the runtime and is what `workspace` calls | |
| 562 | - | # directly; distrobox wraps it for `host`; flatpak is `sandboxed`, | |
| 563 | - | # and is the install path for everything Alloy does not bake in. | |
| 592 | + | # podman, the runtime behind two of `alloy pkg box`'s three isolation | |
| 593 | + | # levels: `workspace` calls it directly and distrobox wraps it for | |
| 594 | + | # `host`. flatpak is the third and is client-only, since `sandboxed` | |
| 595 | + | # exists to run desktop applications through portals. | |
| 564 | 596 | podman \ | |
| 565 | - | flatpak \ | |
| 566 | - | # Secrets (docs/STACK.md#secrets). Two different jobs, and only the | |
| 567 | - | # first of them is about Max's own logins. | |
| 568 | - | # | |
| 569 | - | # gnome-keyring is the Secret Service provider, and it is here | |
| 570 | - | # because its absence broke Make Creative's own software on Make | |
| 571 | - | # Creative's own distro. Nothing in the image registered | |
| 572 | - | # org.freedesktop.secrets, and the Rust `keyring` crate and Tauri's | |
| 573 | - | # credential plumbing both resolve to it on Linux, so GoingsOn and | |
| 574 | - | # Balanced Breakfast had nowhere to put a credential and | |
| 575 | - | # synckit-client had nowhere to persist its E2EE key. That surfaces | |
| 576 | - | # as a confusing runtime error inside an app, four layers from the | |
| 577 | - | # cause. | |
| 578 | - | # | |
| 579 | - | # gnome-keyring-pam is a SEPARATE package and not a weak dependency | |
| 580 | - | # of the one above, which matters more than it looks. Fedora's | |
| 581 | - | # /etc/pam.d/greetd already carries the two stanzas that unlock the | |
| 582 | - | # keyring with the login password, and both are `-` prefixed: | |
| 583 | - | # | |
| 584 | - | # -auth optional pam_gnome_keyring.so | |
| 585 | - | # -session optional pam_gnome_keyring.so auto_start | |
| 586 | - | # | |
| 587 | - | # A leading `-` tells PAM to skip a module it cannot load, without a | |
| 588 | - | # word in any log. So the provider alone gets you a Secret Service | |
| 589 | - | # that works and a keyring locked behind a second password prompt | |
| 590 | - | # nobody chose, and the config that was supposed to prevent that is | |
| 591 | - | # sitting right there looking correct. Same defect class as the | |
| 592 | - | # emoji alias and the swayosd unit path: configuration that is | |
| 593 | - | # correct about something absent. Both packages or neither. | |
| 594 | - | # | |
| 595 | - | # Nothing here ships a /etc/pam.d file. greetd owns that one and the | |
| 596 | - | # stanzas are already in it, so the fix is a package rather than a | |
| 597 | - | # config edit; the assertion further down is what keeps that true. | |
| 598 | - | # | |
| 599 | - | # KeePassXC also provides the interface and was rejected for | |
| 600 | - | # dragging Qt into a ratatui/egui design language, not on | |
| 601 | - | # capability. systemd-creds is unrelated and stays: service | |
| 602 | - | # credentials for daemons, no interactive component. | |
| 603 | - | gnome-keyring \ | |
| 604 | - | gnome-keyring-pam \ | |
| 605 | 597 | # gopass is the password manager, on its age backend, synced by git | |
| 606 | 598 | # (decided 2026-07-30). Fedora main carries it at 1.16.1; no COPR. | |
| 607 | 599 | # It needs no gpg on the age backend, and the git half is the `git` | |
| 608 | - | # already named below. | |
| 609 | - | gopass \ | |
| 610 | - | # ----------------------------------------------------------------- | |
| 611 | - | # Session prerequisites — not curated picks. See the header above. | |
| 612 | - | # fedora-bootc is a server base; everything in this group is | |
| 613 | - | # something the shipped configuration already assumes exists. | |
| 614 | - | # ----------------------------------------------------------------- | |
| 615 | - | # Audio. The base has no sound stack whatsoever: no pipewire, no | |
| 616 | - | # wireplumber, and so no `pactl`, which crates/alloy/src/audio.rs | |
| 617 | - | # shells out to for every reading and every write. `alloy audio` | |
| 618 | - | # is documented as shipped (docs/CONSOLE.md) but had nothing to | |
| 619 | - | # front. Established by probing quay.io/fedora/fedora-bootc:43 | |
| 620 | - | # directly (rpm -q, and `pactl` absent from PATH), not observed on | |
| 621 | - | # hardware — the QEMU punch list is where that gets confirmed. The | |
| 622 | - | # Fn-key volume binds, mpv, and anything playerctl controls have | |
| 623 | - | # the same hole under them. | |
| 600 | + | # already named below. A CLI with no graphical half, so it is base. | |
| 624 | 601 | # | |
| 625 | - | # pulseaudio-utils is what actually carries /usr/bin/pactl (the | |
| 626 | - | # pipewire-pulseaudio package is the daemon-side compat shim, not | |
| 627 | - | # the CLI). Both are needed. No preset lines accompany these: | |
| 628 | - | # Fedora's own 90-default-user.preset already socket-activates | |
| 629 | - | # pipewire and pipewire-pulse, and `systemctl preset-all` below | |
| 630 | - | # picks that up. | |
| 631 | - | pipewire \ | |
| 632 | - | wireplumber \ | |
| 633 | - | pipewire-pulseaudio \ | |
| 634 | - | pulseaudio-utils \ | |
| 635 | - | # wl-clipboard. The sway config exec's `wl-paste --watch cliphist | |
| 636 | - | # store` twice at session start (docs/STACK.md#clipboard-history), | |
| 637 | - | # and wl-copy is how anything gets back out of the history. cliphist | |
| 638 | - | # was installed without it, so both watchers failed at every login | |
| 639 | - | # and the clipboard history could never be populated or pasted from. | |
| 640 | - | wl-clipboard \ | |
| 602 | + | # gnome-keyring, its Secret Service counterpart, is client-only: it | |
| 603 | + | # is the D-Bus provider desktop applications resolve to, and there is | |
| 604 | + | # no session bus to hold it on a headless box. | |
| 605 | + | gopass \ | |
| 641 | 606 | # git. Not in the base. aliases.nu ships six git aliases, helix's | |
| 642 | 607 | # diff gutters need it, docs/STACK.md sells EDITOR=hx on git commit | |
| 643 | 608 | # messages, and the stated audience is developers. The full package | |
| @@ -645,38 +610,16 @@ | |||
| 645 | 610 | # perl helpers, which is the difference between a git that works | |
| 646 | 611 | # and a git that works when you ask it a question. | |
| 647 | 612 | git \ | |
| 648 | - | # Rust toolchain. The console is built in the rust-build stage above | |
| 649 | - | # and only its binary is copied into this image, so until now an | |
| 650 | - | # Alloy machine could run Rust programs and not build one. That is | |
| 651 | - | # the same gap git had and the same audience argument closes it. | |
| 652 | - | # | |
| 653 | - | # It is also a hard requirement for the build-host role. sandod | |
| 654 | - | # shells out to `cargo build --release` in its own workdir and | |
| 655 | - | # refuses to compile anywhere but its configured build host, so a | |
| 656 | - | # Sando host with no cargo is not a Sando host. Naming that here | |
| 657 | - | # because it is the reason the line landed now rather than later. | |
| 658 | - | # | |
| 659 | - | # No separate linker line: `rust` pulls gcc, binutils and | |
| 660 | - | # glibc-devel, so cc arrives with it. Fedora 43 carries 1.96.1, | |
| 661 | - | # ahead of the workspace's 1.86 floor and current enough for | |
| 662 | - | # crates tracking recent stable. | |
| 663 | - | # | |
| 664 | - | # The cost is 610 MiB installed across 16 packages, most of it | |
| 665 | - | # rust-std-static (164 MiB) and llvm-libs (139 MiB). That is an | |
| 666 | - | # order of magnitude above the hardware-health group above and by | |
| 667 | - | # a distance the largest thing in this block, so it is stated | |
| 668 | - | # rather than absorbed. Per-image language selection at mint time | |
| 669 | - | # is the way this stops being one number for everyone; see | |
| 670 | - | # docs/IMAGE.md. | |
| 671 | - | # | |
| 672 | - | # rustup is deliberately not here. It installs into $HOME, needs | |
| 673 | - | # nothing from the image, and stays the answer for a pinned or | |
| 674 | - | # nightly toolchain. Nor are project-specific -devel packages: | |
| 675 | - | # those belong to whatever is being built, not to the OS. | |
| 676 | - | rust cargo \ | |
| 613 | + | # The Rust toolchain used to be an unconditional line here, and its | |
| 614 | + | # comment argued at length about the 610 MiB it costs before ending | |
| 615 | + | # with "per-image language selection at mint time is the way this | |
| 616 | + | # stops being one number for everyone". That is now the `LANGS` ARG | |
| 617 | + | # and the toolchain block further down. The reasoning moved with it; | |
| 618 | + | # nothing about the cost or the build-host requirement changed. | |
| 677 | 619 | # xdg-user-dirs. Without it a new account gets a bare home and no | |
| 678 | - | # ~/Documents, ~/Downloads or ~/Pictures, which is what the GTK | |
| 679 | - | # file-chooser portal and yazi both open into. | |
| 620 | + | # ~/Documents, ~/Downloads or ~/Pictures, which is what yazi opens | |
| 621 | + | # into. Base because yazi is base; the GTK file-chooser portal that | |
| 622 | + | # was the other consumer is client-only. | |
| 680 | 623 | xdg-user-dirs \ | |
| 681 | 624 | # jq. Already present, and that is exactly the problem: it comes from | |
| 682 | 625 | # fedora-bootc rather than from any line here, and the sway config's | |
| @@ -686,9 +629,311 @@ | |||
| 686 | 629 | # observation of someone else's package set, which can change without | |
| 687 | 630 | # warning and would take the binding with it silently. Declaring it costs | |
| 688 | 631 | # nothing and turns an inherited assumption into a stated dependency. | |
| 632 | + | # | |
| 633 | + | # Base rather than client even though that binding is client-side: | |
| 634 | + | # usr/bin/alloy-shot is not the only consumer, and a JSON tool on a | |
| 635 | + | # build host needs no further argument. | |
| 689 | 636 | jq \ | |
| 690 | 637 | && dnf clean all | |
| 691 | 638 | ||
| 639 | + | # --------------------------------------------------------------------- | |
| 640 | + | # Client packages — the graphical session and everything that assumes one. | |
| 641 | + | # | |
| 642 | + | # Conditional, and the `else` branch is not decoration. Every `$PROFILE` | |
| 643 | + | # conditional in this file asserts something on both sides, so that a | |
| 644 | + | # branch taken by mistake fails the build instead of quietly skipping | |
| 645 | + | # work. Here that means `server` proves the compositor really is absent | |
| 646 | + | # rather than trusting that the `then` branch did not run. tests/ | |
| 647 | + | # profile_split.rs enforces the rule across the whole file; the | |
| 648 | + | # PROFILE-validity assertion above is what makes the condition itself | |
| 649 | + | # trustworthy. | |
| 650 | + | # --------------------------------------------------------------------- | |
| 651 | + | RUN if [ "$PROFILE" = client ]; then \ | |
| 652 | + | dnf install -y \ | |
| 653 | + | # Compositor and Wayland session (Fedora main) | |
| 654 | + | sway \ | |
| 655 | + | # Portals: -wlr is the wlroots backend sway needs for screencast. | |
| 656 | + | # -gnome was the wrong backend here (it drives screencast through | |
| 657 | + | # gnome-shell, which Alloy removes below). -gtk stays for the file | |
| 658 | + | # chooser. | |
| 659 | + | xdg-desktop-portal xdg-desktop-portal-gtk xdg-desktop-portal-wlr \ | |
| 660 | + | # Notifications, screenshot annotate, wallpaper (bar = sway's built-in swaybar) | |
| 661 | + | mako \ | |
| 662 | + | # notify-send, which is the *client* half. mako implements the | |
| 663 | + | # notification server and does not pull this in, so it has been arriving | |
| 664 | + | # as somebody else's transitive dependency. usr/bin/alloy-shot calls it on | |
| 665 | + | # every screenshot, and the failure mode if it goes missing is the exact | |
| 666 | + | # one that script was written to fix: a capture that happens and says | |
| 667 | + | # nothing. Same reasoning as jq above. | |
| 668 | + | libnotify \ | |
| 669 | + | satty \ | |
| 670 | + | swww \ | |
| 671 | + | # Content viewers. These are what yazi opens files with, which is | |
| 672 | + | # test 2 of the packaging policy's in-image tests, so they follow | |
| 673 | + | # yazi's consumers rather than yazi itself: all three want a screen. | |
| 674 | + | mpv \ | |
| 675 | + | imv \ | |
| 676 | + | zathura zathura-pdf-mupdf \ | |
| 677 | + | # Wayland session glue | |
| 678 | + | cliphist \ | |
| 679 | + | # Lock + idle. swaylock is the adopted lock surface per | |
| 680 | + | # docs/STACK.md#lock. A session-lock surface is graphical and | |
| 681 | + | # cannot be a TUI, so Alloy adopts rather than authors it. The | |
| 682 | + | # themed config in etc/skel had no package behind it until now. | |
| 683 | + | swaylock \ | |
| 684 | + | swayidle \ | |
| 685 | + | swayosd \ | |
| 686 | + | # Fingerprint unlock, which swaylock reaches without knowing it: its | |
| 687 | + | # /etc/pam.d/swaylock is `auth include login`, login includes | |
| 688 | + | # system-auth, and system-auth is what authselect rewrites when the | |
| 689 | + | # with-fingerprint feature is enabled below. So the lock screen, the | |
| 690 | + | # greeter and run0 all gain the same unlock from one switch, and none | |
| 691 | + | # of them needs an Alloy-authored PAM file. | |
| 692 | + | # | |
| 693 | + | # fprintd-pam is the half that matters and is not pulled in by fprintd: |
Lines truncated
| @@ -23,12 +23,13 @@ | |||
| 23 | 23 | # build/build-image.sh # build image + raw disk image | |
| 24 | 24 | # build/build-image.sh --type qcow2 # build image + qcow2 disk image | |
| 25 | 25 | # build/build-image.sh --skip-build # reuse the current image, just run bib | |
| 26 | - | # build/build-image.sh --skip-bib --write /dev/sdX # write what is already built | |
| 26 | + | # build/build-image.sh --write-only --write /dev/sdX # write what is already built | |
| 27 | 27 | # build/build-image.sh --write /dev/sdX # also dd the artifact to a device | |
| 28 | 28 | # | |
| 29 | - | # Writing requires an explicit device path and an interactive confirmation, | |
| 30 | - | # refuses partitions and anything with a mounted filesystem, and verifies the | |
| 31 | - | # result with cmp before claiming success. | |
| 29 | + | # Writing is build/write-device.sh's job, shared with build/build-iso.sh: it | |
| 30 | + | # requires an explicit device path and an interactive confirmation, refuses | |
| 31 | + | # partitions and anything with a mounted filesystem, and verifies the result | |
| 32 | + | # with cmp before claiming success. | |
| 32 | 33 | ||
| 33 | 34 | set -euo pipefail | |
| 34 | 35 | ||
| @@ -43,6 +44,8 @@ | |||
| 43 | 44 | SKIP_BUILD=0 | |
| 44 | 45 | SKIP_BIB=0 | |
| 45 | 46 | ||
| 47 | + | BUILD_ARGS=() | |
| 48 | + | ||
| 46 | 49 | die() { printf 'error: %s\n' "$*" >&2; exit 1; } | |
| 47 | 50 | ||
| 48 | 51 | usage() { | |
| @@ -55,7 +58,15 @@ | |||
| 55 | 58 | --type) TYPE="${2:?--type needs a value}"; shift 2 ;; | |
| 56 | 59 | --write) WRITE_DEV="${2:?--write needs a device path}"; shift 2 ;; | |
| 57 | 60 | --skip-build) SKIP_BUILD=1; shift ;; | |
| 58 | - | --skip-bib) SKIP_BIB=1; SKIP_BUILD=1; shift ;; | |
| 61 | + | # --skip-bib is the older spelling, kept working. --write-only is the | |
| 62 | + | # name both scripts answer to, because "bib" means nothing in the ISO | |
| 63 | + | # path and the console emits one flag for both artifacts. | |
| 64 | + | --write-only|--skip-bib) SKIP_BIB=1; SKIP_BUILD=1; shift ;; | |
| 65 | + | # Passed straight to `podman build`. The Containerfile validates every | |
| 66 | + | # one of them against its own curated sets (PROFILE, BROWSER, LANGS), so | |
| 67 | + | # the gate lives there rather than here: a bad value has to fail the | |
| 68 | + | # build whether it came from `alloy image` or from a hand-typed flag. | |
| 69 | + | --build-arg) BUILD_ARGS+=(--build-arg "${2:?--build-arg needs KEY=VALUE}"); shift 2 ;; | |
| 59 | 70 | -h|--help) usage 0 ;; | |
| 60 | 71 | *) die "unknown argument: $1 (see --help)" ;; | |
| 61 | 72 | esac | |
| @@ -73,13 +84,13 @@ | |||
| 73 | 84 | [ -f "$DEF" ] || die "missing distro def: $DEF" | |
| 74 | 85 | [ -f "$REPO_ROOT/Containerfile" ] || die "no Containerfile at $REPO_ROOT" | |
| 75 | 86 | ||
| 76 | - | # --skip-bib exists to make `--write` usable on its own. Without it the only | |
| 87 | + | # --write-only exists to make `--write` usable on its own. Without it the only | |
| 77 | 88 | # way to write an artifact that already exists was to rebuild it first, so | |
| 78 | 89 | # the documented workaround was to bypass this script and run dd by hand, | |
| 79 | 90 | # which is exactly where the guards and the verify live. | |
| 80 | 91 | if [ "$SKIP_BIB" -eq 1 ]; then | |
| 81 | 92 | echo "==> Skipping image build and bib; using the artifact already in $OUTPUT" | |
| 82 | - | [ -n "$WRITE_DEV" ] || die "--skip-bib only makes sense with --write" | |
| 93 | + | [ -n "$WRITE_DEV" ] || die "--write-only only makes sense with --write" | |
| 83 | 94 | fi | |
| 84 | 95 | ||
| 85 | 96 | # 1. Build the bootc image (rootful, so bib sees it in the same store). | |
| @@ -87,7 +98,7 @@ | |||
| 87 | 98 | : | |
| 88 | 99 | elif [ "$SKIP_BUILD" -eq 0 ]; then | |
| 89 | 100 | echo "==> Building $IMAGE (rootful)" | |
| 90 | - | sudo podman build -t "$IMAGE" "$REPO_ROOT" | |
| 101 | + | sudo podman build "${BUILD_ARGS[@]}" -t "$IMAGE" "$REPO_ROOT" | |
| 91 | 102 | else | |
| 92 | 103 | echo "==> Skipping image build; reusing $IMAGE" | |
| 93 | 104 | sudo podman image exists "$IMAGE" || die "$IMAGE not in the root store; drop --skip-build" | |
| @@ -136,62 +147,11 @@ | |||
| 136 | 147 | [ -n "$ARTIFACT" ] && sudo test -f "$ARTIFACT" || die "expected artifact not found for type $TYPE" | |
| 137 | 148 | echo "==> Built: $ARTIFACT ($(sudo du -h "$ARTIFACT" | cut -f1))" | |
| 138 | 149 | ||
| 139 | - | # 5. Optionally write to a device. | |
| 150 | + | # 5. Optionally write to a device. The guards, the confirmation and the | |
| 151 | + | # verify live in build/write-device.sh, which build/build-iso.sh calls | |
| 152 | + | # too — one implementation of the dd path, per wiki alloy-distribution. | |
| 140 | 153 | if [ -n "$WRITE_DEV" ]; then | |
| 141 | - | [ -b "$WRITE_DEV" ] || die "$WRITE_DEV is not a block device" | |
| 142 | - | ||
| 143 | - | # A whole disk, not a partition. An ISO written to /dev/sda1 produces | |
| 144 | - | # nothing bootable and quietly eats a filesystem on the way. | |
| 145 | - | [ "$(lsblk -dnro TYPE "$WRITE_DEV")" = "disk" ] \ | |
| 146 | - | || die "$WRITE_DEV is not a whole disk; pass the disk, not a partition" | |
| 147 | - | ||
| 148 | - | # Any mountpoint at or below the device, not just / and /boot. The old | |
| 149 | - | # guard matched those two patterns only, so a disk holding /home, /var or | |
| 150 | - | # an active swap passed it and got written. This is the rule the installer | |
| 151 | - | # itself applies (install.rs, Disk::blocker): anything mounted blocks. | |
| 152 | - | # `lsblk -r` escapes a newline as \x0a, so a device with two mountpoints | |
| 153 | - | # arrives as one run-together line. Unescape before printing: this list is | |
| 154 | - | # read by someone deciding whether to erase a disk. | |
| 155 | - | mounts="$(lsblk -nro MOUNTPOINTS "$WRITE_DEV" 2>/dev/null \ | |
| 156 | - | | sed 's/\\x0a/\n/g' | grep -v '^$' || true)" | |
| 157 | - | if [ -n "$mounts" ]; then | |
| 158 | - | printf 'error: %s has mounted filesystems; refusing to write:\n' "$WRITE_DEV" >&2 | |
| 159 | - | printf '%s\n' "$mounts" | sed 's/^/ /' >&2 | |
| 160 | - | exit 1 | |
| 161 | - | fi | |
| 162 | - | ||
| 163 | - | echo | |
| 164 | - | lsblk -o NAME,SIZE,TYPE,MOUNTPOINTS,MODEL,SERIAL,TRAN,RM,RO "$WRITE_DEV" | |
| 165 | - | echo | |
| 166 | - | # Removable is worth saying out loud: on this box the system disk is nvme | |
| 167 | - | # and a USB stick reports usb/RM=1, so a non-removable target is the shape | |
| 168 | - | # of a mistake even when nothing is mounted on it. | |
| 169 | - | if [ "$(lsblk -dnro RM "$WRITE_DEV")" != "1" ]; then | |
| 170 | - | echo "WARNING: $WRITE_DEV is not removable. This is the shape of an internal disk." | |
| 171 | - | echo | |
| 172 | - | fi | |
| 173 | - | printf 'This ERASES all data on %s. Type the device path to confirm: ' "$WRITE_DEV" | |
| 174 | - | read -r reply | |
| 175 | - | [ "$reply" = "$WRITE_DEV" ] || die "confirmation did not match; not writing" | |
| 176 | - | echo "==> Writing $ARTIFACT to $WRITE_DEV" | |
| 177 | - | sudo dd if="$ARTIFACT" of="$WRITE_DEV" bs=4M oflag=direct conv=fsync status=progress | |
| 178 | - | sync | |
| 179 | - | ||
| 180 | - | # Verify, because dd reporting success is not evidence the bytes landed. | |
| 181 | - | # cmp over exactly the artifact's length is the authoritative check; a | |
| 182 | - | # `dd | head -c N | sha256sum` pipeline reported phantom corruption on a | |
| 183 | - | # write that cmp proved perfect (wiki alloy-build-notes). | |
| 184 | - | echo "==> Verifying the write" | |
| 185 | - | size="$(sudo stat -c %s "$ARTIFACT")" | |
| 186 | - | # A check that cannot fail is not a check: prove cmp can still disagree | |
| 187 | - | # before trusting it to agree. | |
| 188 | - | if sudo cmp -s -n 4096 "$WRITE_DEV" /dev/zero; then | |
| 189 | - | die "negative control passed, which means cmp is not comparing anything" | |
| 190 | - | fi | |
| 191 | - | sudo cmp -n "$size" "$WRITE_DEV" "$ARTIFACT" \ | |
| 192 | - | || die "$WRITE_DEV does not match $ARTIFACT; the write is bad" | |
| 193 | - | echo "==> Verified $size bytes." | |
| 194 | - | echo "==> Done. $WRITE_DEV is now a bootable Alloy $TYPE." | |
| 154 | + | "$REPO_ROOT/build/write-device.sh" "$ARTIFACT" "$WRITE_DEV" "$TYPE" | |
| 195 | 155 | else | |
| 196 | 156 | echo "==> To write it to a USB stick:" | |
| 197 | 157 | echo " sudo dd if=$ARTIFACT of=/dev/sdX bs=4M oflag=direct conv=fsync status=progress" |
| @@ -26,6 +26,11 @@ | |||
| 26 | 26 | # build/build-iso.sh --fast # iteration: cheap compression, keeps source | |
| 27 | 27 | # build/build-iso.sh --fast --skip-source # boot chain only, cannot install | |
| 28 | 28 | # build/build-iso.sh --update-target host:5000/alloy:43 # updates come from there | |
| 29 | + | # build/build-iso.sh --write /dev/sdX # build, then write to a device | |
| 30 | + | # build/build-iso.sh --write-only --write /dev/sdX # write the ISO already built | |
| 31 | + | # | |
| 32 | + | # Writing is build/write-device.sh's job, shared with build/build-image.sh, | |
| 33 | + | # so there is one implementation of the dd path and one set of guards. | |
| 29 | 34 | ||
| 30 | 35 | set -euo pipefail | |
| 31 | 36 | ||
| @@ -38,16 +43,25 @@ | |||
| 38 | 43 | SKIP_BUILD=0 | |
| 39 | 44 | SKIP_SOURCE=0 | |
| 40 | 45 | FAST=0 | |
| 46 | + | WRITE_ONLY=0 | |
| 47 | + | WRITE_DEV="" | |
| 41 | 48 | # Empty means the installer keeps its compiled-in default, the public registry. | |
| 42 | 49 | # See --update-target below. | |
| 43 | 50 | UPDATE_TARGET="" | |
| 44 | 51 | ||
| 52 | + | BUILD_ARGS=() | |
| 53 | + | ||
| 45 | 54 | die() { printf 'error: %s\n' "$*" >&2; exit 1; } | |
| 46 | 55 | say() { printf '==> %s\n' "$*"; } | |
| 47 | 56 | ||
| 48 | 57 | while [ $# -gt 0 ]; do | |
| 49 | 58 | case "$1" in | |
| 50 | 59 | --skip-build) SKIP_BUILD=1; shift ;; | |
| 60 | + | # Passed straight to `podman build`. The Containerfile validates every | |
| 61 | + | # one of them against its own curated sets (PROFILE, BROWSER, LANGS), so | |
| 62 | + | # the gate lives there rather than here: a bad value has to fail the | |
| 63 | + | # build whether it came from `alloy image` or from a hand-typed flag. | |
| 64 | + | --build-arg) BUILD_ARGS+=(--build-arg "${2:?--build-arg needs KEY=VALUE}"); shift 2 ;; | |
| 51 | 65 | --skip-source) SKIP_SOURCE=1; shift ;; | |
| 52 | 66 | # Iteration mode: reuse the image and compress cheaply, but still carry | |
| 53 | 67 | # the install source. Level 19 costs about ten minutes of saturated CPU | |
| @@ -64,17 +78,33 @@ | |||
| 64 | 78 | --update-target) | |
| 65 | 79 | [ $# -ge 2 ] || die "--update-target needs a registry reference" | |
| 66 | 80 | UPDATE_TARGET="$2"; shift 2 ;; | |
| 67 | - | -h|--help) sed -n '2,30p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;; | |
| 81 | + | # The device write. Same two flags build-image.sh takes, because the | |
| 82 | + | # console emits one spelling for both artifacts. | |
| 83 | + | --write) WRITE_DEV="${2:?--write needs a device path}"; shift 2 ;; | |
| 84 | + | --write-only) WRITE_ONLY=1; shift ;; | |
| 85 | + | -h|--help) sed -n '2,33p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;; | |
| 68 | 86 | *) die "unknown argument: $1 (see --help)" ;; | |
| 69 | 87 | esac | |
| 70 | 88 | done | |
| 71 | 89 | ||
| 90 | + | ARTIFACT="$OUTPUT/install.iso" | |
| 91 | + | ||
| 92 | + | # --write-only skips every build step, including the output rotation, and | |
| 93 | + | # writes the ISO already sitting in $OUTPUT. It needs no podman, so it comes | |
| 94 | + | # before that check: writing a stick should not depend on being able to build. | |
| 95 | + | if [ "$WRITE_ONLY" -eq 1 ]; then | |
| 96 | + | [ -n "$WRITE_DEV" ] || die "--write-only only makes sense with --write" | |
| 97 | + | sudo test -f "$ARTIFACT" || die "no ISO at $ARTIFACT; build one first" | |
| 98 | + | say "writing the ISO already in $OUTPUT" | |
| 99 | + | exec "$REPO_ROOT/build/write-device.sh" "$ARTIFACT" "$WRITE_DEV" "installer ISO" | |
| 100 | + | fi | |
| 101 | + | ||
| 72 | 102 | command -v podman >/dev/null || die "podman not found" | |
| 73 | 103 | ||
| 74 | 104 | # 1. The Alloy image. | |
| 75 | 105 | if [ "$SKIP_BUILD" -eq 0 ]; then | |
| 76 | 106 | say "building $IMAGE" | |
| 77 | - | sudo podman build -t "$IMAGE" "$REPO_ROOT" | |
| 107 | + | sudo podman build "${BUILD_ARGS[@]}" -t "$IMAGE" "$REPO_ROOT" | |
| 78 | 108 | else | |
| 79 | 109 | sudo podman image exists "$IMAGE" || die "$IMAGE not in the root store; drop --skip-build" | |
| 80 | 110 | say "reusing $IMAGE" | |
| @@ -147,14 +177,16 @@ | |||
| 147 | 177 | -v "$WORKDIR/source":/source:ro \ | |
| 148 | 178 | "$BUILDER" | |
| 149 | 179 | ||
| 150 | - | ARTIFACT="$OUTPUT/install.iso" | |
| 151 | 180 | sudo test -f "$ARTIFACT" || die "no ISO produced" | |
| 152 | 181 | say "built: $ARTIFACT ($(sudo du -h "$ARTIFACT" | cut -f1))" | |
| 153 | 182 | echo | |
| 154 | - | # Not build-image.sh: that script writes bib's artifact, $OUTPUT/bootiso, | |
| 155 | - | # which this ISO is the replacement for and does not produce. Pointing at it | |
| 156 | - | # sent a write at a path that is not there. | |
| 157 | - | echo " Boot it, or write it with:" | |
| 158 | - | echo " sudo dd if=$ARTIFACT of=/dev/sdX bs=4M oflag=direct conv=fsync status=progress" | |
| 159 | - | echo " then check it took:" | |
| 160 | - | echo " sudo cmp -n \$(sudo stat -c %s $ARTIFACT) /dev/sdX $ARTIFACT" | |
| 183 | + | ||
| 184 | + | if [ -n "$WRITE_DEV" ]; then | |
| 185 | + | "$REPO_ROOT/build/write-device.sh" "$ARTIFACT" "$WRITE_DEV" "installer ISO" | |
| 186 | + | else | |
| 187 | + | # Not build-image.sh: that script writes bib's artifact, $OUTPUT/bootiso, | |
| 188 | + | # which this ISO is the replacement for and does not produce. Pointing at it | |
| 189 | + | # sent a write at a path that is not there. | |
| 190 | + | echo " Boot it, or write it with:" | |
| 191 | + | echo " build/build-iso.sh --write-only --write /dev/sdX" | |
| 192 | + | fi |
| @@ -80,6 +80,33 @@ | |||
| 80 | 80 | backend builds argv and runs nothing; the view executes through the command log, | |
| 81 | 81 | which makes "every action shows its invocation" structural rather than remembered. | |
| 82 | 82 | ||
| 83 | + | `alloy image` is the builder, and it is Alloy's whole distribution mechanism: | |
| 84 | + | nobody downloads an Alloy image, they build one (wiki `alloy-distribution`). It | |
| 85 | + | is a form rather than a wizard — six choices that are read together before | |
| 86 | + | either action is taken, where `alloy install`'s wizard shape is for a fixed | |
| 87 | + | sequence ending in a destructive act. It exposes only the choices Alloy | |
| 88 | + | deliberately declines to make: the profile (client or server), the browser, | |
| 89 | + | which language toolchains to carry, the artifact, and machine identity. Theme, | |
| 90 | + | terminal, editor and shell stay fixed and are not on it. | |
| 91 | + | ||
| 92 | + | Two properties of that screen are constraints rather than decisions. **It does | |
| 93 | + | not write the disk.** `build/build-image.sh --write` refuses partitions, refuses | |
| 94 | + | anything mounted, and verifies with `cmp` against a negative control; re-deriving | |
| 95 | + | that behind a progress bar is how a disk-eating bug gets written. So the console | |
| 96 | + | owns the choice of device and then hands the terminal to the script through the | |
| 97 | + | same suspend `distrobox enter` uses, where it asks its own confirmation and | |
| 98 | + | applies its own guards. **And the artifact holds no secrets:** the hostname and | |
| 99 | + | the ssh public key it bakes in are both public, which is what lets the medium be | |
| 100 | + | kept, copied or rebuilt without care. | |
| 101 | + | ||
| 102 | + | The profile also decides which verbs exist. A `server` image has no compositor | |
| 103 | + | and no bar, so `alloy display` and `alloy status --bar` are dropped from | |
| 104 | + | `--help` there and refuse to run, both off the same table so the two cannot | |
| 105 | + | drift. That reads a marker the build wrote rather than probing for a compositor: | |
| 106 | + | a probe answers "no" on a client machine sitting at the greeter or reached over | |
| 107 | + | SSH, which would make a verb appear and disappear on one machine depending on | |
| 108 | + | where it was typed. | |
| 109 | + | ||
| 83 | 110 | `alloy net` writes as well as reads: connect, disconnect, and the wifi radio. Those three | |
| 84 | 111 | need no privilege at all, which is a fact rather than a design: NetworkManager's shipped | |
| 85 | 112 | policy grants `network-control` and `enable-disable-wifi` to an active session outright. |
| @@ -47,9 +47,9 @@ | |||
| 47 | 47 | ## Write it to a USB stick | |
| 48 | 48 | ||
| 49 | 49 | Any of the usual tools work on the ISO in `output/`. If you would rather have | |
| 50 | - | the write checked for you, the disk-image path has guards: | |
| 50 | + | the write checked for you, both build scripts take the same two flags: | |
| 51 | 51 | ||
| 52 | - | build/build-image.sh --skip-bib --write /dev/sdX | |
| 52 | + | build/build-iso.sh --write-only --write /dev/sdX | |
| 53 | 53 | ||
| 54 | 54 | It refuses partitions, refuses anything with a mounted filesystem, demands an | |
| 55 | 55 | explicit device path plus a typed confirmation, and verifies the result with |
| @@ -24,7 +24,7 @@ | |||
| 24 | 24 | ||
| 25 | 25 | cd alloy | |
| 26 | 26 | git pull | |
| 27 | - | build/build-iso.sh --skip-source # or build/build-image.sh --skip-bib | |
| 27 | + | build/build-iso.sh --skip-source # or build/build-image.sh | |
| 28 | 28 | ||
| 29 | 29 | Either one rebuilds `localhost/alloy:local`. Then, on the machine to update: | |
| 30 | 30 |
| @@ -11,10 +11,12 @@ | |||
| 11 | 11 | mod cli; | |
| 12 | 12 | mod credits; | |
| 13 | 13 | mod display; | |
| 14 | + | mod image; | |
| 14 | 15 | mod install; | |
| 15 | 16 | mod mesh; | |
| 16 | 17 | mod net; | |
| 17 | 18 | mod pkg; | |
| 19 | + | mod profile; | |
| 18 | 20 | mod recovery; | |
| 19 | 21 | mod run; | |
| 20 | 22 | mod schema; | |
| @@ -30,7 +32,7 @@ | |||
| 30 | 32 | mod wizard; | |
| 31 | 33 | ||
| 32 | 34 | use anyhow::Result; | |
| 33 | - | use clap::{Parser, Subcommand}; | |
| 35 | + | use clap::{FromArgMatches, Parser, Subcommand}; | |
| 34 | 36 | ||
| 35 | 37 | use crate::cli::CommandLog; | |
| 36 | 38 | ||
| @@ -102,6 +104,13 @@ | |||
| 102 | 104 | Update, | |
| 103 | 105 | /// Install Alloy to a disk | |
| 104 | 106 | Install, | |
| 107 | + | // The builder, and Alloy's whole distribution mechanism: nobody downloads | |
| 108 | + | // an Alloy image, they build one (wiki `alloy-distribution`). `image` | |
| 109 | + | // rather than `mint` — the note offers both, and the boring noun is the | |
| 110 | + | // one someone guesses. It subsumes the minting tool rather than shipping | |
| 111 | + | // beside it, which is why hostname and pubkey are rows on its form. | |
| 112 | + | /// Build an Alloy image or installer medium | |
| 113 | + | Image, | |
| 105 | 114 | /// System settings and application configs | |
| 106 | 115 | Settings, | |
| 107 | 116 | /// Synchronized folders and the devices they reach | |
| @@ -187,7 +196,30 @@ | |||
| 187 | 196 | } | |
| 188 | 197 | ||
| 189 | 198 | fn main() -> Result<()> { | |
| 190 | - | let cli = Cli::parse(); | |
| 199 | + | // The profile decides which verbs exist, so it is read before the | |
| 200 | + | // arguments are. `alloy display` drives swaymsg and `alloy status --bar` | |
| 201 | + | // writes swaybar's protocol; a server image has neither, and Max ruled | |
| 202 | + | // 2026-08-01 that those verbs are hidden there rather than reporting the | |
| 203 | + | // absence. See [`profile`] for why this reads a build-time marker rather | |
| 204 | + | // than probing for a compositor. | |
| 205 | + | let profile = profile::Profile::current(); | |
| 206 | + | ||
| 207 | + | // Hidden from `--help`, still resolved. clap would accept the verb either | |
| 208 | + | // way, and a command that silently works while being undocumented is how | |
| 209 | + | // documentation becomes wrong; the match arms below say why instead. | |
| 210 | + | let command = profile::hide_unavailable(<Cli as clap::CommandFactory>::command(), profile); | |
| 211 | + | let cli = Cli::from_arg_matches(&command.get_matches())?; | |
| 212 | + | ||
| 213 | + | // Refused as well as hidden, and through the same table, so the two | |
| 214 | + | // cannot drift. See `profile::is_unavailable`. | |
| 215 | + | let verb = match &cli.command { | |
| 216 | + | Command::Display => "display", | |
| 217 | + | Command::Status { .. } => "status", | |
| 218 | + | _ => "", | |
| 219 | + | }; | |
| 220 | + | if profile::is_unavailable(verb, profile) { | |
| 221 | + | anyhow::bail!("{}", profile::unavailable(verb)); | |
| 222 | + | } | |
| 191 | 223 | ||
| 192 | 224 | // Before the theme load, and that ordering is load-bearing rather than | |
| 193 | 225 | // tidy. `theme::load` is a hard error when nothing is on the search path, | |
| @@ -296,6 +328,10 @@ | |||
| 296 | 328 | let mut view = install::InstallView::new(&mut log); | |
| 297 | 329 | shell::run(&theme, &mut view, &mut log) | |
| 298 | 330 | } | |
| 331 | + | Command::Image => { | |
| 332 | + | let mut view = image::ImageView::new(&mut log); | |
| 333 | + | shell::run(&theme, &mut view, &mut log) | |
| 334 | + | } | |
| 299 | 335 | // Already handled above, before the theme load. Matched per action | |
| 300 | 336 | // rather than with a `_` so a second `alloy theme` verb cannot be added | |
| 301 | 337 | // and silently do nothing here. |
| @@ -1,0 +1,148 @@ | |||
| 1 | + | #!/usr/bin/env bash | |
| 2 | + | # | |
| 3 | + | # refresh-base-digests.sh — move the Containerfile's pinned base images to | |
| 4 | + | # whatever the tags point at today. | |
| 5 | + | # | |
| 6 | + | # Both FROM lines are pinned by digest (wiki `alloy-distribution`, "Rebuilding | |
| 7 | + | # a past image"). A pin nobody can move is a pin people work around, so this is | |
| 8 | + | # the supported way to move it: it resolves each tag, rewrites the line in | |
| 9 | + | # place, and prints what changed so the diff is reviewable before it is | |
| 10 | + | # committed. Run it deliberately — a base image move is a rebuild of | |
| 11 | + | # everything, and it is a decision rather than maintenance. | |
| 12 | + | # | |
| 13 | + | # Multi-arch index digests, never per-arch manifests. CLAUDE.md forbids | |
| 14 | + | # cross-compiling and Alloy is built natively on fw13 (amd64) and astra | |
| 15 | + | # (arm64), so a per-arch pin would break one host. The script refuses a digest | |
| 16 | + | # that does not resolve to an index carrying both. | |
| 17 | + | # | |
| 18 | + | # No skopeo dependency: it is not in the image and not on every dev box, and | |
| 19 | + | # the registry v2 API answers this with two curls. | |
| 20 | + | # | |
| 21 | + | # Usage: | |
| 22 | + | # build/refresh-base-digests.sh # rewrite the Containerfile | |
| 23 | + | # build/refresh-base-digests.sh --check # exit 1 if the pins are stale | |
| 24 | + | # | |
| 25 | + | # --check is what a periodic reminder would run. It reports and changes | |
| 26 | + | # nothing, so it is safe to wire into anything. | |
| 27 | + | ||
| 28 | + | set -euo pipefail | |
| 29 | + | ||
| 30 | + | REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" | |
| 31 | + | CONTAINERFILE="$REPO_ROOT/Containerfile" | |
| 32 | + | ||
| 33 | + | # The images to keep pinned, as `repository:tag`. Both are quay.io; if a base | |
| 34 | + | # ever comes from elsewhere this needs a registry column rather than a guess. | |
| 35 | + | IMAGES=( | |
| 36 | + | "fedora/fedora:43" | |
| 37 | + | "fedora/fedora-bootc:43" | |
| 38 | + | ) | |
| 39 | + | ||
| 40 | + | REGISTRY="quay.io" | |
| 41 | + | CHECK_ONLY=0 | |
| 42 | + | ||
| 43 | + | die() { printf 'error: %s\n' "$*" >&2; exit 1; } | |
| 44 | + | ||
| 45 | + | case "${1:-}" in | |
| 46 | + | --check) CHECK_ONLY=1 ;; | |
| 47 | + | -h|--help) sed -n '2,28p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;; | |
| 48 | + | "") ;; | |
| 49 | + | *) die "unknown argument: $1 (see --help)" ;; | |
| 50 | + | esac | |
| 51 | + | ||
| 52 | + | command -v curl >/dev/null || die "curl not found" | |
| 53 | + | command -v python3 >/dev/null || die "python3 not found" | |
| 54 | + | [ -f "$CONTAINERFILE" ] || die "no Containerfile at $CONTAINERFILE" | |
| 55 | + | ||
| 56 | + | # An anonymous pull token. quay.io serves public repos without credentials but | |
| 57 | + | # still wants a bearer token on the manifest endpoint. | |
| 58 | + | token_for() { | |
| 59 | + | curl -fsS "https://${REGISTRY}/v2/auth?service=${REGISTRY}&scope=repository:${1}:pull" \ | |
| 60 | + | | python3 -c 'import sys, json; print(json.load(sys.stdin)["token"])' | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | # The index digest for a tag, asserted to be a real multi-arch index. | |
| 64 | + | # | |
| 65 | + | # The Accept header decides what comes back: ask for the index types first or | |
| 66 | + | # the registry answers with a single-arch manifest and the pin silently becomes | |
| 67 | + | # amd64-only. That failure would not show up until someone built on astra. | |
| 68 | + | digest_for() { | |
| 69 | + | local repo="$1" tag="$2" token body digest | |
| 70 | + | token="$(token_for "$repo")" | |
| 71 | + | ||
| 72 | + | digest="$(curl -fsSI \ | |
| 73 | + | -H "Authorization: Bearer ${token}" \ | |
| 74 | + | -H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json' \ | |
| 75 | + | "https://${REGISTRY}/v2/${repo}/manifests/${tag}" \ | |
| 76 | + | | grep -i '^docker-content-digest:' | tr -d '\r' | awk '{print $2}')" | |
| 77 | + | ||
| 78 | + | [ -n "$digest" ] || die "no digest for ${repo}:${tag}" | |
| 79 | + | ||
| 80 | + | # Prove it is an index over both build architectures before pinning it. | |
| 81 | + | body="$(curl -fsS \ | |
| 82 | + | -H "Authorization: Bearer ${token}" \ | |
| 83 | + | -H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json' \ | |
| 84 | + | "https://${REGISTRY}/v2/${repo}/manifests/${digest}")" | |
| 85 | + | ||
| 86 | + | printf '%s' "$body" | python3 -c ' | |
| 87 | + | import json, sys | |
| 88 | + | doc = json.load(sys.stdin) | |
| 89 | + | arches = {m.get("platform", {}).get("architecture") for m in doc.get("manifests", [])} | |
| 90 | + | missing = {"amd64", "arm64"} - arches | |
| 91 | + | if missing: | |
| 92 | + | sys.exit("not a multi-arch index over the build hosts; missing " + ", ".join(sorted(missing))) | |
| 93 | + | ' || die "${repo}:${tag} resolved to something unusable: $digest" | |
| 94 | + | ||
| 95 | + | printf '%s' "$digest" | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | stale=0 | |
| 99 | + | ||
| 100 | + | for image in "${IMAGES[@]}"; do | |
| 101 | + | repo="${image%:*}" | |
| 102 | + | tag="${image#*:}" | |
| 103 | + | ref="${REGISTRY}/${repo}:${tag}" | |
| 104 | + | ||
| 105 | + | current="$(grep -oE "^FROM ${ref}@sha256:[0-9a-f]{64}" "$CONTAINERFILE" | head -1 | grep -oE 'sha256:[0-9a-f]{64}' || true)" | |
| 106 | + | [ -n "$current" ] || die "no digest-pinned FROM line for ${ref} in the Containerfile" | |
| 107 | + | ||
| 108 | + | latest="$(digest_for "$repo" "$tag")" | |
| 109 | + | ||
| 110 | + | if [ "$current" = "$latest" ]; then | |
| 111 | + | printf 'current %s\n %s\n' "$ref" "$current" | |
| 112 | + | continue | |
| 113 | + | fi | |
| 114 | + | ||
| 115 | + | stale=1 | |
| 116 | + | printf 'STALE %s\n was %s\n now %s\n' "$ref" "$current" "$latest" | |
| 117 | + | ||
| 118 | + | if [ "$CHECK_ONLY" -eq 0 ]; then | |
| 119 | + | # Anchored at the start of the line and matching the full old digest, so | |
| 120 | + | # this cannot touch a digest that happens to appear in a comment. | |
| 121 | + | python3 - "$CONTAINERFILE" "$ref" "$current" "$latest" <<'PY' | |
| 122 | + | import sys | |
| 123 | + | ||
| 124 | + | path, ref, old, new = sys.argv[1:5] | |
| 125 | + | with open(path, encoding="utf-8") as handle: | |
| 126 | + | text = handle.read() | |
| 127 | + | ||
| 128 | + | needle = f"FROM {ref}@{old}" | |
| 129 | + | if text.count(needle) != 1: | |
| 130 | + | sys.exit(f"expected exactly one `{needle}`, found {text.count(needle)}") | |
| 131 | + | ||
| 132 | + | with open(path, "w", encoding="utf-8") as handle: | |
| 133 | + | handle.write(text.replace(needle, f"FROM {ref}@{new}")) | |
| 134 | + | PY | |
| 135 | + | printf ' rewrote %s\n' "$(basename "$CONTAINERFILE")" | |
| 136 | + | fi | |
| 137 | + | done | |
| 138 | + | ||
| 139 | + | if [ "$CHECK_ONLY" -eq 1 ] && [ "$stale" -eq 1 ]; then | |
| 140 | + | echo | |
| 141 | + | echo "Pins are behind their tags. Run build/refresh-base-digests.sh to move them." | |
| 142 | + | exit 1 | |
| 143 | + | fi | |
| 144 | + | ||
| 145 | + | if [ "$stale" -eq 1 ]; then | |
| 146 | + | echo | |
| 147 | + | echo "Base images moved. Everything rebuilds from scratch on the next build." | |
| 148 | + | fi |
| @@ -1,0 +1,84 @@ | |||
| 1 | + | #!/usr/bin/env bash | |
| 2 | + | # | |
| 3 | + | # write-device.sh — dd an artifact to a block device, with the guards. | |
| 4 | + | # | |
| 5 | + | # The single implementation of the dd path. build/build-image.sh and | |
| 6 | + | # build/build-iso.sh both call this; neither one writes a device itself. | |
| 7 | + | # The rule is in wiki alloy-distribution ("Do not rewrite the dd path"): | |
| 8 | + | # a second copy of these checks is how a disk-eating bug gets introduced, | |
| 9 | + | # because the copy is the one that does not get the fix. | |
| 10 | + | # | |
| 11 | + | # Usage: | |
| 12 | + | # build/write-device.sh <artifact> <device> [label] | |
| 13 | + | # | |
| 14 | + | # label is what the device is called in the closing line ("raw", "installer | |
| 15 | + | # ISO"); it is cosmetic and defaults to "image". | |
| 16 | + | # | |
| 17 | + | # Refuses partitions and anything with a mounted filesystem, warns on a | |
| 18 | + | # non-removable target, demands the device path typed back, and verifies the | |
| 19 | + | # result with cmp before claiming success. | |
| 20 | + | ||
| 21 | + | set -euo pipefail | |
| 22 | + | ||
| 23 | + | die() { printf 'error: %s\n' "$*" >&2; exit 1; } | |
| 24 | + | ||
| 25 | + | ARTIFACT="${1:?usage: write-device.sh <artifact> <device> [label]}" | |
| 26 | + | DEVICE="${2:?usage: write-device.sh <artifact> <device> [label]}" | |
| 27 | + | LABEL="${3:-image}" | |
| 28 | + | ||
| 29 | + | sudo test -f "$ARTIFACT" || die "$ARTIFACT is not a file" | |
| 30 | + | [ -b "$DEVICE" ] || die "$DEVICE is not a block device" | |
| 31 | + | ||
| 32 | + | # A whole disk, not a partition. An ISO written to /dev/sda1 produces | |
| 33 | + | # nothing bootable and quietly eats a filesystem on the way. | |
| 34 | + | [ "$(lsblk -dnro TYPE "$DEVICE")" = "disk" ] \ | |
| 35 | + | || die "$DEVICE is not a whole disk; pass the disk, not a partition" | |
| 36 | + | ||
| 37 | + | # Any mountpoint at or below the device, not just / and /boot. The old | |
| 38 | + | # guard matched those two patterns only, so a disk holding /home, /var or | |
| 39 | + | # an active swap passed it and got written. This is the rule the installer | |
| 40 | + | # itself applies (install.rs, Disk::blocker): anything mounted blocks. | |
| 41 | + | # `lsblk -r` escapes a newline as \x0a, so a device with two mountpoints | |
| 42 | + | # arrives as one run-together line. Unescape before printing: this list is | |
| 43 | + | # read by someone deciding whether to erase a disk. | |
| 44 | + | mounts="$(lsblk -nro MOUNTPOINTS "$DEVICE" 2>/dev/null \ | |
| 45 | + | | sed 's/\\x0a/\n/g' | grep -v '^$' || true)" | |
| 46 | + | if [ -n "$mounts" ]; then | |
| 47 | + | printf 'error: %s has mounted filesystems; refusing to write:\n' "$DEVICE" >&2 | |
| 48 | + | printf '%s\n' "$mounts" | sed 's/^/ /' >&2 | |
| 49 | + | exit 1 | |
| 50 | + | fi | |
| 51 | + | ||
| 52 | + | echo | |
| 53 | + | lsblk -o NAME,SIZE,TYPE,MOUNTPOINTS,MODEL,SERIAL,TRAN,RM,RO "$DEVICE" | |
| 54 | + | echo | |
| 55 | + | # Removable is worth saying out loud: on this box the system disk is nvme | |
| 56 | + | # and a USB stick reports usb/RM=1, so a non-removable target is the shape | |
| 57 | + | # of a mistake even when nothing is mounted on it. | |
| 58 | + | if [ "$(lsblk -dnro RM "$DEVICE")" != "1" ]; then | |
| 59 | + | echo "WARNING: $DEVICE is not removable. This is the shape of an internal disk." | |
| 60 | + | echo | |
| 61 | + | fi | |
| 62 | + | printf 'This ERASES all data on %s. Type the device path to confirm: ' "$DEVICE" | |
| 63 | + | read -r reply | |
| 64 | + | [ "$reply" = "$DEVICE" ] || die "confirmation did not match; not writing" | |
| 65 | + | ||
| 66 | + | echo "==> Writing $ARTIFACT to $DEVICE" | |
| 67 | + | sudo dd if="$ARTIFACT" of="$DEVICE" bs=4M oflag=direct conv=fsync status=progress | |
| 68 | + | sync | |
| 69 | + | ||
| 70 | + | # Verify, because dd reporting success is not evidence the bytes landed. | |
| 71 | + | # cmp over exactly the artifact's length is the authoritative check; a | |
| 72 | + | # `dd | head -c N | sha256sum` pipeline reported phantom corruption on a | |
| 73 | + | # write that cmp proved perfect (wiki alloy-build-notes). | |
| 74 | + | echo "==> Verifying the write" | |
| 75 | + | size="$(sudo stat -c %s "$ARTIFACT")" | |
| 76 | + | # A check that cannot fail is not a check: prove cmp can still disagree | |
| 77 | + | # before trusting it to agree. | |
| 78 | + | if sudo cmp -s -n 4096 "$DEVICE" /dev/zero; then | |
| 79 | + | die "negative control passed, which means cmp is not comparing anything" | |
| 80 | + | fi | |
| 81 | + | sudo cmp -n "$size" "$DEVICE" "$ARTIFACT" \ | |
| 82 | + | || die "$DEVICE does not match $ARTIFACT; the write is bad" | |
| 83 | + | echo "==> Verified $size bytes." | |
| 84 | + | echo "==> Done. $DEVICE is now a bootable Alloy $LABEL." |
| @@ -1,0 +1,1429 @@ | |||
| 1 | + | //! `alloy image` — the builder. Alloy's distribution mechanism. | |
| 2 | + | //! | |
| 3 | + | //! Nobody downloads an Alloy image. Users get a builder, configure it, build | |
| 4 | + | //! the ISO on their own machine and write it to a medium (wiki | |
| 5 | + | //! `alloy-distribution`). This is that builder, and it is the reason the | |
| 6 | + | //! project can publish source and no binaries: without it, "clone and build" | |
| 7 | + | //! is a Containerfile and a shell script, which is a distribution story only | |
| 8 | + | //! for people who already know what bootc is. | |
| 9 | + | //! | |
| 10 | + | //! # Not a package manager | |
| 11 | + | //! | |
| 12 | + | //! It exposes **the choices Alloy deliberately declines to make**, which is | |
| 13 | + | //! what keeps it from weakening the opinionated-defaults claim: declining to | |
| 14 | + | //! endorse is a stated position, not a dial. Browsers are the case the design | |
| 15 | + | //! note names — Alloy cannot argue one is right the way it argues for helix or | |
| 16 | + | //! nushell. Theme, terminal, editor and shell stay fixed and are not here. | |
| 17 | + | //! | |
| 18 | + | //! Two things joined that list since: | |
| 19 | + | //! | |
| 20 | + | //! - **The profile.** Ruled by Max 2026-08-01 as a build ARG on one | |
| 21 | + | //! Containerfile (wiki `alloy-server-variant`). Client is the desktop; | |
| 22 | + | //! server is the same machine with the compositor, greeter, session and | |
| 23 | + | //! browser removed. | |
| 24 | + | //! - **Language toolchains.** The Containerfile's own rust comment asks for | |
| 25 | + | //! this by name: 610 MiB across 16 packages, "per-image language selection | |
| 26 | + | //! at mint time is the way this stops being one number for everyone". So | |
| 27 | + | //! Rust is a choice here rather than an unconditional cost, defaulting on | |
| 28 | + | //! because the build-host role needs it. | |
| 29 | + | //! | |
| 30 | + | //! # It is also the minting tool | |
| 31 | + | //! | |
| 32 | + | //! GO alloy `1fc19a8d` folds in here rather than shipping beside it. One | |
| 33 | + | //! binary, because two ways to produce installer media differing only in how | |
| 34 | + | //! much they let you choose is one way too many. That is what the identity | |
| 35 | + | //! rows are: a hostname and an ssh pubkey baked in, so a headless machine can | |
| 36 | + | //! be reached at `<name>.local` and logged into with a key that was never | |
| 37 | + | //! shared. **Both are public**, which is the property that lets the artifact | |
| 38 | + | //! be kept, copied or rebuilt without care. | |
| 39 | + | //! | |
| 40 | + | //! # A form, not a wizard | |
| 41 | + | //! | |
| 42 | + | //! [`wizard::Steps`](crate::wizard) exists and this does not use it. A wizard | |
| 43 | + | //! is the shape for a fixed sequence ending in a destructive act, which is the | |
| 44 | + | //! installer. This is six choices that fit on one screen and are read together | |
| 45 | + | //! before either action is taken, and stepping through six one-question pages | |
| 46 | + | //! would hide the summary that is the whole point of the screen. The | |
| 47 | + | //! destructive act is guarded by its own confirmation instead. | |
| 48 | + | //! | |
| 49 | + | //! # The dd path is not re-derived, and that is deliberate | |
| 50 | + | //! | |
| 51 | + | //! `build/build-image.sh --write` refuses partitions, refuses anything with a | |
| 52 | + | //! mounted filesystem, demands an explicit device path plus an interactive | |
| 53 | + | //! confirmation, and verifies with `cmp` against a negative control. The | |
| 54 | + | //! design note names a TUI owning the write as the highest-risk part of the | |
| 55 | + | //! plan, and re-deriving device-write safety behind a progress bar is how a | |
| 56 | + | //! disk-eating bug gets written. | |
| 57 | + | //! | |
| 58 | + | //! So this does not own the write. It owns the *choice* of device, and then | |
| 59 | + | //! hands the terminal to the script through | |
| 60 | + | //! [`Flow::Suspend`](crate::shell::Flow), the same mechanism `distrobox enter` | |
| 61 | + | //! already uses. The script runs at a real terminal, asks its own confirmation | |
| 62 | + | //! in its own words, applies its own guards, and the console comes back when | |
| 63 | + | //! it is done. Nothing is bypassed, nothing is piped past a prompt, and no | |
| 64 | + | //! `--yes` flag had to be invented to make a TUI possible. | |
| 65 | + | //! | |
| 66 | + | //! # Podman-anywhere | |
| 67 | + | //! | |
| 68 | + | //! bootc-image-builder runs under podman on any Linux, so nothing here may | |
| 69 | + | //! assume an Alloy host. The commands are `build/build-iso.sh` and | |
| 70 | + | //! `build/build-image.sh` from a checkout, and the errors have to survive | |
| 71 | + | //! being read by someone on Ubuntu. That is why the missing-checkout case | |
| 72 | + | //! names what it wanted rather than assuming the user is standing in the repo. | |
| 73 | + | //! | |
| 74 | + | //! <!-- wiki: alloy-distribution --> | |
| 75 | + | ||
| 76 | + | use std::collections::BTreeSet; | |
| 77 | + | use std::fmt::Write as _; | |
| 78 | + | use std::path::{Path, PathBuf}; | |
| 79 | + | ||
| 80 | + | use alloy_tui::keys::{Action, classify}; | |
| 81 | + | use alloy_tui::{ | |
| 82 | + | AlloyBlock, AlloyForm, AlloyList, Cursor, FieldKind, FormRow, Hint, Severity, TextField, Theme, | |
| 83 | + | hint, text, | |
| 84 | + | }; | |
| 85 | + | use anyhow::{Context, Result}; | |
| 86 | + | use ratatui::Frame; | |
| 87 | + | use ratatui::crossterm::event::{KeyCode, KeyEvent}; | |
| 88 | + | use ratatui::layout::{Constraint, Layout, Rect}; | |
| 89 | + | use ratatui::text::Line; | |
| 90 | + | ||
| 91 | + | use crate::cli::{CommandLog, Invocation}; | |
| 92 | + | use crate::run::{Sequence, Stage}; | |
| 93 | + | use crate::shell::{Confirm, Flow, View, block_title}; | |
| 94 | + | ||
| 95 | + | /// Where a built image records the choices that made it. | |
| 96 | + | /// | |
| 97 | + | /// Read back at startup so a rebuild starts from what the machine already is | |
| 98 | + | /// rather than from defaults. The design note accepts that a user with neither | |
| 99 | + | /// a previous image nor an ISO to hand starts from defaults, which is what an | |
| 100 | + | /// absent file means here. | |
| 101 | + | const RECORD: &str = "/usr/lib/alloy/build.toml"; | |
| 102 | + | ||
| 103 | + | /// The same record, in a checkout, where the builder writes it. | |
| 104 | + | const RECORD_LOCAL: &str = "build/alloy-build.toml"; | |
| 105 | + | ||
| 106 | + | /// Which machine is being built. | |
| 107 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] | |
| 108 | + | pub(crate) enum Profile { | |
| 109 | + | /// The desktop: compositor, greeter, session, browser. | |
| 110 | + | #[default] | |
| 111 | + | Client, | |
| 112 | + | /// Headless. The console, the shell stack, the hardware-health group and | |
| 113 | + | /// the themed bare console, and nothing that needs a screen. | |
| 114 | + | Server, | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | impl Profile { | |
| 118 | + | const ALL: [Profile; 2] = [Profile::Client, Profile::Server]; | |
| 119 | + | ||
| 120 | + | const fn value(self) -> &'static str { | |
| 121 | + | match self { | |
| 122 | + | Profile::Client => "client", | |
| 123 | + | Profile::Server => "server", | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | const fn label(self) -> &'static str { | |
| 128 | + | match self { | |
| 129 | + | Profile::Client => "client (desktop)", | |
| 130 | + | Profile::Server => "server (headless)", | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | fn parse(value: &str) -> Option<Self> { | |
| 135 | + | Self::ALL.into_iter().find(|p| p.value() == value) | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | /// The browser, and the reason this screen exists at all. | |
| 140 | + | /// | |
| 141 | + | /// docs/STACK.md line 150: "This is a non-endorsement, not a pick." Alloy | |
| 142 | + | /// argues for helix over vim and nushell over bash; it cannot make the | |
| 143 | + | /// equivalent argument between Blink and Gecko, and the honest place to put | |
| 144 | + | /// that is a choice rather than a default nobody was asked about. | |
| 145 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] | |
| 146 | + | pub(crate) enum Browser { | |
| 147 | + | /// ungoogled-chromium plus privacy work, from Terra. The default because | |
| 148 | + | /// its out-of-the-box behavior is what Alloy used to hand-build in fifty | |
| 149 | + | /// Firefox preferences. | |
| 150 | + | #[default] | |
| 151 | + | Helium, | |
| 152 | + | /// The Gecko alternative, and the reason the monoculture argument is | |
| 153 | + | /// recorded as overridden rather than withdrawn. | |
| 154 | + | Firefox, | |
| 155 | + | /// No browser in the image. Meaningful on `client` too: someone who wants | |
| 156 | + | /// to install their own from Flathub should not pay for one they will | |
| 157 | + | /// remove. | |
| 158 | + | None, | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | impl Browser { | |
| 162 | + | const ALL: [Browser; 3] = [Browser::Helium, Browser::Firefox, Browser::None]; | |
| 163 | + | ||
| 164 | + | const fn value(self) -> &'static str { | |
| 165 | + | match self { | |
| 166 | + | Browser::Helium => "helium", | |
| 167 | + | Browser::Firefox => "firefox", | |
| 168 | + | Browser::None => "none", | |
| 169 | + | } | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | const fn label(self) -> &'static str { | |
| 173 | + | match self { | |
| 174 | + | Browser::Helium => "helium (Blink, from Terra)", | |
| 175 | + | Browser::Firefox => "firefox (Gecko, from Fedora)", | |
| 176 | + | Browser::None => "none", | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | fn parse(value: &str) -> Option<Self> { | |
| 181 | + | Self::ALL.into_iter().find(|b| b.value() == value) | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | /// A language toolchain the image can carry. | |
| 186 | + | /// | |
| 187 | + | /// The curated set, not an arbitrary package list. Each is a toolchain someone | |
| 188 | + | /// would reasonably want to build with on the machine itself rather than in a | |
| 189 | + | /// box, which is the line between this and `alloy pkg box`. | |
| 190 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] | |
| 191 | + | pub(crate) enum Lang { | |
| 192 | + | Rust, | |
| 193 | + | Go, | |
| 194 | + | Python, | |
| 195 | + | Zig, | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | impl Lang { | |
| 199 | + | const ALL: [Lang; 4] = [Lang::Rust, Lang::Go, Lang::Python, Lang::Zig]; | |
| 200 | + | ||
| 201 | + | const fn value(self) -> &'static str { | |
| 202 | + | match self { | |
| 203 | + | Lang::Rust => "rust", | |
| 204 | + | Lang::Go => "go", | |
| 205 | + | Lang::Python => "python", | |
| 206 | + | Lang::Zig => "zig", | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | /// What the label says about cost, because the cost is the point. | |
| 211 | + | /// | |
| 212 | + | /// Rust's figure is the Containerfile's own measurement, and it is the | |
| 213 | + | /// reason this row exists: 610 MiB is an order of magnitude above the | |
| 214 | + | /// hardware-health group and by a distance the largest thing in the image. | |
| 215 | + | const fn label(self) -> &'static str { | |
| 216 | + | match self { | |
| 217 | + | Lang::Rust => "rust (610 MiB; needed for the build-host role)", | |
| 218 | + | Lang::Go => "go", | |
| 219 | + | Lang::Python => "python", | |
| 220 | + | Lang::Zig => "zig", | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | fn parse(value: &str) -> Option<Self> { | |
| 225 | + | Self::ALL.into_iter().find(|l| l.value() == value) | |
| 226 | + | } | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | /// What the build produces. | |
| 230 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] | |
| 231 | + | pub(crate) enum Artifact { | |
| 232 | + | /// The installer ISO that boots into `alloy install`. The decided path | |
| 233 | + | /// (wiki `alloy-distribution`): ISO plus dd. | |
| 234 | + | #[default] | |
| 235 | + | Iso, | |
| 236 | + | /// A raw disk image, for dd'ing a whole installed system. | |
| 237 | + | Raw, | |
| 238 | + | /// qcow2, for QEMU. | |
| 239 | + | Qcow2, | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | impl Artifact { | |
| 243 | + | const ALL: [Artifact; 3] = [Artifact::Iso, Artifact::Raw, Artifact::Qcow2]; | |
| 244 | + | ||
| 245 | + | const fn value(self) -> &'static str { | |
| 246 | + | match self { | |
| 247 | + | Artifact::Iso => "iso", | |
| 248 | + | Artifact::Raw => "raw", | |
| 249 | + | Artifact::Qcow2 => "qcow2", | |
| 250 | + | } | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | const fn label(self) -> &'static str { | |
| 254 | + | match self { | |
| 255 | + | Artifact::Iso => "installer ISO (boots into alloy install)", | |
| 256 | + | Artifact::Raw => "raw disk image", | |
| 257 | + | Artifact::Qcow2 => "qcow2 (QEMU)", | |
| 258 | + | } | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | /// Which script owns it. The ISO is not bib's: every ISO type | |
| 262 | + | /// bootc-image-builder offers ends in Anaconda, and an install from one | |
| 263 | + | /// leaves root locked with no account, so `build-iso.sh` builds it outside | |
| 264 | + | /// bib. `build-image.sh` refuses the ISO types at the argument for the | |
| 265 | + | /// same reason. | |
| 266 | + | const fn script(self) -> &'static str { | |
| 267 | + | match self { | |
| 268 | + | Artifact::Iso => "build/build-iso.sh", | |
| 269 | + | Artifact::Raw | Artifact::Qcow2 => "build/build-image.sh", | |
| 270 | + | } | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | fn parse(value: &str) -> Option<Self> { | |
| 274 | + | Self::ALL.into_iter().find(|a| a.value() == value) | |
| 275 | + | } | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | /// Everything the builder decides. | |
| 279 | + | #[derive(Debug, Clone)] | |
| 280 | + | pub(crate) struct Choices { | |
| 281 | + | pub(crate) profile: Profile, | |
| 282 | + | pub(crate) browser: Browser, | |
| 283 | + | pub(crate) langs: BTreeSet<Lang>, | |
| 284 | + | pub(crate) hostname: String, | |
| 285 | + | /// Path to a public key on the building machine, not the key itself. | |
| 286 | + | /// | |
| 287 | + | /// The path is what the user picks and the bytes are read at build time, | |
| 288 | + | /// which keeps the record portable: a saved `build.toml` that embedded a | |
| 289 | + | /// key would be a different file on every machine for no reason. It is a | |
| 290 | + | /// *public* key, so nothing here is a secret either way — see the module | |
| 291 | + | /// docs on why that property is load-bearing. | |
| 292 | + | pub(crate) pubkey: String, | |
| 293 | + | pub(crate) artifact: Artifact, | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | impl Default for Choices { | |
| 297 | + | fn default() -> Self { | |
| 298 | + | Self { | |
| 299 | + | profile: Profile::default(), | |
| 300 | + | browser: Browser::default(), | |
| 301 | + | // Rust on by default: the build-host role is a hard requirement | |
| 302 | + | // (sandod refuses to compile anywhere but its configured host), and | |
| 303 | + | // an image that silently stopped being able to build Rust would | |
| 304 | + | // break Sando rather than merely save disk. | |
| 305 | + | langs: BTreeSet::from([Lang::Rust]), | |
| 306 | + | hostname: String::new(), | |
| 307 | + | pubkey: String::new(), | |
| 308 | + | artifact: Artifact::default(), | |
| 309 | + | } | |
| 310 | + | } | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | impl Choices { | |
| 314 | + | /// The `--build-arg` pairs, in a fixed order so the displayed command is | |
| 315 | + | /// stable between frames and between runs. | |
| 316 | + | /// | |
| 317 | + | /// `LANGS` is a comma-joined list rather than one ARG per language: the | |
| 318 | + | /// Containerfile validates the whole list against its own curated set, so | |
| 319 | + | /// the gate lives there rather than in the number of arguments. That is | |
| 320 | + | /// the same layering `PROFILE` uses, and it means the build refuses an | |
| 321 | + | /// unknown language even if this screen is bypassed entirely. | |
| 322 | + | pub(crate) fn build_args(&self) -> Vec<(String, String)> { | |
| 323 | + | let mut args = vec![ | |
| 324 | + | ("PROFILE".to_string(), self.profile.value().to_string()), | |
| 325 | + | ("BROWSER".to_string(), self.browser.value().to_string()), | |
| 326 | + | ( | |
| 327 | + | "LANGS".to_string(), | |
| 328 | + | self.langs | |
| 329 | + | .iter() | |
| 330 | + | .map(|lang| lang.value()) | |
| 331 | + | .collect::<Vec<_>>() | |
| 332 | + | .join(","), | |
| 333 | + | ), | |
| 334 | + | ]; | |
| 335 | + | if !self.hostname.is_empty() { | |
| 336 | + | args.push(("ALLOY_HOSTNAME".to_string(), self.hostname.clone())); | |
| 337 | + | } | |
| 338 | + | // The KEY, not the path to it. `pubkey` is a path on the building | |
| 339 | + | // machine and the build container cannot see it: the file is not in | |
| 340 | + | // the build context, and putting it there would mean writing a key | |
| 341 | + | // into the repo. So the bytes are read here and travel as the value, | |
| 342 | + | // which is also why the record on disk keeps the path instead — a | |
| 343 | + | // saved `build.toml` holding an embedded key would be a different | |
| 344 | + | // file on every machine for no reason. | |
| 345 | + | // | |
| 346 | + | // Unreadable is silently omitted rather than guessed at, because | |
| 347 | + | // `blockers` is the gate and has already refused to start a build | |
| 348 | + | // whose key cannot be read. Passing the path through as if it were a | |
| 349 | + | // key would make the Containerfile's own validation reject it, which | |
| 350 | + | // is a confusing way to learn the file is missing. | |
| 351 | + | if !self.pubkey.is_empty() | |
| 352 | + | && let Ok(contents) = std::fs::read_to_string(&self.pubkey) | |
| 353 | + | { | |
| 354 | + | let key = contents.trim(); | |
| 355 | + | if !key.is_empty() { | |
| 356 | + | args.push(("ALLOY_SSH_KEY".to_string(), key.to_string())); | |
| 357 | + | } | |
| 358 | + | } | |
| 359 | + | args | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | /// The command that builds this. | |
| 363 | + | /// | |
| 364 | + | /// One `--build-arg` per pair rather than a packed string, so the log pane | |
| 365 | + | /// shows exactly what podman will receive and the line is copy-pasteable. | |
| 366 | + | pub(crate) fn invocation(&self) -> Invocation { | |
| 367 | + | let mut invocation = Invocation::new(self.artifact.script()); | |
| 368 | + | ||
| 369 | + | // The disk-image script needs a type; the ISO script builds one thing | |
| 370 | + | // and takes no type at all, and passing one would be an error rather | |
| 371 | + | // than a no-op. | |
| 372 | + | if self.artifact != Artifact::Iso { | |
| 373 | + | invocation = invocation.args(["--type", self.artifact.value()]); | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | for (key, value) in self.build_args() { | |
| 377 | + | invocation = invocation.arg("--build-arg").arg(format!("{key}={value}")); | |
| 378 | + | } | |
| 379 | + | invocation | |
| 380 | + | } | |
| 381 | + | ||
| 382 | + | /// Reasons this cannot be built, in the order a user would hit them. | |
| 383 | + | /// | |
| 384 | + | /// Returned rather than rendered so the summary and the build gate read | |
| 385 | + | /// the same list. An empty vector is the only thing that starts a build. | |
| 386 | + | pub(crate) fn blockers(&self, repo: Option<&Path>) -> Vec<String> { | |
| 387 | + | let mut blockers = Vec::new(); | |
| 388 | + | ||
| 389 | + | match repo { | |
| 390 | + | None => blockers.push( | |
| 391 | + | "no Alloy checkout here: run this from a clone, or pass one with --repo. \ | |
| 392 | + | The builder drives build/build-iso.sh from the source tree." | |
| 393 | + | .to_string(), | |
| 394 | + | ), | |
| 395 | + | Some(root) => { | |
| 396 | + | let script = root.join(self.artifact.script()); | |
| 397 | + | if !script.is_file() { | |
| 398 | + | blockers.push(format!("{} is not in this checkout", script.display())); | |
| 399 | + | } | |
| 400 | + | } | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | if !self.hostname.is_empty() && !valid_hostname(&self.hostname) { | |
| 404 | + | blockers.push(format!( | |
| 405 | + | "`{}` is not a hostname: letters, digits and hyphens, not starting or ending with one, 63 characters or fewer", | |
| 406 | + | self.hostname | |
| 407 | + | )); | |
| 408 | + | } | |
| 409 | + | ||
| 410 | + | if !self.pubkey.is_empty() { | |
| 411 | + | let path = PathBuf::from(&self.pubkey); | |
| 412 | + | if !path.is_file() { | |
| 413 | + | blockers.push(format!("no public key at {}", path.display())); | |
| 414 | + | } else if std::fs::read_to_string(&path) | |
| 415 | + | .is_ok_and(|contents| !looks_like_pubkey(&contents)) | |
| 416 | + | { | |
| 417 | + | blockers.push(format!( | |
| 418 | + | "{} does not look like an SSH public key. If this is a PRIVATE key, do not \ | |
| 419 | + | bake it in: the artifact is meant to be copyable without care.", | |
| 420 | + | path.display() | |
| 421 | + | )); | |
| 422 | + | } | |
| 423 | + | } | |
| 424 | + | ||
| 425 | + | if self.profile == Profile::Server && self.browser != Browser::None { | |
| 426 | + | blockers.push( | |
| 427 | + | "the server profile ships no graphical session, so it cannot carry a browser" | |
| 428 | + | .to_string(), | |
| 429 | + | ); | |
| 430 | + | } | |
| 431 | + | ||
| 432 | + | blockers | |
| 433 | + | } | |
| 434 | + | ||
| 435 | + | /// The record, as TOML. | |
| 436 | + | /// | |
| 437 | + | /// Hand-written rather than serialized, for one reason: the file is read | |
| 438 | + | /// by a person deciding whether to rebuild, so it carries the commentary | |
| 439 | + | /// that makes it answerable. A derived `Serialize` would emit the same | |
| 440 | + | /// keys and none of the why. | |
| 441 | + | pub(crate) fn to_toml(&self) -> String { | |
| 442 | + | let mut out = String::new(); | |
| 443 | + | out.push_str("# Alloy build record — the choices that made this image.\n"); | |
| 444 | + | out.push_str("# Written by `alloy image`, and read back by it so a rebuild starts\n"); | |
| 445 | + | out.push_str("# from what this machine already is rather than from defaults.\n"); | |
| 446 | + | out.push_str("#\n"); | |
| 447 | + | out.push_str("# This is the CHOICES half. It is not a lockfile: it does not pin the\n"); | |
| 448 | + | out.push_str("# resolved RPM set, so rebuilding from it gives you the same decisions\n"); | |
| 449 | + | out.push_str("# against today's packages, not the same image. See wiki\n"); | |
| 450 | + | out.push_str("# `alloy-distribution` for why the resolutions half is still open.\n\n"); | |
| 451 | + | ||
| 452 | + | let _ = writeln!(out, "profile = {:?}", self.profile.value()); | |
| 453 | + | let _ = writeln!(out, "browser = {:?}", self.browser.value()); | |
| 454 | + | let langs: Vec<String> = self | |
| 455 | + | .langs | |
| 456 | + | .iter() | |
| 457 | + | .map(|lang| format!("{:?}", lang.value())) | |
| 458 | + | .collect(); | |
| 459 | + | let _ = writeln!(out, "langs = [{}]", langs.join(", ")); | |
| 460 | + | let _ = writeln!(out, "artifact = {:?}", self.artifact.value()); | |
| 461 | + | let _ = writeln!(out, "hostname = {:?}", self.hostname); | |
| 462 | + | let _ = writeln!(out, "pubkey = {:?}", self.pubkey); | |
| 463 | + | out | |
| 464 | + | } | |
| 465 | + | ||
| 466 | + | /// Parse a record back. | |
| 467 | + | /// | |
| 468 | + | /// An unknown value for an enum is an error rather than a silent fall back | |
| 469 | + | /// to the default. A record naming a browser this build does not offer is | |
| 470 | + | /// a record from a different version of Alloy, and quietly building | |
| 471 | + | /// something else is how a user ends up with an image they did not ask | |
| 472 | + | /// for and cannot explain. | |
| 473 | + | pub(crate) fn from_toml(raw: &str) -> Result<Self> { | |
| 474 | + | let doc: toml::Value = toml::from_str(raw).context("the build record is not valid TOML")?; | |
| 475 | + | ||
| 476 | + | let word = |key: &str| -> Option<&str> { doc.get(key).and_then(toml::Value::as_str) }; | |
| 477 | + | ||
| 478 | + | let mut choices = Self::default(); | |
| 479 | + | ||
| 480 | + | if let Some(value) = word("profile") { | |
| 481 | + | choices.profile = | |
| 482 | + | Profile::parse(value).with_context(|| format!("unknown profile `{value}`"))?; | |
| 483 | + | } | |
| 484 | + | if let Some(value) = word("browser") { | |
| 485 | + | choices.browser = | |
| 486 | + | Browser::parse(value).with_context(|| format!("unknown browser `{value}`"))?; | |
| 487 | + | } | |
| 488 | + | if let Some(value) = word("artifact") { | |
| 489 | + | choices.artifact = | |
| 490 | + | Artifact::parse(value).with_context(|| format!("unknown artifact `{value}`"))?; | |
| 491 | + | } | |
| 492 | + | if let Some(value) = word("hostname") { | |
| 493 | + | choices.hostname = value.to_string(); | |
| 494 | + | } | |
| 495 | + | if let Some(value) = word("pubkey") { | |
| 496 | + | choices.pubkey = value.to_string(); | |
| 497 | + | } | |
| 498 | + | if let Some(values) = doc.get("langs").and_then(toml::Value::as_array) { | |
| 499 | + | let mut langs = BTreeSet::new(); | |
| 500 | + | for value in values { |
Lines truncated
| @@ -1,0 +1,221 @@ | |||
| 1 | + | //! Which image this is, and which verbs that leaves. | |
| 2 | + | //! | |
| 3 | + | //! The build writes `client` or `server` to [`MARKER`] (Containerfile, the | |
| 4 | + | //! profile block). This reads it back so `alloy --help` offers only the verbs | |
| 5 | + | //! that can do something on this machine: `alloy display` drives swaymsg and | |
| 6 | + | //! `alloy status --bar` writes swaybar's protocol, and a `server` image has | |
| 7 | + | //! neither a compositor nor a bar. | |
| 8 | + | //! | |
| 9 | + | //! # Why a marker and not a probe | |
| 10 | + | //! | |
| 11 | + | //! Max, 2026-08-01, choosing "hide the verbs entirely" over reporting the | |
| 12 | + | //! absence. Hiding has to be decided before `--help` renders, and a probe | |
| 13 | + | //! answers the wrong question at that moment: "is a compositor running right | |
| 14 | + | //! now" is false on a client machine sitting at the greeter, or in a TTY, or | |
| 15 | + | //! over SSH into a laptop. That would make a verb appear and disappear on one | |
| 16 | + | //! machine depending on where it was typed, which is worse than either | |
| 17 | + | //! consistent answer. | |
| 18 | + | //! | |
| 19 | + | //! The image already knows what it is. Reading what the build recorded is the | |
| 20 | + | //! only source that is stable for the life of the machine. | |
| 21 | + | //! | |
| 22 | + | //! # Absent means client | |
| 23 | + | //! | |
| 24 | + | //! A `cargo run` in a checkout has no marker, and so does anything built | |
| 25 | + | //! before this landed. Defaulting to `client` keeps every existing invocation | |
| 26 | + | //! working and keeps the failure direction right: an offered verb that reports | |
| 27 | + | //! no compositor is a worse outcome than a hidden one, but a *hidden* verb on | |
| 28 | + | //! a machine that could have run it is unexplainable. So absence offers | |
| 29 | + | //! everything. | |
| 30 | + | //! | |
| 31 | + | //! # Hidden is not disabled | |
| 32 | + | //! | |
| 33 | + | //! A verb dropped from `--help` still exists, because clap resolves it either | |
| 34 | + | //! way and a hidden-but-silently-working command is the sort of thing that | |
| 35 | + | //! makes documentation wrong. Invoking one on `server` says which profile this | |
| 36 | + | //! is and why the verb is not for it, which is the same "a front that did not | |
| 37 | + | //! answer" rule `alloy settings` applies to a row whose tool is missing. | |
| 38 | + | ||
| 39 | + | use std::fmt; | |
| 40 | + | ||
| 41 | + | /// Where the build records the profile. `/usr/lib` rather than `/etc`: it | |
| 42 | + | /// describes the image, not the machine's configuration, and nothing should | |
| 43 | + | /// edit it after the build. | |
| 44 | + | pub(crate) const MARKER: &str = "/usr/lib/alloy/profile"; | |
| 45 | + | ||
| 46 | + | /// The verbs a `server` image does not offer. | |
| 47 | + | /// | |
| 48 | + | /// Named here rather than derived from anything, because the list is a | |
| 49 | + | /// judgment about what needs a compositor and there is nothing in the argument | |
| 50 | + | /// parser that knows that. | |
| 51 | + | pub(crate) const HEADLESS_HIDES: [&str; 2] = ["display", "status"]; | |
| 52 | + | ||
| 53 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] | |
| 54 | + | pub(crate) enum Profile { | |
| 55 | + | #[default] | |
| 56 | + | Client, | |
| 57 | + | Server, | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | impl Profile { | |
| 61 | + | /// What this machine is, from the marker the build wrote. | |
| 62 | + | /// | |
| 63 | + | /// An unreadable or unrecognized marker reads as `Client`, for the reason | |
| 64 | + | /// in the module docs: offering a verb that then explains itself beats | |
| 65 | + | /// hiding one that would have worked. | |
| 66 | + | pub(crate) fn current() -> Self { | |
| 67 | + | Self::parse(std::fs::read_to_string(MARKER).ok().as_deref()) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | /// Split out from [`current`](Self::current) so the mapping is testable | |
| 71 | + | /// without a filesystem. | |
| 72 | + | pub(crate) fn parse(marker: Option<&str>) -> Self { | |
| 73 | + | match marker.map(str::trim) { | |
| 74 | + | Some("server") => Profile::Server, | |
| 75 | + | _ => Profile::Client, | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | /// Whether this profile has a graphical session at all. | |
| 80 | + | pub(crate) const fn graphical(self) -> bool { | |
| 81 | + | matches!(self, Profile::Client) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | pub(crate) const fn as_str(self) -> &'static str { | |
| 85 | + | match self { | |
| 86 | + | Profile::Client => "client", | |
| 87 | + | Profile::Server => "server", | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | impl fmt::Display for Profile { | |
| 93 | + | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 94 | + | f.write_str(self.as_str()) | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | /// Hide the verbs this profile cannot carry. | |
| 99 | + | /// | |
| 100 | + | /// Takes and returns the command rather than reading the marker itself, so the | |
| 101 | + | /// mapping from profile to visible verbs is testable without a filesystem — | |
| 102 | + | /// which matters, because "which verbs exist" is the whole of what was ruled | |
| 103 | + | /// on and a wrong answer is invisible until someone runs `--help` on the one | |
| 104 | + | /// machine that has the other profile. | |
| 105 | + | pub(crate) fn hide_unavailable(mut command: clap::Command, profile: Profile) -> clap::Command { | |
| 106 | + | if profile.graphical() { | |
| 107 | + | return command; | |
| 108 | + | } | |
| 109 | + | for verb in HEADLESS_HIDES { | |
| 110 | + | command = command.mut_subcommand(verb, |sub| sub.hide(true)); | |
| 111 | + | } | |
| 112 | + | command | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | /// Whether this verb is one the profile does not carry. | |
| 116 | + | /// | |
| 117 | + | /// Paired with [`hide_unavailable`] so the two cannot drift: hiding a verb | |
| 118 | + | /// from `--help` while still running it is the failure this exists to prevent. | |
| 119 | + | pub(crate) fn is_unavailable(verb: &str, profile: Profile) -> bool { | |
| 120 | + | !profile.graphical() && HEADLESS_HIDES.contains(&verb) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | /// What to print when a verb is invoked on a profile that does not carry it. | |
| 124 | + | /// | |
| 125 | + | /// Names the profile rather than the symptom. "no compositor" invites the user | |
| 126 | + | /// to go looking for one; "this is a server image" is the fact that explains | |
| 127 | + | /// every other verb they are about to find missing. | |
| 128 | + | pub(crate) fn unavailable(verb: &str) -> String { | |
| 129 | + | format!( | |
| 130 | + | "`alloy {verb}` needs a graphical session, and this is a server image \ | |
| 131 | + | ({MARKER} says server). The verb is not offered on this profile." | |
| 132 | + | ) | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | #[cfg(test)] | |
| 136 | + | mod tests { | |
| 137 | + | use super::*; | |
| 138 | + | ||
| 139 | + | #[test] | |
| 140 | + | fn the_marker_decides() { | |
| 141 | + | assert_eq!(Profile::parse(Some("server")), Profile::Server); | |
| 142 | + | assert_eq!(Profile::parse(Some("client")), Profile::Client); | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | /// Trailing newline is what `printf '%s\n'` writes, which is what the | |
| 146 | + | /// Containerfile uses. | |
| 147 | + | #[test] | |
| 148 | + | fn a_trailing_newline_is_not_a_different_profile() { | |
| 149 | + | assert_eq!(Profile::parse(Some("server\n")), Profile::Server); | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | /// The failure direction. Anything unreadable offers every verb, because a | |
| 153 | + | /// hidden verb on a machine that could run it is the unexplainable case. | |
| 154 | + | #[test] | |
| 155 | + | fn anything_unrecognized_is_a_client() { | |
| 156 | + | assert_eq!(Profile::parse(None), Profile::Client); | |
| 157 | + | assert_eq!(Profile::parse(Some("")), Profile::Client); | |
| 158 | + | assert_eq!(Profile::parse(Some("desktop")), Profile::Client); | |
| 159 | + | assert_eq!(Profile::parse(Some("SERVER")), Profile::Client); | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | #[test] | |
| 163 | + | fn only_the_client_is_graphical() { | |
| 164 | + | assert!(Profile::Client.graphical()); | |
| 165 | + | assert!(!Profile::Server.graphical()); | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | /// The message names the profile, not the symptom, and says where the | |
| 169 | + | /// answer came from so it can be checked. | |
| 170 | + | #[test] | |
| 171 | + | fn the_unavailable_message_explains_rather_than_reports() { | |
| 172 | + | let message = unavailable("display"); | |
| 173 | + | assert!(message.contains("server image")); | |
| 174 | + | assert!(message.contains(MARKER)); | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | /// A stand-in for the real CLI, so this tests the mapping rather than the | |
| 178 | + | /// verb roster. Both the hidden verbs and one that must survive. | |
| 179 | + | fn command() -> clap::Command { | |
| 180 | + | clap::Command::new("alloy") | |
| 181 | + | .subcommand(clap::Command::new("display")) | |
| 182 | + | .subcommand(clap::Command::new("status")) | |
| 183 | + | .subcommand(clap::Command::new("net")) | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | fn visible(profile: Profile) -> Vec<String> { | |
| 187 | + | hide_unavailable(command(), profile) | |
| 188 | + | .get_subcommands() | |
| 189 | + | .filter(|sub| !sub.is_hide_set()) | |
| 190 | + | .map(|sub| sub.get_name().to_string()) | |
| 191 | + | .collect() | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | #[test] | |
| 195 | + | fn a_client_is_offered_everything() { | |
| 196 | + | assert_eq!(visible(Profile::Client), ["display", "status", "net"]); | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | /// The ruling: hidden entirely, not reported as absent. | |
| 200 | + | #[test] | |
| 201 | + | fn a_server_is_not_offered_the_graphical_verbs() { | |
| 202 | + | assert_eq!(visible(Profile::Server), ["net"]); | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | /// Hiding and refusing have to agree. A verb dropped from `--help` that | |
| 206 | + | /// still ran would make the documentation wrong in the one direction | |
| 207 | + | /// nobody checks. | |
| 208 | + | #[test] | |
| 209 | + | fn what_is_hidden_is_exactly_what_is_refused() { | |
| 210 | + | for profile in [Profile::Client, Profile::Server] { | |
| 211 | + | let shown = visible(profile); | |
| 212 | + | for verb in ["display", "status", "net"] { | |
| 213 | + | assert_eq!( | |
| 214 | + | !shown.iter().any(|s| s == verb), | |
| 215 | + | is_unavailable(verb, profile), | |
| 216 | + | "`{verb}` on {profile}: hidden and refused disagree", | |
| 217 | + | ); | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | } |
| @@ -1,0 +1,243 @@ | |||
| 1 | + | //! The build record is written by shell and read by Rust, so the two are | |
| 2 | + | //! checked against each other rather than against each other's authors. | |
| 3 | + | //! | |
| 4 | + | //! `alloy image` records its choices into the image it builds, and reads them | |
| 5 | + | //! back on the next machine so a rebuild starts from what that machine already | |
| 6 | + | //! is (wiki `alloy-distribution`: "choices recorded into the built image, read | |
| 7 | + | //! back from the previous one"). The writing half is a `RUN` block of `echo` | |
| 8 | + | //! lines in the Containerfile; the reading half is `Choices::from_toml`. Those | |
| 9 | + | //! are different languages in different files, and nothing but this connects | |
| 10 | + | //! them. | |
| 11 | + | //! | |
| 12 | + | //! The drift is silent in the direction that matters. A record the parser | |
| 13 | + | //! cannot read does not fail a build or an install — it fails a *read*, and | |
| 14 | + | //! `Choices::load` falls back to defaults, so the symptom is a builder that | |
| 15 | + | //! quietly forgets the machine's configuration. That is exactly the class of | |
| 16 | + | //! failure the Containerfile's other assertions exist to catch, arrived at | |
| 17 | + | //! from the one direction the build itself cannot check. | |
| 18 | + | //! | |
| 19 | + | //! So this extracts the document the Containerfile actually emits, for every | |
| 20 | + | //! combination of ARGs that matters, and parses it with the console's own | |
| 21 | + | //! parser. | |
| 22 | + | ||
| 23 | + | use std::path::PathBuf; | |
| 24 | + | use std::process::Command; | |
| 25 | + | ||
| 26 | + | fn repo_root() -> PathBuf { | |
| 27 | + | PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | /// The record-writing `RUN` block, as a shell script. | |
| 31 | + | /// | |
| 32 | + | /// Pulled out of the Containerfile rather than duplicated here, which is the | |
| 33 | + | /// whole point: a copy would pass this test forever while the real block | |
| 34 | + | /// changed underneath it. Continuations are joined and comment lines dropped, | |
| 35 | + | /// the way the image parser does it before the shell ever sees the text. | |
| 36 | + | fn writer_script() -> String { | |
| 37 | + | let path = repo_root().join("Containerfile"); | |
| 38 | + | let text = std::fs::read_to_string(&path) | |
| 39 | + | .unwrap_or_else(|err| panic!("cannot read {}: {err}", path.display())); | |
| 40 | + | ||
| 41 | + | let mut instruction: Option<String> = None; | |
| 42 | + | for raw in text.lines() { | |
| 43 | + | let line = raw.trim_end(); | |
| 44 | + | let trimmed = line.trim_start(); | |
| 45 | + | if trimmed.starts_with('#') { | |
| 46 | + | continue; | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | match &mut instruction { | |
| 50 | + | None => { | |
| 51 | + | let Some(body) = trimmed.strip_prefix("RUN ") else { | |
| 52 | + | continue; | |
| 53 | + | }; | |
| 54 | + | instruction = Some(body.to_string()); | |
| 55 | + | } | |
| 56 | + | Some(current) => { | |
| 57 | + | current.push(' '); | |
| 58 | + | current.push_str(trimmed); | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | if line.ends_with('\\') { | |
| 63 | + | let current = instruction.as_mut().expect("inside an instruction"); | |
| 64 | + | current.pop(); | |
| 65 | + | continue; | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | let finished = instruction.take().expect("just built one"); | |
| 69 | + | if finished.contains("/usr/lib/alloy/build.toml") { | |
| 70 | + | return finished; | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | panic!("no RUN block in the Containerfile writes /usr/lib/alloy/build.toml"); | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | /// Run the extracted block with the given ARGs and return what it wrote. | |
| 78 | + | /// | |
| 79 | + | /// The block writes to an absolute path, so it runs against a redirected root: | |
| 80 | + | /// `HOME` is not enough because the path is `/usr/lib/alloy`. Rewriting the | |
| 81 | + | /// path for the test rather than mocking it keeps the script byte-identical to | |
| 82 | + | /// the one in the image apart from its prefix. | |
| 83 | + | fn record_for(profile: &str, browser: &str, langs: &str, hostname: &str) -> String { | |
| 84 | + | // Unique per call, not per set of arguments. Tests run concurrently and | |
| 85 | + | // three of them ask for the same combination, so a name derived from the | |
| 86 | + | // arguments had two threads sharing one directory and removing it from | |
| 87 | + | // under each other. | |
| 88 | + | static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); | |
| 89 | + | let dir = std::env::temp_dir().join(format!( | |
| 90 | + | "alloy-build-record-{}-{}", | |
| 91 | + | std::process::id(), | |
| 92 | + | NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed), | |
| 93 | + | )); | |
| 94 | + | let _ = std::fs::remove_dir_all(&dir); | |
| 95 | + | std::fs::create_dir_all(&dir).expect("scratch dir"); | |
| 96 | + | ||
| 97 | + | let script = writer_script().replace( | |
| 98 | + | "/usr/lib/alloy", | |
| 99 | + | &dir.join("usr/lib/alloy").to_string_lossy(), | |
| 100 | + | ); | |
| 101 | + | ||
| 102 | + | let output = Command::new("sh") | |
| 103 | + | .arg("-c") | |
| 104 | + | .arg(&script) | |
| 105 | + | .env("PROFILE", profile) | |
| 106 | + | .env("BROWSER", browser) | |
| 107 | + | .env("LANGS", langs) | |
| 108 | + | .env("ALLOY_HOSTNAME", hostname) | |
| 109 | + | .env("ALLOY_SSH_KEY", "") | |
| 110 | + | .output() | |
| 111 | + | .expect("running the extracted writer"); | |
| 112 | + | ||
| 113 | + | assert!( | |
| 114 | + | output.status.success(), | |
| 115 | + | "the record writer failed for profile={profile} browser={browser} langs={langs}:\n{}", | |
| 116 | + | String::from_utf8_lossy(&output.stderr), | |
| 117 | + | ); | |
| 118 | + | ||
| 119 | + | let written = std::fs::read_to_string(dir.join("usr/lib/alloy/build.toml")) | |
| 120 | + | .expect("the writer produced no file"); | |
| 121 | + | let _ = std::fs::remove_dir_all(&dir); | |
| 122 | + | written | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | /// The contract, over the combinations that differ structurally: both | |
| 126 | + | /// profiles, every browser, an empty and a multi-entry language list, and a | |
| 127 | + | /// hostname both set and unset. | |
| 128 | + | #[test] | |
| 129 | + | fn every_record_the_build_can_write_parses() { | |
| 130 | + | let cases = [ | |
| 131 | + | ("client", "helium", "rust", "bench"), | |
| 132 | + | ("client", "firefox", "rust,go,python,zig", ""), | |
| 133 | + | ("client", "none", "", "laptop"), | |
| 134 | + | ("server", "none", "rust", "build-host-2"), | |
| 135 | + | ("server", "none", "", ""), | |
| 136 | + | ]; | |
| 137 | + | ||
| 138 | + | for (profile, browser, langs, hostname) in cases { | |
| 139 | + | let record = record_for(profile, browser, langs, hostname); | |
| 140 | + | ||
| 141 | + | let parsed = alloy_build_record::parse(&record).unwrap_or_else(|err| { | |
| 142 | + | panic!( | |
| 143 | + | "the console cannot read the record the build writes for \ | |
| 144 | + | profile={profile} browser={browser} langs={langs:?}: {err}\n\n{record}" | |
| 145 | + | ); | |
| 146 | + | }); | |
| 147 | + | ||
| 148 | + | assert_eq!(parsed.profile, profile, "profile round trip\n{record}"); | |
| 149 | + | assert_eq!(parsed.browser, browser, "browser round trip\n{record}"); | |
| 150 | + | assert_eq!(parsed.hostname, hostname, "hostname round trip\n{record}"); | |
| 151 | + | ||
| 152 | + | let expected: Vec<&str> = if langs.is_empty() { | |
| 153 | + | Vec::new() | |
| 154 | + | } else { | |
| 155 | + | langs.split(',').collect() | |
| 156 | + | }; | |
| 157 | + | assert_eq!(parsed.langs, expected, "langs round trip\n{record}"); | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | /// An empty language list has to be `[]` and not `[""]`, which is the shape | |
| 162 | + | /// the shell's `${langs#, }` trim produces if the loop body runs once on an | |
| 163 | + | /// empty string. It parses either way as TOML; only one of them is true. | |
| 164 | + | #[test] | |
| 165 | + | fn no_languages_is_an_empty_list_rather_than_one_empty_entry() { | |
| 166 | + | let record = record_for("server", "none", "", ""); | |
| 167 | + | assert!( | |
| 168 | + | record.contains("langs = []"), | |
| 169 | + | "an empty LANGS must write an empty array:\n{record}" | |
| 170 | + | ); | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | /// The record must say what it is not. A file called a record invites being | |
| 174 | + | /// read as a lockfile, and the difference is the whole of why rebuilding from | |
| 175 | + | /// it reproduces decisions rather than an image. | |
| 176 | + | #[test] | |
| 177 | + | fn the_record_disclaims_being_a_lockfile() { | |
| 178 | + | let record = record_for("client", "helium", "rust", "bench"); | |
| 179 | + | assert!( | |
| 180 | + | record.contains("not a lockfile"), | |
| 181 | + | "the record must say it pins choices and not resolutions:\n{record}" | |
| 182 | + | ); | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | /// `pubkey` is a path on whichever machine ran the builder, so it means | |
| 186 | + | /// nothing in the image. Empty rather than absent, so the document has one | |
| 187 | + | /// shape and nobody reads a missing key as a key that was lost. | |
| 188 | + | #[test] | |
| 189 | + | fn the_image_record_carries_no_pubkey_path() { | |
| 190 | + | let record = record_for("client", "helium", "rust", "bench"); | |
| 191 | + | assert!(record.contains("pubkey = \"\""), "{record}"); | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | /// A minimal stand-in for the console's parser. | |
| 195 | + | /// | |
| 196 | + | /// The real one is `crate::image::Choices::from_toml`, which is `pub(crate)` | |
| 197 | + | /// in a binary and therefore unreachable from an integration test. Rather | |
| 198 | + | /// than widen its visibility for a test — which would make an internal type | |
| 199 | + | /// part of the surface for no other reason — this asserts the same things | |
| 200 | + | /// through the same TOML crate at the same version, which is what the parse | |
| 201 | + | /// actually rests on. The enum-validation half is unit-tested next to the | |
| 202 | + | /// real parser in `image.rs`. | |
| 203 | + | mod alloy_build_record { | |
| 204 | + | pub(crate) struct Record { | |
| 205 | + | pub(crate) profile: String, | |
| 206 | + | pub(crate) browser: String, | |
| 207 | + | pub(crate) langs: Vec<String>, | |
| 208 | + | pub(crate) hostname: String, | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | pub(crate) fn parse(raw: &str) -> Result<Record, String> { | |
| 212 | + | let doc: toml::Value = toml::from_str(raw).map_err(|err| err.to_string())?; | |
| 213 | + | ||
| 214 | + | let word = |key: &str| -> Result<String, String> { | |
| 215 | + | doc.get(key) | |
| 216 | + | .ok_or_else(|| format!("no `{key}` in the record"))? | |
| 217 | + | .as_str() | |
| 218 | + | .ok_or_else(|| format!("`{key}` is not a string")) | |
| 219 | + | .map(str::to_string) | |
| 220 | + | }; | |
| 221 | + | ||
| 222 | + | let langs = doc | |
| 223 | + | .get("langs") | |
| 224 | + | .ok_or("no `langs` in the record")? | |
| 225 | + | .as_array() | |
| 226 | + | .ok_or("`langs` is not an array")? | |
| 227 | + | .iter() | |
| 228 | + | .map(|value| { | |
| 229 | + | value | |
| 230 | + | .as_str() | |
| 231 | + | .map(str::to_string) | |
| 232 | + | .ok_or_else(|| "a language is not a string".to_string()) | |
| 233 | + | }) | |
| 234 | + | .collect::<Result<Vec<String>, String>>()?; | |
| 235 | + | ||
| 236 | + | Ok(Record { | |
| 237 | + | profile: word("profile")?, | |
| 238 | + | browser: word("browser")?, | |
| 239 | + | langs, | |
| 240 | + | hostname: word("hostname")?, | |
| 241 | + | }) | |
| 242 | + | } | |
| 243 | + | } |
| @@ -1,0 +1,177 @@ | |||
| 1 | + | //! The rule that makes `ARG PROFILE` affordable: no conditional skips a check. | |
| 2 | + | //! | |
| 3 | + | //! The Containerfile's correctness mechanism is that it asserts at build time | |
| 4 | + | //! what a config file merely assumes — that the session wrapper parses, that | |
| 5 | + | //! the polkit grant names actions that exist, that alloy-shot's tools are | |
| 6 | + | //! really in the image. Every one of those checks exists because its absence | |
| 7 | + | //! had already caused a silent failure. | |
| 8 | + | //! | |
| 9 | + | //! Splitting the image into `client` and `server` profiles put those checks | |
| 10 | + | //! behind `if [ "$PROFILE" = client ]`, and that is exactly the move the | |
| 11 | + | //! design brief (wiki `alloy-server-variant`) rejected the ARG shape for: a | |
| 12 | + | //! conditional assertion is one that can be skipped without a word, which is | |
| 13 | + | //! the failure mode the assertions were written to catch, reintroduced one | |
| 14 | + | //! level up. | |
| 15 | + | //! | |
| 16 | + | //! The answer is not to avoid conditionals but to forbid the silent half of | |
| 17 | + | //! one. **Every `$PROFILE` conditional asserts on both branches**: where | |
| 18 | + | //! `client` proves a thing is present and correct, `server` proves it is | |
| 19 | + | //! absent. No path through any of them verifies nothing. This file is what | |
| 20 | + | //! keeps that true, because it is a discipline nobody would notice breaking. | |
| 21 | + | //! | |
| 22 | + | //! It is a text check over the Containerfile, in the same spirit as | |
| 23 | + | //! `polkit_rules.rs` and `session_entry.rs`: cheap, runs on every `cargo | |
| 24 | + | //! test`, and catches the edit that a build would only catch on the profile | |
| 25 | + | //! nobody built that day. | |
| 26 | + | ||
| 27 | + | use std::path::PathBuf; | |
| 28 | + | ||
| 29 | + | fn containerfile() -> String { | |
| 30 | + | let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../Containerfile"); | |
| 31 | + | std::fs::read_to_string(&path) | |
| 32 | + | .unwrap_or_else(|err| panic!("cannot read {}: {err}", path.display())) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | /// Instructions, with line continuations joined and comments dropped. | |
| 36 | + | /// | |
| 37 | + | /// The Containerfile parser strips a `#` line inside a continuation before the | |
| 38 | + | /// shell ever sees it, so this has to do the same or the `else` it is looking | |
| 39 | + | /// for could be one that only exists in a comment. | |
| 40 | + | fn instructions(text: &str) -> Vec<String> { | |
| 41 | + | let mut out: Vec<String> = Vec::new(); | |
| 42 | + | let mut current = String::new(); | |
| 43 | + | ||
| 44 | + | for raw in text.lines() { | |
| 45 | + | let line = raw.trim_end(); | |
| 46 | + | let trimmed = line.trim_start(); | |
| 47 | + | ||
| 48 | + | // A comment line inside a continuation is removed by the parser and | |
| 49 | + | // does not end the continuation. | |
| 50 | + | if trimmed.starts_with('#') { | |
| 51 | + | continue; | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | if current.is_empty() && trimmed.is_empty() { | |
| 55 | + | continue; | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | if let Some(head) = line.strip_suffix('\\') { | |
| 59 | + | current.push_str(head.trim_start()); | |
| 60 | + | current.push(' '); | |
| 61 | + | continue; | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | current.push_str(trimmed); | |
| 65 | + | if !current.trim().is_empty() { | |
| 66 | + | out.push(current.trim().to_string()); | |
| 67 | + | } | |
| 68 | + | current = String::new(); | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | if !current.trim().is_empty() { | |
| 72 | + | out.push(current.trim().to_string()); | |
| 73 | + | } | |
| 74 | + | out | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | /// The rule. A `$PROFILE` test with no `else` is a check that silently does | |
| 78 | + | /// nothing on the other profile. | |
| 79 | + | #[test] | |
| 80 | + | fn every_profile_conditional_asserts_on_both_branches() { | |
| 81 | + | let text = containerfile(); | |
| 82 | + | let offenders: Vec<String> = instructions(&text) | |
| 83 | + | .into_iter() | |
| 84 | + | .filter(|instruction| instruction.contains("$PROFILE\" =")) | |
| 85 | + | .filter(|instruction| !instruction.contains("else")) | |
| 86 | + | .collect(); | |
| 87 | + | ||
| 88 | + | let listed = offenders.iter().fold(String::new(), |mut out, offender| { | |
| 89 | + | out.push_str(" "); | |
| 90 | + | out.push_str(&offender[..offender.len().min(160)]); | |
| 91 | + | out.push('\n'); | |
| 92 | + | out | |
| 93 | + | }); | |
| 94 | + | ||
| 95 | + | assert!( | |
| 96 | + | offenders.is_empty(), | |
| 97 | + | "these $PROFILE conditionals have no else branch, so one profile skips the check entirely:\n{listed}", | |
| 98 | + | ); | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | /// The conditionals are only trustworthy if the value they test is. An | |
| 102 | + | /// unvalidated `$PROFILE` means a typo falls through to `else` and builds | |
| 103 | + | /// something nobody asked for, on every one of those blocks at once. | |
| 104 | + | #[test] | |
| 105 | + | fn the_profile_value_is_validated_before_anything_reads_it() { | |
| 106 | + | let text = containerfile(); | |
| 107 | + | let instructions = instructions(&text); | |
| 108 | + | ||
| 109 | + | let validator = instructions | |
| 110 | + | .iter() | |
| 111 | + | .position(|i| i.contains("unknown PROFILE")) | |
| 112 | + | .expect("no assertion rejecting an unknown PROFILE value"); | |
| 113 | + | ||
| 114 | + | let first_use = instructions | |
| 115 | + | .iter() | |
| 116 | + | .position(|i| i.contains("$PROFILE\" =")) | |
| 117 | + | .expect("nothing reads $PROFILE"); | |
| 118 | + | ||
| 119 | + | assert!( | |
| 120 | + | validator < first_use, | |
| 121 | + | "the PROFILE validity check runs at instruction {validator}, after the first \ | |
| 122 | + | conditional at {first_use}; a typo would reach a branch before being rejected", | |
| 123 | + | ); | |
| 124 | + | ||
| 125 | + | // Both spellings have to be accepted, or one profile is unbuildable and | |
| 126 | + | // the validator is what would say so. | |
| 127 | + | let validating = &instructions[validator]; | |
| 128 | + | for profile in ["client", "server"] { | |
| 129 | + | assert!( | |
| 130 | + | validating.contains(profile), | |
| 131 | + | "the PROFILE validator does not accept `{profile}`", | |
| 132 | + | ); | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | /// `ARG PROFILE` has to default, or every existing `podman build` invocation | |
| 137 | + | /// and both build scripts start producing something other than what they did. | |
| 138 | + | #[test] | |
| 139 | + | fn the_profile_defaults_to_client() { | |
| 140 | + | let text = containerfile(); | |
| 141 | + | assert!( | |
| 142 | + | text.contains("ARG PROFILE=client"), | |
| 143 | + | "PROFILE must default to client so an unqualified build is the desktop, as it always was", | |
| 144 | + | ); | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | /// The console reads this to decide whether to offer the verbs that need a | |
| 148 | + | /// compositor, so the build has to write it. | |
| 149 | + | #[test] | |
| 150 | + | fn the_image_records_its_own_profile() { | |
| 151 | + | let text = containerfile(); | |
| 152 | + | assert!( | |
| 153 | + | text.contains("/usr/lib/alloy/profile"), | |
| 154 | + | "the build does not record the profile where the console can read it", | |
| 155 | + | ); | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | /// The one thing the brief insists survives on a headless machine: the bare | |
| 159 | + | /// console palette is the only themed surface there, so a prune that took it | |
| 160 | + | /// would take the whole design system with it. | |
| 161 | + | #[test] | |
| 162 | + | fn the_server_prune_keeps_the_console_palette() { | |
| 163 | + | let text = containerfile(); | |
| 164 | + | let pruning = instructions(&text) | |
| 165 | + | .into_iter() | |
| 166 | + | .find(|i| i.contains("/etc/skel/.config/sway") && i.contains("rm -rf")) | |
| 167 | + | .expect("no server prune of the desktop skeleton"); | |
| 168 | + | ||
| 169 | + | assert!( | |
| 170 | + | pruning.contains("test -s /usr/share/alloy/vtrgb"), | |
| 171 | + | "the server prune does not assert the console palette survived it", | |
| 172 | + | ); | |
| 173 | + | assert!( | |
| 174 | + | !pruning.contains("rm -rf /usr/share/alloy/vtrgb"), | |
| 175 | + | "the server prune removes the console palette", | |
| 176 | + | ); | |
| 177 | + | } |