| 1 |
|
| 2 |
|
| 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 |
|
| 37 |
|
| 38 |
|
| 39 |
|
| 40 |
|
| 41 |
|
| 42 |
|
| 43 |
|
| 44 |
|
| 45 |
|
| 46 |
|
| 47 |
|
| 48 |
|
| 49 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 102 |
|
| 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 |
|
| 114 |
for cond, in_else in if_stack: |
| 115 |
if cond is None: |
| 116 |
continue |
| 117 |
if cond == in_else: |
| 118 |
return False |
| 119 |
return True |
| 120 |
|
| 121 |
|
| 122 |
def install_sites(dials): |
| 123 |
|
| 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 |
|
| 165 |
|
| 166 |
|
| 167 |
|
| 168 |
if_stack = [] |
| 169 |
for frag in re.split(r'[;]', line): |
| 170 |
f = frag.strip() |
| 171 |
if not f: |
| 172 |
continue |
| 173 |
|
| 174 |
|
| 175 |
|
| 176 |
|
| 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 |
|
| 189 |
|
| 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 |
|
| 199 |
|
| 200 |
|
| 201 |
|
| 202 |
|
| 203 |
|
| 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 |
|
| 214 |
|
| 215 |
|
| 216 |
|
| 217 |
|
| 218 |
|
| 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 |
|
| 241 |
|
| 242 |
|
| 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 |
|
| 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 |
|
| 301 |
|
| 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 |
|