Skip to main content

max / alloy

14.0 KB · 318 lines History Blame Raw
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 dials.setdefault("TRIM", "unused")
86 return dials
87
88
89 def eval_condition(frag, dials):
90 """One `if` fragment against the recipe's dials: True, False, or None.
91
92 Only the shape the Containerfile uses is read -- `[ "$VAR" = value ]` and its
93 `!=`, joined by `&&`. Anything else returns None and is treated as taken,
94 so a new guard shape costs a wide probe rather than a silent one.
95 """
96 if "||" in frag:
97 return None
98 tests = re.findall(r'\[\s*"\$(\w+)"\s*(=|!=)\s*([A-Za-z0-9._+-]+)\s*\]', frag)
99 if not tests:
100 return None
101 # Every `[ ... ]` in the fragment has to be one this understands, or the
102 # conjunction is being evaluated against half its terms.
103 if len(tests) != frag.count("[ "):
104 return None
105 for var, op, want in tests:
106 have = dials.get(var, "")
107 if (have == want) != (op == "="):
108 return False
109 return True
110
111
112 def branch_taken(if_stack):
113 """Whether the fragment's enclosing if/else frames all lead here."""
114 for cond, in_else in if_stack:
115 if cond is None:
116 continue
117 if cond == in_else: # False and not in else, or True and in else
118 return False
119 return True
120
121
122 def install_sites(dials):
123 """Package names this mint installs, with the dial arm that guards each.
124
125 The Containerfile is joined into logical lines first. A `RUN` is one command
126 however many backslashes it spans, so a `case` statement and every arm inside
127 it arrive together; a parser that read physical lines would see the package
128 list of the base block (one name per line) as no packages at all.
129
130 Comment-only lines are dropped before joining, which is what podman's own
131 parser does with them inside a continuation -- that is why the file can carry
132 a comment between two package names at all.
133
134 Attribution is by the innermost enclosing `case "$VAR" in` and the arm label
135 in force, so a package inside `postgres16)` is dropped when the recipe says
136 DB=none. Approximating this instead would report packages the mint does not
137 install, and a false failure is how an instrument stops being read.
138
139 `if [ "$VAR" = value ]` is read the same way, and it is not a nicety: the
140 profile split is written as an if/else rather than a case, so a parser that
141 saw only `case` attributed every client-only package to the base. Measured
142 2026-09-04 on astra, the first server recipe anyone ran this against: its
143 package set came back one package short of fw13's (firefox), carrying sway,
144 greetd, cups and fontconfig, and the probe then failed bootc's /var lint on
145 the client tmpfiles file a server mint correctly drops. A finding against a
146 mint that would pass, which is the failure mode this file exists to avoid.
147 """
148 logical, buf = [], ""
149 for raw in open(os.path.join(REPO, "Containerfile")):
150 line = raw.rstrip("\n")
151 if line.strip().startswith("#"):
152 continue
153 if line.rstrip().endswith("\\"):
154 buf += line.rstrip()[:-1] + " "
155 continue
156 logical.append(buf + line)
157 buf = ""
158 if buf:
159 logical.append(buf)
160
161 pkgs = {}
162 for line in logical:
163 case_stack, arm = [], None
164 # if/else frames, innermost last. Each is [condition, in_else], where a
165 # condition of None means a shape this does not read -- those count as
166 # taken, because dropping packages on an unrecognised guard would hide
167 # real findings, which is the one failure worse than reporting extra.
168 if_stack = []
169 for frag in re.split(r'[;]', line):
170 f = frag.strip()
171 if not f:
172 continue
173 # A fragment carries whatever keywords preceded it with no
174 # semicolon between them: `RUN if [ ... ]` is one, and so is
175 # `then dnf install ...`. Anchoring on the bare keyword found
176 # nothing, which is how PROFILE=server kept every client package.
177 kw = re.sub(r'^((RUN|then|do|else)\s+)+', '', f)
178 if re.match(r'^(el)?if\s', kw):
179 cond = eval_condition(kw, dials)
180 if kw.startswith("elif"):
181 if if_stack:
182 if_stack[-1] = [cond, False]
183 else:
184 if_stack.append([cond, False])
185 continue
186 if re.match(r'^else\b', f) and if_stack:
187 if_stack[-1][1] = True
188 # `else` and the command it guards can share a fragment, so
189 # this falls through rather than continuing.
190 if re.match(r'^fi\b', f):
191 if if_stack:
192 if_stack.pop()
193 continue
194 m = re.search(r'case\s+"\$(\w+)"\s+in', f)
195 if m:
196 case_stack.append(m.group(1))
197 arm = None
198 # The opening `case`, its first arm label and that arm's `dnf
199 # install` all land in one fragment, because nothing separates
200 # them with a semicolon. Anchoring the arm pattern at the start
201 # of the fragment finds it for every arm except the first, which
202 # is how GUI=tauri and DB=postgres16 both read as unguarded and
203 # then got dropped.
204 rest = f[m.end():].strip()
205 m2 = re.match(r'^([\w|]+)\)', rest)
206 if m2:
207 arm = m2.group(1)
208 else:
209 m2 = re.match(r'^([\w|]+)\)', f)
210 if m2 and case_stack:
211 arm = m2.group(1)
212 idx = f.find("dnf install")
213 # `dnf install` also appears inside error messages the Containerfile
214 # prints, e.g. "...a dnf install that moved above the layer setting
215 # it." Reading that as a command turns its prose into package names,
216 # which is how `above`, `moved`, `layer` and `setting` ended up in a
217 # probe's install list. An odd number of quotes before the match
218 # means the match is inside a string.
219 if idx >= 0 and f[:idx].count('"') % 2 == 0 and branch_taken(if_stack):
220 tail = f[idx + len("dnf install"):]
221 tail = re.split(r'&&|\|\|', tail)[0]
222 guard = "base" if not case_stack else "%s=%s" % (case_stack[-1], arm)
223 for tok in tail.split():
224 if tok.startswith("-") or tok.startswith("$"):
225 continue
226 if not re.match(r'^[A-Za-z0-9][A-Za-z0-9._+-]*$', tok):
227 continue
228 pkgs.setdefault(tok, guard)
229 if re.search(r'\besac\b', f):
230 if case_stack:
231 case_stack.pop()
232 arm = None
233
234 selected = {}
235 for pkg, guard in pkgs.items():
236 if guard == "base":
237 selected[pkg] = guard
238 continue
239 var, want = guard.split("=", 1)
240 # The language block loops `for lang in $(echo "$LANGS" | tr ',' ' ')`
241 # and switches on `$lang`, so the guard names the loop variable rather
242 # than the dial. Without this every language package reads as unselected.
243 if var == "lang":
244 var = "LANGS"
245 have = dials.get(var, "")
246 values = [v.strip() for v in have.split(",")] if var == "LANGS" else [have]
247 if any(v in (want or "").split("|") for v in values):
248 selected[pkg] = guard
249 return selected
250
251
252 def check_dials(dials):
253 """The Containerfile's own validator, run without building to reach it.
254
255 That validator is inside the image build, so a recipe that cannot pass it
256 fails around step 20 of 103 -- cheap as builds go, and still minutes to learn
257 something answerable in milliseconds. Found by this check on 2026-09-04:
258 build/hosts/astra.env set PROFILE=server and never set BROWSER, so the ARG
259 default of firefox applied and `server:firefox` is exactly what the validator
260 refuses. No astra mint had ever been attempted, so nothing had exercised it.
261
262 Kept deliberately narrow: it mirrors rules the Containerfile states, and a
263 rule that moves there has to move here. A second copy of a check is a
264 liability, so this stays a short list rather than growing into a schema.
265 """
266 print("== dials: the recipe against the Containerfile's own validator")
267 bad = 0
268 profile, browser = dials.get("PROFILE"), dials.get("BROWSER")
269 if profile == "server" and browser != "none":
270 bad += 1
271 print(" FAIL PROFILE=server with BROWSER=%s" % browser)
272 print(" the server profile ships no graphical session and the")
273 print(" validator refuses it. Set BROWSER=none in the recipe.")
274 for key, allowed in (("PROFILE", ("client", "server")),
275 ("DB", ("none", "postgres16")),
276 ("GUI", ("none", "tauri")),
277 ("BROWSER", ("firefox", "none")),
278 ("TRIM", ("unused", "keep"))):
279 v = dials.get(key)
280 if v is not None and v not in allowed:
281 bad += 1
282 print(" FAIL %s=%s is not one of %s" % (key, v, ", ".join(allowed)))
283 for lang in [x.strip() for x in dials.get("LANGS", "").split(",") if x.strip()]:
284 if lang not in ("rust", "c", "go", "python", "zig", "js"):
285 bad += 1
286 print(" FAIL LANGS names %r, which the builder does not offer" % lang)
287 if not bad:
288 print(" ok: every dial is a value the validator accepts")
289 return 1 if bad else 0
290
291
292 def main():
293 if len(sys.argv) != 2:
294 sys.exit("usage: preflight.py <host>")
295 host = sys.argv[1]
296 dials = read_recipe(host)
297 print("preflight for %s: %s\n" % (host, " ".join("%s=%s" % kv for kv in sorted(dials.items()))))
298 pkgs = install_sites(dials)
299 print("%d package(s) this mint installs\n" % len(pkgs))
300 # Machine-readable, for build/preflight.sh's probe image. Emitted rather than
301 # recomputed in shell so there is one parser for the Containerfile, not two.
302 probe_set = sorted(p for p in pkgs if p not in THIRD_PARTY)
303 skipped = sorted(p for p in pkgs if p in THIRD_PARTY)
304 if skipped:
305 print("NOT-PROBED: %s" % " ".join(skipped))
306 print("PKGSET: %s" % " ".join(probe_set))
307 print("TMPFILES-DROP: %s" % " ".join(
308 ([] if dials.get("PROFILE") == "client" else ["50-alloy-var-client.conf"])
309 + ([] if dials.get("DB", "none") != "none" else ["50-alloy-var-postgres.conf"])))
310
311 rc = 0
312 rc |= check_dials(dials)
313 return rc
314
315
316 if __name__ == "__main__":
317 raise SystemExit(main())
318