Skip to main content

max / alloy

13.1 KB · 287 lines History Blame Raw
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 # build/preflight.sh fw13 --fast # the instant checks only, no probe
7 #
8 # Exit codes, and the split matters because both builders gate on it:
9 #
10 # 0 clean
11 # 1 findings about the image; do not build
12 # 2 usage: no host named, or no recipe by that name
13 # 4 findings about this host alone; the medium is fine
14 #
15 # See the comment above the exit at the bottom for why those are two codes.
16 #
17 # WHY THIS EXISTS. Every assertion Alloy has lives inside the Containerfile, so
18 # running one costs a build up to that step, and `bootc container lint` is step
19 # 101 of 103 — a single undeclared file in /var costs the whole image. On
20 # 2026-09-04 four mints in a row each found one defect and were thrown away.
21 # Three of those four were answerable with `dnf repoquery` against the same
22 # repos, without installing anything:
23 #
24 # undeclared /var content repoquery -l postgresql16-server
25 # no webkit repoquery --whatprovides 'pkgconfig(webkit2gtk-4.1)'
26 # no pg_trgm repoquery --whatprovides '*/pg_trgm.control'
27 #
28 # The fourth was not, and is the honest limit of this script: linuxdeploy bundles
29 # its own binutils and cannot read Fedora 43's `.relr.dyn` sections, which is a
30 # runtime interaction no static check finds. What WAS catchable there is that the
31 # workaround had existed before and was lost, which is what the `guards` check is.
32 #
33 # It runs in a container from the base image rather than on the dev host, because
34 # the questions are about Fedora's repos and fw13 is Pop. That also means it
35 # needs no dnf, no repo config and no Fedora anything on the machine running it.
36 #
37 # Not a substitute for the Containerfile's own assertions. Those run against the
38 # built image and prove what IS; this runs against the repos and predicts what
39 # WOULD BE. Both are wanted: this one is fast and can be wrong, that one is slow
40 # and cannot.
41
42 set -euo pipefail
43
44 REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
45 . "$REPO_ROOT/build/privilege.sh"
46
47 HOST="${1:-}"
48 [ -n "$HOST" ] || {
49 printf 'usage: build/preflight.sh <host>\n' >&2
50 printf 'known hosts: %s\n' "$(cd "$REPO_ROOT/build/hosts" && ls -1 ./*.env | sed 's|^\./||; s|\.env$||' | tr '\n' ' ')" >&2
51 exit 2
52 }
53 [ -f "$REPO_ROOT/build/hosts/$HOST.env" ] || {
54 printf 'preflight: no recipe for host %s\n' "$HOST" >&2
55 exit 2
56 }
57
58 # The base the Containerfile builds FROM, so repoquery sees the same repos the
59 # build will. Pinned by digest there; read it back rather than repeating it, so
60 # this cannot drift from what is actually built.
61 BASE="$(grep -m1 -oE 'registry\.fedoraproject\.org/fedora-bootc:[0-9]+@sha256:[0-9a-f]+' "$REPO_ROOT/Containerfile")"
62 [ -n "$BASE" ] || { echo "preflight: could not read the base image from the Containerfile" >&2; exit 1; }
63
64 # `privc`, not `priv`: this output is read as a report and captured in CI, and
65 # run0 without --pipe puts its child on a pty, which staples a carriage return to
66 # every line. Not `exec`, because privc is a shell function.
67 out="$(mktemp)"; probe=""
68 # The `|| :` is load-bearing, and its absence was a bug this script carried from
69 # the start. bash lets an EXIT trap set the script's exit status, and
70 # `[ -n "$probe" ] && rm -rf "$probe"` ends on a false test whenever the probe
71 # was never built -- which is every --fast run. So a clean --fast run printed
72 # "preflight: clean" and exited 1. Invisible while nothing read the code, and
73 # fatal the moment the builders started gating on it: --fast is exactly what a
74 # cross-architecture mint runs, so astra's every mint would have been refused.
75 cleanup() { rm -f "$out"; [ -n "$probe" ] && rm -rf "$probe"; :; }
76 trap cleanup EXIT
77
78 # `set -e` with `pipefail` would end the script the moment the resolver reports a
79 # finding, which is the one case this exists for. Findings are data here, not
80 # errors, so the status is read rather than acted on.
81 set +e
82 privc podman run --rm \
83 -v "$REPO_ROOT:/repo:ro" \
84 --entrypoint python3 \
85 "$BASE" /repo/build/preflight.py "$HOST" | tee "$out"
86 rc="${PIPESTATUS[0]}"
87 set -e
88
89 # ------------------------------------------------------------------ the probe ---
90 # The remaining two checks are answered by a probe image rather than predicted.
91 #
92 # The first version predicted both from `dnf repoquery`, and both were wrong in
93 # the same way. var-payload derived bootc's rule from the symptom and reported
94 # paths the real build accepts. requires compared a capability's provider against
95 # the packages the Containerfile names, so `bin:git` failed because /usr/bin/git
96 # comes from `git-core` while the recipe installs `git` -- a false failure, and a
97 # check that cries wolf stops being read.
98 #
99 # Both go away by building the smallest image that can be asked directly: this
100 # mint's package set plus the tmpfiles files it would ship. `command -v` and
101 # `bootc container lint` then observe rather than predict. It is a build, which
102 # is what this script exists to avoid, but it is one dnf transaction rather than
103 # 103 steps, and it is right rather than close.
104 #
105 # WHAT THE PROBE CANNOT SEE, stated here rather than discovered later: anything
106 # that reaches /var from something other than a package. The real Containerfile
107 # copies etc/ and usr/ and runs its own steps, and any of those could leave a
108 # file the probe never carries. The class it does catch is the one that has
109 # actually bitten -- a package's %post leaving content behind.
110 if [ "$rc" -ne 0 ]; then
111 # The dials failed, so the recipe describes an image the Containerfile's own
112 # validator refuses. Everything the probe would measure is a property of a
113 # mint that cannot happen, so building it costs a dnf transaction to answer
114 # questions about nothing. It also makes the gate slow precisely when it is
115 # about to refuse, and a gate people wait on is a gate people route around.
116 echo
117 echo "== probe: skipped; the dials above are already a refusal"
118 elif [ "${2:-}" = "--fast" ]; then
119 echo
120 echo "== probe: skipped (--fast); dials and guards only"
121 else
122 pkgset="$(sed -n 's/^PKGSET: //p' "$out")"
123 drop="$(sed -n 's/^TMPFILES-DROP: //p' "$out")"
124 if [ -z "$pkgset" ]; then
125 echo; echo "== probe: SKIP no package set came back from the resolver"
126 else
127 probe="$(mktemp -d)"
128 mkdir -p "$probe/tmpfiles"
129 cp "$REPO_ROOT"/usr/lib/tmpfiles.d/*.conf "$probe/tmpfiles/"
130 for f in $drop; do rm -f "$probe/tmpfiles/$f"; done
131 if [ -f "$REPO_ROOT/build/hosts/$HOST.requires" ]; then
132 cp "$REPO_ROOT/build/hosts/$HOST.requires" "$probe/requires"
133 else
134 : > "$probe/requires"
135 fi
136
137 # Run inside the probe: what the role needs, asked of a filesystem.
138 cat > "$probe/check-requires.sh" <<'CHECK'
139 #!/bin/sh
140 # Each requirement, asked of the image rather than of the repos.
141 bad=0; any=0
142 while IFS= read -r line; do
143 line="${line%%#*}"
144 line="$(printf '%s' "$line" | tr -d '[:space:]')"
145 [ -n "$line" ] || continue
146 any=1
147 kind="${line%%:*}"; value="${line#*:}"
148 case "$kind" in
149 bin) command -v "$value" >/dev/null 2>&1 ;;
150 pkgconfig) pkg-config --exists "$value" 2>/dev/null ;;
151 file) find / -path "$value" -print -quit 2>/dev/null | grep -q . ;;
152 *) printf ' SKIP %-34s unknown requirement kind
153 ' "$line"; continue ;;
154 esac
155 if [ $? -eq 0 ]; then
156 printf ' ok %s
157 ' "$line"
158 else
159 printf ' FAIL %s
160 ' "$line"
161 # Only now is repoquery worth the wait, and only for what is missing.
162 case "$kind" in
163 bin) q="/usr/bin/$value" ;;
164 pkgconfig) q="pkgconfig($value)" ;;
165 file) q="$value" ;;
166 esac
167 c="$(dnf -q repoquery --qf '%{name}
168 ' --whatprovides "$q" 2>/dev/null | sort -u | head -4 | tr '
169 ' ' ')"
170 [ -n "$c" ] && printf ' add one of: %s
171 ' "$c"
172 bad=1
173 fi
174 done < /requires
175 [ "$any" = 1 ] || echo " SKIP this host declares no role contract"
176 exit "$bad"
177 CHECK
178 chmod +x "$probe/check-requires.sh"
179
180 cat > "$probe/Containerfile" <<PROBE
181 FROM $BASE
182 RUN set -eux; dnf install -y --skip-unavailable --setopt=install_weak_deps=False $pkgset; \\
183 dnf clean all; \\
184 rm -f /var/log/dnf5.log*; \\
185 rm -rf /var/lib/dnf /var/cache/libdnf5; \\
186 rm -f /var/cache/ldconfig/aux-cache; \\
187 rm -f /var/cache/swcatalog/cache/*.xb; \\
188 rm -f /var/lib/authselect/checksum
189 COPY tmpfiles/ /usr/lib/tmpfiles.d/
190 COPY requires /requires
191 COPY check-requires.sh /check-requires.sh
192 PROBE
193
194 echo
195 echo "== probe: building the smallest image that can answer (one dnf transaction)"
196 if ! privc podman build -q -t localhost/alloy-preflight-probe:"$HOST" "$probe" >/dev/null 2>"$probe/build.err"; then
197 echo " SKIP the probe would not build:"
198 sed 's/^/ /' "$probe/build.err" | tail -6
199 rc=1
200 else
201 # Packages the base repos cannot see. The real build enables Terra
202 # and several COPRs before it installs; the probe does not, so those
203 # are absent here and their absence is the probe's blind spot rather
204 # than a finding. Named, so nobody reads a clean probe as covering
205 # more than it does. --skip-unavailable is what keeps one of them
206 # from failing the whole transaction.
207 missing="$(privc podman run --rm --entrypoint sh localhost/alloy-preflight-probe:"$HOST" -c \
208 'for p in '"$pkgset"'; do rpm -q --whatprovides "$p" >/dev/null 2>&1 || echo "$p"; done' 2>/dev/null | tr '\n' ' ')"
209 notprobed="$(sed -n 's/^NOT-PROBED: //p' "$out")"
210 [ -n "$notprobed" ] && missing="$missing$notprobed"
211 if [ -n "$missing" ]; then
212 echo
213 echo "== probe coverage: not installed here, so not checked"
214 printf ' %s\n' "$missing" | fold -s -w 76 | sed 's/^/ /'
215 echo " (the real build enables Terra and COPRs first; the probe does not)"
216 fi
217
218 echo
219 echo "== requires: what this host's role needs, asked of the image"
220 set +e
221 privc podman run --rm --entrypoint sh localhost/alloy-preflight-probe:"$HOST" /check-requires.sh
222 [ $? -eq 0 ] || rc=1
223 set -e
224
225 echo
226 echo "== var-payload: bootc's own lint, on that probe"
227 lint="$(privc podman run --rm --entrypoint bootc localhost/alloy-preflight-probe:"$HOST" \
228 container lint --no-truncate 2>&1 || true)"
229 if printf '%s' "$lint" | grep -q 'var-tmpfiles'; then
230 printf '%s\n' "$lint" | sed -n '/var-tmpfiles/,/^$/p' | sed 's/^/ /'
231 echo " FAIL bootc would refuse this mint's /var; declare the paths above"
232 rc=1
233 else
234 echo " ok: bootc finds nothing undeclared in /var for this package set"
235 fi
236 fi
237 fi
238 fi
239
240 # The guards run out here rather than in the container, because they read sibling
241 # repos under ~/Code that the container has no business mounting. They are plain
242 # greps and need no Fedora anything.
243 #
244 # This is the only check about regression rather than absence. NO_STRIP lived in
245 # _private/scripts/build-dist.sh, the move to Bento recipes dropped it, and a
246 # build rediscovered it months later: linuxdeploy bundles its own binutils and
247 # cannot read Fedora 43's `.relr.dyn` sections, so without it every AppImage
248 # built on an Alloy host fails at bundling with an error naming nothing useful.
249 echo
250 echo "== guards: known workarounds, asserted to still be there"
251 tree_root="$(cd "$REPO_ROOT/.." && pwd)"
252 guards_bad=0
253 for rel in Apps/goingson/dist/recipes/linux.rhai Apps/balanced_breakfast/dist/recipes/linux.rhai; do
254 f="$tree_root/$rel"
255 if [ ! -f "$f" ]; then
256 printf ' SKIP %-52s not on this machine\n' "$rel"
257 elif grep -q 'NO_STRIP' "$f"; then
258 printf ' ok %-52s carries NO_STRIP\n' "$rel"
259 else
260 printf ' FAIL %-52s lost NO_STRIP; every AppImage built on Alloy would fail at bundling\n' "$rel"
261 guards_bad=1
262 fi
263 done
264
265 # Two kinds of finding, and they are not the same kind of problem, so they no
266 # longer share an exit code.
267 #
268 # dials, requires and var-payload are about the medium: an image built with one
269 # of them outstanding is wrong wherever it is built, and a mint should not
270 # proceed. guards is about the machine you are standing on -- it reads sibling
271 # repos under ~/Code that no image contains -- so a stale goingson clone predicts
272 # a failed AppImage on this host and says nothing about the medium being minted.
273 #
274 # Collapsed into one code, the script could not be used as a gate at all: it
275 # would refuse to build a perfectly good ISO because a repo next door was
276 # behind. Separated, a caller can act on each. build/preflight-gate.sh does.
277 if [ "$rc" -eq 0 ] && [ "$guards_bad" -eq 0 ]; then
278 echo; echo "preflight: clean"
279 exit 0
280 fi
281 if [ "$rc" -eq 0 ]; then
282 echo; echo "preflight: this host has findings; the medium itself is clean"
283 exit 4
284 fi
285 echo; echo "preflight: findings above; fix them before building"
286 exit 1
287