Skip to main content

max / alloy

Answer before building what a build would answer slowly Four mints in a row on 2026-09-04 each found one defect and were thrown away. Three were answerable without installing anything; the fourth was not, and is written down as the limit rather than left to be found. Every assertion Alloy had lived inside the Containerfile, so running one cost a build up to that step, and bootc container lint is step 101 of 103 -- a single undeclared file in /var costs the whole image. The checks were not missing. They were in the most expensive place. Four checks. dials mirrors the Containerfile's own validator and already caught a real one: build/hosts/astra.env set PROFILE=server and never set BROWSER, so the ARG default of firefox applied and server:firefox is what that validator refuses. astra's medium has never been built, so nothing had exercised the recipe. requires is the first place in the tree that says what a host's ROLE needs rather than which dials its mint sets -- the distinction today's findings turn on, since every dial was correct and the image still could not build a Tauri app or restore a prod dump. var-payload asks bootc. guards assert a known workaround is still there, which is the only check here about regression: NO_STRIP lived in build-dist.sh, the move to Bento recipes dropped it, and a build rediscovered it months later. The last two run against a probe image rather than a prediction. The first version predicted both from repoquery and both were wrong the same way: var-payload derived bootc's rule from the symptom and flagged paths the real build accepts, and requires compared providers against named packages, so bin:git failed because /usr/bin/git comes from git-core. Building the smallest image that can be asked directly removes both. It is a build, which is what this exists to avoid, but it is one dnf transaction rather than 103 steps, and it is right rather than close. Four blind spots are stated in the files rather than left to be discovered: /var content that arrives from something other than a package, packages from third-party repos the probe does not configure, COPR packages that do not resolve in a bare base, and runtime interactions like linuxdeploy's strip that no static check reaches. Validated by reverting the day's two static fixes: it reports the missing pg_trgm and pgcrypto, and bootc reports the pgsql directories with the exact tmpfiles lines to add.
Author: Max Johnson <me@maxj.phd> · 2026-09-04 20:10 UTC
Signed with PGP, not checked
Commit: b99edf89de3b87286c472d8fbd82b93d7c8a2505
Parent: 23ebab6
5 files changed, +561 insertions, -1 deletion
M Containerfile +12 -1
@@ -2138,7 +2138,7 @@
2138 2138 case "$DB" in \
2139 2139 postgres16) \
2140 2140 dnf install -y --setopt=install_weak_deps=False \
2141 - postgresql16 postgresql16-server; \
2141 + postgresql16 postgresql16-server postgresql16-contrib; \
2142 2142 dnf clean all; \
2143 2143 command -v psql >/dev/null \
2144 2144 || { echo "DB=postgres16 was asked for and psql is not in the image" >&2; exit 1; }; \
@@ -2146,6 +2146,17 @@
2146 2146 || { echo "DB=postgres16 was asked for and postgres is not in the image" >&2; exit 1; }; \
2147 2147 psql --version | grep -q " 16\." \
2148 2148 || { echo "DB=postgres16 installed $(psql --version), which is not the major production runs" >&2; exit 1; }; \
2149 + # The extensions a production dump declares, without which a restore
2150 + # stops at the first CREATE EXTENSION and Sando's migration_check and
2151 + # cargo_test gates cannot run at all. Measured 2026-09-04 against
2152 + # /srv/sando/backups: the server dump needs pgcrypto and pg_trgm, the
2153 + # multithreaded one pg_trgm. They ship in postgresql16-contrib, and the
2154 + # unversioned postgresql-contrib is 18.6 on this base, which is the
2155 + # wrong major and the trap this asserts against.
2156 + for ext in pgcrypto pg_trgm; do \
2157 + find / -name "$ext.control" -path "*/extension/*" 2>/dev/null | grep -q . \
2158 + || { echo "DB=postgres16 was asked for and $ext.control is not in the image; a prod dump would fail to restore" >&2; exit 1; }; \
2159 + done; \
2149 2160 # The RPM's %post leaves a regular file in /var, and /var is per-machine
2150 2161 # state on a bootc system: `bootc container lint` fails the build on a
2151 2162 # non-directory there, because tmpfiles.d has no type that describes an
@@ -21,3 +21,10 @@
21 21 # astra builds the arm64 releases, so it needs the same desktop-app set fw13
22 22 # does. See build/hosts/fw13.env for why this is native rather than boxed.
23 23 GUI=tauri
24 +
25 + # The server profile ships no graphical session, and the Containerfile's
26 + # validator refuses `server:firefox` outright. BROWSER defaults to firefox, so
27 + # leaving it unset is not "no browser" -- it is a mint that fails at the
28 + # validator. Found by build/preflight.sh on 2026-09-04, before astra's medium
29 + # had ever been built for the first time (alloy `2341d23f`).
30 + BROWSER=none
@@ -1,0 +1,49 @@
1 + # What fw13's ROLE needs, as opposed to which dials its mint sets.
2 + #
3 + # build/hosts/fw13.env says LANGS=rust,js DB=postgres16 GUI=tauri. That is the
4 + # input. This file is the output: the capabilities that have to exist on the
5 + # other side, checked by build/preflight.sh against what those dials install.
6 + #
7 + # The distinction is the whole point. Every dial was set correctly on 2026-09-04
8 + # and the image still could not build a Tauri app or restore a prod dump, because
9 + # nothing anywhere said those were things fw13 has to be able to do.
10 + #
11 + # Syntax: bin:<name>, pkgconfig:<module>, file:<glob>.
12 +
13 + # Four roles ride on this machine (see fw13.env): Bento's x86_64 builder, Bento's
14 + # controller for astra/mbp/windows-x86, the crates.io publish host, and Sando's
15 + # controller with the PostgreSQL its gates restore into.
16 +
17 + # --- the tree's own builds
18 + bin:cargo
19 + bin:rustc
20 + bin:gcc # arrives with rust; a mint without it has no C toolchain
21 + bin:git # the publish recipe runs `git describe --exact-match`
22 +
23 + # --- Sando's code_smoke gate builds two TypeScript frontends
24 + bin:node
25 + bin:npm
26 +
27 + # --- Sando's migration_check and cargo_test restore a prod dump over a socket.
28 + # The extensions are not optional: a restore stops at the first CREATE EXTENSION
29 + # it cannot satisfy. Measured against /srv/sando/backups on 2026-09-04 — the
30 + # server dump declares pgcrypto and pg_trgm, the multithreaded one pg_trgm.
31 + bin:psql
32 + bin:postgres
33 + bin:initdb
34 + file:*/pgcrypto.control
35 + file:*/pg_trgm.control
36 +
37 + # --- the four Tauri apps. webkit2gtk is the one that matters; the rest are what
38 + # cargo-tauri and linuxdeploy reach for. No Alloy image shipped any of them until
39 + # the GUI dial existed.
40 + pkgconfig:webkit2gtk-4.1
41 + pkgconfig:gtk+-3.0
42 + pkgconfig:libsoup-3.0
43 + pkgconfig:librsvg-2.0
44 + pkgconfig:openssl
45 +
46 + # --- the fleet control plane. ops-exec spawns the binary by name, so a machine
47 + # without it cannot collect a build from another host or hand a release over.
48 + bin:rsync
49 + bin:ssh
@@ -1,0 +1,248 @@
1 + #!/usr/bin/env python3
2 + """Answer, without building, the questions a build would otherwise answer slowly.
3 +
4 + Called by build/preflight.sh; see that file's header for why this exists.
5 +
6 + Three checks, in the order they were learned the expensive way on 2026-09-04:
7 +
8 + var-payload Every package this mint installs, asked for its /var content
9 + with `repoquery -l`, diffed against the tmpfiles.d declarations.
10 + `bootc container lint` fails a build on undeclared /var content,
11 + and it is step 101 of 103, so this class costs a whole image.
12 +
13 + requires The capabilities the host's ROLE needs, resolved with
14 + `repoquery --whatprovides` and checked against what the
15 + Containerfile installs. The recipes say which dials to set; they
16 + have never said what has to come out the other side.
17 +
18 + guards Known workarounds, asserted to still be present. The only check
19 + here that is about regression rather than absence: NO_STRIP
20 + lived in _private/scripts/build-dist.sh, the move to Bento
21 + recipes dropped it, and a build rediscovered it months later.
22 +
23 + The parser is deliberately dial-aware rather than approximate. A check that
24 + reports a package this host does not install is a false failure, and a false
25 + failure is how an instrument stops being read -- the same rule build/check-host.sh
26 + states about SKIP.
27 + """
28 +
29 + import os
30 + import re
31 + import subprocess
32 + import sys
33 +
34 + REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
35 +
36 + # /var paths that are the build host's leavings rather than anything a running
37 + # machine needs. The Containerfile deletes these before the lint, so they are
38 + # never in the built image and must not be reported as undeclared.
39 + # Packages the real build installs from a repo the probe does not have. The
40 + # Containerfile adds Tailscale's own repo and Terra/COPRs before it installs; a
41 + # bare base resolves some of those names to a DIFFERENT build of the same
42 + # software, with a different payload. Measured 2026-09-04: the probe's tailscale
43 + # ships /var/lib/tailscale and the real image has no such directory, so leaving
44 + # it in produced a var-payload finding against a mint that passes bootc lint.
45 + #
46 + # Excluded and reported rather than installed, so the probe is narrower than the
47 + # real build and never wrong about it. A false finding is worse than a missing
48 + # one here: the whole argument for this script is that it can be trusted without
49 + # a build to check it.
50 + THIRD_PARTY = (
51 + "tailscale",
52 + "terra-release",
53 + "swayosd", "satty", "cliphist", "starship", "swww", "yazi",
54 + "bibata-cursor-theme", "bottom",
55 + )
56 +
57 + VAR_IGNORE = (
58 + "/var/cache/dnf", "/var/lib/dnf", "/var/log", "/var/tmp", "/var/run",
59 + "/var/lib/rpm", "/var/lib/authselect", "/var/cache/ldconfig",
60 + )
61 +
62 +
63 + def run(cmd, **kw):
64 + return subprocess.run(cmd, capture_output=True, text=True, **kw)
65 +
66 +
67 + def read_recipe(host):
68 + """The dials, read the way build/host-recipe.sh reads them."""
69 + path = os.path.join(REPO, "build", "hosts", host + ".env")
70 + if not os.path.exists(path):
71 + sys.exit("preflight: no recipe at %s" % path)
72 + dials = {}
73 + for line in open(path):
74 + line = line.split("#", 1)[0].strip()
75 + if not line or "=" not in line:
76 + continue
77 + k, v = line.split("=", 1)
78 + dials[k.strip()] = v.strip()
79 + # The Containerfile's own defaults, for a dial the recipe does not set.
80 + dials.setdefault("PROFILE", "client")
81 + dials.setdefault("BROWSER", "firefox")
82 + dials.setdefault("LANGS", "")
83 + dials.setdefault("DB", "none")
84 + dials.setdefault("GUI", "none")
85 + return dials
86 +
87 +
88 + def install_sites(dials):
89 + """Package names this mint installs, with the dial arm that guards each.
90 +
91 + The Containerfile is joined into logical lines first. A `RUN` is one command
92 + however many backslashes it spans, so a `case` statement and every arm inside
93 + it arrive together; a parser that read physical lines would see the package
94 + list of the base block (one name per line) as no packages at all.
95 +
96 + Comment-only lines are dropped before joining, which is what podman's own
97 + parser does with them inside a continuation -- that is why the file can carry
98 + a comment between two package names at all.
99 +
100 + Attribution is by the innermost enclosing `case "$VAR" in` and the arm label
101 + in force, so a package inside `postgres16)` is dropped when the recipe says
102 + DB=none. Approximating this instead would report packages the mint does not
103 + install, and a false failure is how an instrument stops being read.
104 + """
105 + logical, buf = [], ""
106 + for raw in open(os.path.join(REPO, "Containerfile")):
107 + line = raw.rstrip("\n")
108 + if line.strip().startswith("#"):
109 + continue
110 + if line.rstrip().endswith("\\"):
111 + buf += line.rstrip()[:-1] + " "
112 + continue
113 + logical.append(buf + line)
114 + buf = ""
115 + if buf:
116 + logical.append(buf)
117 +
118 + pkgs = {}
119 + for line in logical:
120 + case_stack, arm = [], None
121 + for frag in re.split(r'[;]', line):
122 + f = frag.strip()
123 + if not f:
124 + continue
125 + m = re.search(r'case\s+"\$(\w+)"\s+in', f)
126 + if m:
127 + case_stack.append(m.group(1))
128 + arm = None
129 + # The opening `case`, its first arm label and that arm's `dnf
130 + # install` all land in one fragment, because nothing separates
131 + # them with a semicolon. Anchoring the arm pattern at the start
132 + # of the fragment finds it for every arm except the first, which
133 + # is how GUI=tauri and DB=postgres16 both read as unguarded and
134 + # then got dropped.
135 + rest = f[m.end():].strip()
136 + m2 = re.match(r'^([\w|]+)\)', rest)
137 + if m2:
138 + arm = m2.group(1)
139 + else:
140 + m2 = re.match(r'^([\w|]+)\)', f)
141 + if m2 and case_stack:
142 + arm = m2.group(1)
143 + idx = f.find("dnf install")
144 + # `dnf install` also appears inside error messages the Containerfile
145 + # prints, e.g. "...a dnf install that moved above the layer setting
146 + # it." Reading that as a command turns its prose into package names,
147 + # which is how `above`, `moved`, `layer` and `setting` ended up in a
148 + # probe's install list. An odd number of quotes before the match
149 + # means the match is inside a string.
150 + if idx >= 0 and f[:idx].count('"') % 2 == 0:
151 + tail = f[idx + len("dnf install"):]
152 + tail = re.split(r'&&|\|\|', tail)[0]
153 + guard = "base" if not case_stack else "%s=%s" % (case_stack[-1], arm)
154 + for tok in tail.split():
155 + if tok.startswith("-") or tok.startswith("$"):
156 + continue
157 + if not re.match(r'^[A-Za-z0-9][A-Za-z0-9._+-]*$', tok):
158 + continue
159 + pkgs.setdefault(tok, guard)
160 + if re.search(r'\besac\b', f):
161 + if case_stack:
162 + case_stack.pop()
163 + arm = None
164 +
165 + selected = {}
166 + for pkg, guard in pkgs.items():
167 + if guard == "base":
168 + selected[pkg] = guard
169 + continue
170 + var, want = guard.split("=", 1)
171 + # The language block loops `for lang in $(echo "$LANGS" | tr ',' ' ')`
172 + # and switches on `$lang`, so the guard names the loop variable rather
173 + # than the dial. Without this every language package reads as unselected.
174 + if var == "lang":
175 + var = "LANGS"
176 + have = dials.get(var, "")
177 + values = [v.strip() for v in have.split(",")] if var == "LANGS" else [have]
178 + if any(v in (want or "").split("|") for v in values):
179 + selected[pkg] = guard
180 + return selected
181 +
182 +
183 + def check_dials(dials):
184 + """The Containerfile's own validator, run without building to reach it.
185 +
186 + That validator is inside the image build, so a recipe that cannot pass it
187 + fails around step 20 of 103 -- cheap as builds go, and still minutes to learn
188 + something answerable in milliseconds. Found by this check on 2026-09-04:
189 + build/hosts/astra.env set PROFILE=server and never set BROWSER, so the ARG
190 + default of firefox applied and `server:firefox` is exactly what the validator
191 + refuses. No astra mint had ever been attempted, so nothing had exercised it.
192 +
193 + Kept deliberately narrow: it mirrors rules the Containerfile states, and a
194 + rule that moves there has to move here. A second copy of a check is a
195 + liability, so this stays a short list rather than growing into a schema.
196 + """
197 + print("== dials: the recipe against the Containerfile's own validator")
198 + bad = 0
199 + profile, browser = dials.get("PROFILE"), dials.get("BROWSER")
200 + if profile == "server" and browser != "none":
201 + bad += 1
202 + print(" FAIL PROFILE=server with BROWSER=%s" % browser)
203 + print(" the server profile ships no graphical session and the")
204 + print(" validator refuses it. Set BROWSER=none in the recipe.")
205 + for key, allowed in (("PROFILE", ("client", "server")),
206 + ("DB", ("none", "postgres16")),
207 + ("GUI", ("none", "tauri")),
208 + ("BROWSER", ("firefox", "none")),
209 + ("TRIM", ("unused", "keep"))):
210 + v = dials.get(key)
211 + if v is not None and v not in allowed:
212 + bad += 1
213 + print(" FAIL %s=%s is not one of %s" % (key, v, ", ".join(allowed)))
214 + for lang in [x.strip() for x in dials.get("LANGS", "").split(",") if x.strip()]:
215 + if lang not in ("rust", "go", "python", "zig", "js"):
216 + bad += 1
217 + print(" FAIL LANGS names %r, which the builder does not offer" % lang)
218 + if not bad:
219 + print(" ok: every dial is a value the validator accepts")
220 + return 1 if bad else 0
221 +
222 +
223 + def main():
224 + if len(sys.argv) != 2:
225 + sys.exit("usage: preflight.py <host>")
226 + host = sys.argv[1]
227 + dials = read_recipe(host)
228 + print("preflight for %s: %s\n" % (host, " ".join("%s=%s" % kv for kv in sorted(dials.items()))))
229 + pkgs = install_sites(dials)
230 + print("%d package(s) this mint installs\n" % len(pkgs))
231 + # Machine-readable, for build/preflight.sh's probe image. Emitted rather than
232 + # recomputed in shell so there is one parser for the Containerfile, not two.
233 + probe_set = sorted(p for p in pkgs if p not in THIRD_PARTY)
234 + skipped = sorted(p for p in pkgs if p in THIRD_PARTY)
235 + if skipped:
236 + print("NOT-PROBED: %s" % " ".join(skipped))
237 + print("PKGSET: %s" % " ".join(probe_set))
238 + print("TMPFILES-DROP: %s" % " ".join(
239 + ([] if dials.get("PROFILE") == "client" else ["50-alloy-var-client.conf"])
240 + + ([] if dials.get("DB", "none") != "none" else ["50-alloy-var-postgres.conf"])))
241 +
242 + rc = 0
243 + rc |= check_dials(dials)
244 + return rc
245 +
246 +
247 + if __name__ == "__main__":
248 + raise SystemExit(main())
@@ -1,0 +1,245 @@
1 + #!/usr/bin/env bash
2 + #
3 + # preflight.sh — answer, in seconds, the questions a mint would answer in minutes.
4 + #
5 + # build/preflight.sh fw13 # check fw13's recipe before building it
6 + #
7 + # WHY THIS EXISTS. Every assertion Alloy has lives inside the Containerfile, so
8 + # running one costs a build up to that step, and `bootc container lint` is step
9 + # 101 of 103 — a single undeclared file in /var costs the whole image. On
10 + # 2026-09-04 four mints in a row each found one defect and were thrown away.
11 + # Three of those four were answerable with `dnf repoquery` against the same
12 + # repos, without installing anything:
13 + #
14 + # undeclared /var content repoquery -l postgresql16-server
15 + # no webkit repoquery --whatprovides 'pkgconfig(webkit2gtk-4.1)'
16 + # no pg_trgm repoquery --whatprovides '*/pg_trgm.control'
17 + #
18 + # The fourth was not, and is the honest limit of this script: linuxdeploy bundles
19 + # its own binutils and cannot read Fedora 43's `.relr.dyn` sections, which is a
20 + # runtime interaction no static check finds. What WAS catchable there is that the
21 + # workaround had existed before and was lost, which is what the `guards` check is.
22 + #
23 + # It runs in a container from the base image rather than on the dev host, because
24 + # the questions are about Fedora's repos and fw13 is Pop. That also means it
25 + # needs no dnf, no repo config and no Fedora anything on the machine running it.
26 + #
27 + # Not a substitute for the Containerfile's own assertions. Those run against the
28 + # built image and prove what IS; this runs against the repos and predicts what
29 + # WOULD BE. Both are wanted: this one is fast and can be wrong, that one is slow
30 + # and cannot.
31 +
32 + set -euo pipefail
33 +
34 + REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
35 + . "$REPO_ROOT/build/privilege.sh"
36 +
37 + HOST="${1:-}"
38 + [ -n "$HOST" ] || {
39 + printf 'usage: build/preflight.sh <host>\n' >&2
40 + printf 'known hosts: %s\n' "$(cd "$REPO_ROOT/build/hosts" && ls -1 ./*.env | sed 's|^\./||; s|\.env$||' | tr '\n' ' ')" >&2
41 + exit 2
42 + }
43 + [ -f "$REPO_ROOT/build/hosts/$HOST.env" ] || {
44 + printf 'preflight: no recipe for host %s\n' "$HOST" >&2
45 + exit 2
46 + }
47 +
48 + # The base the Containerfile builds FROM, so repoquery sees the same repos the
49 + # build will. Pinned by digest there; read it back rather than repeating it, so
50 + # this cannot drift from what is actually built.
51 + BASE="$(grep -m1 -oE 'registry\.fedoraproject\.org/fedora-bootc:[0-9]+@sha256:[0-9a-f]+' "$REPO_ROOT/Containerfile")"
52 + [ -n "$BASE" ] || { echo "preflight: could not read the base image from the Containerfile" >&2; exit 1; }
53 +
54 + # `privc`, not `priv`: this output is read as a report and captured in CI, and
55 + # run0 without --pipe puts its child on a pty, which staples a carriage return to
56 + # every line. Not `exec`, because privc is a shell function.
57 + out="$(mktemp)"; probe=""
58 + cleanup() { rm -f "$out"; [ -n "$probe" ] && rm -rf "$probe"; }
59 + trap cleanup EXIT
60 +
61 + # `set -e` with `pipefail` would end the script the moment the resolver reports a
62 + # finding, which is the one case this exists for. Findings are data here, not
63 + # errors, so the status is read rather than acted on.
64 + set +e
65 + privc podman run --rm \
66 + -v "$REPO_ROOT:/repo:ro" \
67 + --entrypoint python3 \
68 + "$BASE" /repo/build/preflight.py "$HOST" | tee "$out"
69 + rc="${PIPESTATUS[0]}"
70 + set -e
71 +
72 + # ------------------------------------------------------------------ the probe ---
73 + # The remaining two checks are answered by a probe image rather than predicted.
74 + #
75 + # The first version predicted both from `dnf repoquery`, and both were wrong in
76 + # the same way. var-payload derived bootc's rule from the symptom and reported
77 + # paths the real build accepts. requires compared a capability's provider against
78 + # the packages the Containerfile names, so `bin:git` failed because /usr/bin/git
79 + # comes from `git-core` while the recipe installs `git` -- a false failure, and a
80 + # check that cries wolf stops being read.
81 + #
82 + # Both go away by building the smallest image that can be asked directly: this
83 + # mint's package set plus the tmpfiles files it would ship. `command -v` and
84 + # `bootc container lint` then observe rather than predict. It is a build, which
85 + # is what this script exists to avoid, but it is one dnf transaction rather than
86 + # 103 steps, and it is right rather than close.
87 + #
88 + # WHAT THE PROBE CANNOT SEE, stated here rather than discovered later: anything
89 + # that reaches /var from something other than a package. The real Containerfile
90 + # copies etc/ and usr/ and runs its own steps, and any of those could leave a
91 + # file the probe never carries. The class it does catch is the one that has
92 + # actually bitten -- a package's %post leaving content behind.
93 + if [ "${2:-}" = "--fast" ]; then
94 + echo
95 + echo "== probe: skipped (--fast); dials and guards only"
96 + else
97 + pkgset="$(sed -n 's/^PKGSET: //p' "$out")"
98 + drop="$(sed -n 's/^TMPFILES-DROP: //p' "$out")"
99 + if [ -z "$pkgset" ]; then
100 + echo; echo "== probe: SKIP no package set came back from the resolver"
101 + else
102 + probe="$(mktemp -d)"
103 + mkdir -p "$probe/tmpfiles"
104 + cp "$REPO_ROOT"/usr/lib/tmpfiles.d/*.conf "$probe/tmpfiles/"
105 + for f in $drop; do rm -f "$probe/tmpfiles/$f"; done
106 + if [ -f "$REPO_ROOT/build/hosts/$HOST.requires" ]; then
107 + cp "$REPO_ROOT/build/hosts/$HOST.requires" "$probe/requires"
108 + else
109 + : > "$probe/requires"
110 + fi
111 +
112 + # Run inside the probe: what the role needs, asked of a filesystem.
113 + cat > "$probe/check-requires.sh" <<'CHECK'
114 + #!/bin/sh
115 + # Each requirement, asked of the image rather than of the repos.
116 + bad=0; any=0
117 + while IFS= read -r line; do
118 + line="${line%%#*}"
119 + line="$(printf '%s' "$line" | tr -d '[:space:]')"
120 + [ -n "$line" ] || continue
121 + any=1
122 + kind="${line%%:*}"; value="${line#*:}"
123 + case "$kind" in
124 + bin) command -v "$value" >/dev/null 2>&1 ;;
125 + pkgconfig) pkg-config --exists "$value" 2>/dev/null ;;
126 + file) find / -path "$value" -print -quit 2>/dev/null | grep -q . ;;
127 + *) printf ' SKIP %-34s unknown requirement kind
128 + ' "$line"; continue ;;
129 + esac
130 + if [ $? -eq 0 ]; then
131 + printf ' ok %s
132 + ' "$line"
133 + else
134 + printf ' FAIL %s
135 + ' "$line"
136 + # Only now is repoquery worth the wait, and only for what is missing.
137 + case "$kind" in
138 + bin) q="/usr/bin/$value" ;;
139 + pkgconfig) q="pkgconfig($value)" ;;
140 + file) q="$value" ;;
141 + esac
142 + c="$(dnf -q repoquery --qf '%{name}
143 + ' --whatprovides "$q" 2>/dev/null | sort -u | head -4 | tr '
144 + ' ' ')"
145 + [ -n "$c" ] && printf ' add one of: %s
146 + ' "$c"
147 + bad=1
148 + fi
149 + done < /requires
150 + [ "$any" = 1 ] || echo " SKIP this host declares no role contract"
151 + exit "$bad"
152 + CHECK
153 + chmod +x "$probe/check-requires.sh"
154 +
155 + cat > "$probe/Containerfile" <<PROBE
156 + FROM $BASE
157 + RUN set -eux; dnf install -y --skip-unavailable --setopt=install_weak_deps=False $pkgset; \\
158 + dnf clean all; \\
159 + rm -f /var/log/dnf5.log*; \\
160 + rm -rf /var/lib/dnf /var/cache/libdnf5; \\
161 + rm -f /var/cache/ldconfig/aux-cache; \\
162 + rm -f /var/cache/swcatalog/cache/*.xb; \\
163 + rm -f /var/lib/authselect/checksum
164 + COPY tmpfiles/ /usr/lib/tmpfiles.d/
165 + COPY requires /requires
166 + COPY check-requires.sh /check-requires.sh
167 + PROBE
168 +
169 + echo
170 + echo "== probe: building the smallest image that can answer (one dnf transaction)"
171 + if ! privc podman build -q -t localhost/alloy-preflight-probe:"$HOST" "$probe" >/dev/null 2>"$probe/build.err"; then
172 + echo " SKIP the probe would not build:"
173 + sed 's/^/ /' "$probe/build.err" | tail -6
174 + rc=1
175 + else
176 + # Packages the base repos cannot see. The real build enables Terra
177 + # and several COPRs before it installs; the probe does not, so those
178 + # are absent here and their absence is the probe's blind spot rather
179 + # than a finding. Named, so nobody reads a clean probe as covering
180 + # more than it does. --skip-unavailable is what keeps one of them
181 + # from failing the whole transaction.
182 + missing="$(privc podman run --rm --entrypoint sh localhost/alloy-preflight-probe:"$HOST" -c \
183 + 'for p in '"$pkgset"'; do rpm -q --whatprovides "$p" >/dev/null 2>&1 || echo "$p"; done' 2>/dev/null | tr '\n' ' ')"
184 + notprobed="$(sed -n 's/^NOT-PROBED: //p' "$out")"
185 + [ -n "$notprobed" ] && missing="$missing$notprobed"
186 + if [ -n "$missing" ]; then
187 + echo
188 + echo "== probe coverage: not installed here, so not checked"
189 + printf ' %s\n' "$missing" | fold -s -w 76 | sed 's/^/ /'
190 + echo " (the real build enables Terra and COPRs first; the probe does not)"
191 + fi
192 +
193 + echo
194 + echo "== requires: what this host's role needs, asked of the image"
195 + set +e
196 + privc podman run --rm --entrypoint sh localhost/alloy-preflight-probe:"$HOST" /check-requires.sh
197 + [ $? -eq 0 ] || rc=1
198 + set -e
199 +
200 + echo
201 + echo "== var-payload: bootc's own lint, on that probe"
202 + lint="$(privc podman run --rm --entrypoint bootc localhost/alloy-preflight-probe:"$HOST" \
203 + container lint --no-truncate 2>&1 || true)"
204 + if printf '%s' "$lint" | grep -q 'var-tmpfiles'; then
205 + printf '%s\n' "$lint" | sed -n '/var-tmpfiles/,/^$/p' | sed 's/^/ /'
206 + echo " FAIL bootc would refuse this mint's /var; declare the paths above"
207 + rc=1
208 + else
209 + echo " ok: bootc finds nothing undeclared in /var for this package set"
210 + fi
211 + fi
212 + fi
213 + fi
214 +
215 + # The guards run out here rather than in the container, because they read sibling
216 + # repos under ~/Code that the container has no business mounting. They are plain
217 + # greps and need no Fedora anything.
218 + #
219 + # This is the only check about regression rather than absence. NO_STRIP lived in
220 + # _private/scripts/build-dist.sh, the move to Bento recipes dropped it, and a
221 + # build rediscovered it months later: linuxdeploy bundles its own binutils and
222 + # cannot read Fedora 43's `.relr.dyn` sections, so without it every AppImage
223 + # built on an Alloy host fails at bundling with an error naming nothing useful.
224 + echo
225 + echo "== guards: known workarounds, asserted to still be there"
226 + tree_root="$(cd "$REPO_ROOT/.." && pwd)"
227 + guards_bad=0
228 + for rel in Apps/goingson/dist/recipes/linux.rhai Apps/balanced_breakfast/dist/recipes/linux.rhai; do
229 + f="$tree_root/$rel"
230 + if [ ! -f "$f" ]; then
231 + printf ' SKIP %-52s not on this machine\n' "$rel"
232 + elif grep -q 'NO_STRIP' "$f"; then
233 + printf ' ok %-52s carries NO_STRIP\n' "$rel"
234 + else
235 + printf ' FAIL %-52s lost NO_STRIP; every AppImage built on Alloy would fail at bundling\n' "$rel"
236 + guards_bad=1
237 + fi
238 + done
239 +
240 + if [ "$rc" -eq 0 ] && [ "$guards_bad" -eq 0 ]; then
241 + echo; echo "preflight: clean"
242 + exit 0
243 + fi
244 + echo; echo "preflight: findings above; fix them before building"
245 + exit 1