max / alloy
5 files changed,
+561 insertions,
-1 deletion
| @@ -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 |