4"""Gate: every tool a gate declares must exist in the image the gates run in (#513).
8``scripts/ci.sh`` and the fragments under ``scripts/ci/gates/`` declare their
9external dependencies with ``require_cmd`` / ``require_python_mod``, which fail
10loudly when a tool is absent. That is the right behaviour at run time and it is
11far too late: the gate has already been scheduled, a runner has already been
12taken, and the verdict is a provisioning error rather than an answer about the
13tree. Twice in one day a gate landed asserting a tool the deployed runner image
14does not carry -- ``runner-clock`` wanted ``gh``, ``ci-status-contract`` wanted
15``jq`` -- and both were caught by somebody noticing, not by a check.
17``toolchain-parity`` cannot close this. It reads the pinned ``ARG`` versions out
18of ``.devcontainer/Dockerfile`` and compares them against tools that ARE on
19PATH; a dependency that is simply missing has no pin to disagree with, so it is
20structurally invisible there. This checker asks the other question: does every
21declared dependency resolve at all, in the image that will run the gate.
23Reaching the image, or failing
24------------------------------
25The subject is the *deployed image*, never the Dockerfile that is supposed to
26describe it -- the two are free to disagree and have (#487, and the pinned
27doxygen layer of #486 that was never rebuilt in). So the probe is either:
29* **inside the image** -- the gate's normal home is a workflow step on
30 ``runs-on: ra8-ci``, where the process already IS the runner container. That
31 is proved rather than assumed: the image writes ``/etc/ra8-ci-runner``, and
32 without that marker this checker will not claim to have probed it.
33* **into the image** -- given a container runtime that holds the image, run the
34 same probe inside a throwaway container.
36With neither, the answer is EXIT 2 and a message naming both routes. It is not
37a pass. A gate that shrugs when it cannot reach its subject re-creates exactly
38the blind spot it was written to close.
42An extractor that stops matching reports an empty dependency set and therefore
43a clean run, forever. Two guards: the scan fails when it finds fewer than
44``K_MIN_DEPENDENCIES`` declarations (the tree carries roughly twice that), and
45``--selftest`` asserts the extractor on every shape the sources actually use --
46plus the negative case, a declaration inside a comment, which must NOT be
47collected. The verdict is asserted in both directions too: a present tool must
48pass and an absent one must fail.
52 check_runner_image_deps.py # auto: marker, else a runtime
53 check_runner_image_deps.py --image REF # probe REF via docker/podman
54 check_runner_image_deps.py --local # probe this PATH (needs marker)
55 check_runner_image_deps.py --list # print what would be probed
56 check_runner_image_deps.py --selftest # prove the checker both ways
58Exit 0 when every declared dependency resolves, 1 when any does not, and 2 when
59no image could be reached or the sources could not be parsed.
62from __future__
import annotations
71from dataclasses
import dataclass
72from pathlib
import Path
74REPO_ROOT = Path(__file__).resolve().parents[2]
75CI_SH = REPO_ROOT /
"scripts" /
"ci.sh"
76GATES_DIR = REPO_ROOT /
"scripts" /
"ci" /
"gates"
85K_IMAGE_MARKER = Path(
"/etc/ra8-ci-runner")
90K_DEFAULT_IMAGE =
"localhost/ra8-ci-runner:v2"
94K_RUNTIMES = (
"docker",
"podman")
96K_PROBE_TIMEOUT_S = 300
101K_MIN_DEPENDENCIES = 10
109_DECL_RE = re.compile(
r"^[ \t]*require_(cmd|python_mod)[ \t]+(\S+)")
112_LITERAL_RE = re.compile(
r"^[A-Za-z0-9][A-Za-z0-9._+-]*$")
115@dataclass(frozen=True)
117 """One ``require_cmd`` / ``require_python_mod`` declaration found in a gate.
120 kind: ``K_KIND_CMD`` for an executable, ``K_KIND_MOD`` for a Python
122 name: The executable or module name as declared.
123 where: ``<path>:<line>`` of the declaration, for failure messages.
131def _fail(message: str) ->
None:
132 """Print a fatal message and exit 2 -- scan impossible, never scan clean.
135 message: What could not be done, and what would make it possible.
137 print(f
"ERROR: {message}", file=sys.stderr)
138 sys.exit(EXIT_UNREACHABLE)
141def gate_sources() -> list[Path]:
142 """Return the shell files that declare gate dependencies, in scan order.
145 ``scripts/ci.sh`` followed by every ``scripts/ci/gates/*.sh`` fragment.
147 return [CI_SH, *sorted(GATES_DIR.glob(
"*.sh"))]
150def extract_dependencies(paths: list[Path]) -> tuple[list[Dependency], list[str]]:
151 """Collect every dependency declaration in `paths`.
153 A declaration inside a comment is not collected: the regex anchors on the
154 call at the start of the line, so the prose in these files that *mentions*
155 ``require_cmd`` cannot inflate the set.
158 paths: Shell files to scan.
161 A ``(dependencies, unresolvable)`` pair. ``unresolvable`` lists the
162 declarations whose argument is not a literal name -- a variable, say --
163 which this checker cannot probe and must not silently drop.
165 found: list[Dependency] = []
166 unresolvable: list[str] = []
168 if not path.is_file():
170 rel = path.relative_to(REPO_ROOT)
172 text = path.read_text(encoding=
"utf-8")
173 except UnicodeDecodeError
as error:
177 _fail(f
"{rel} is not UTF-8 text and cannot be scanned for dependencies: {error}")
179 for number, line
in enumerate(text.splitlines(), start=1):
180 match = _DECL_RE.match(line)
183 kind = K_KIND_CMD
if match.group(1) ==
"cmd" else K_KIND_MOD
184 name = match.group(2)
185 where = f
"{rel}:{number}"
186 if _LITERAL_RE.match(name)
is None:
187 unresolvable.append(f
"{where}: require_{match.group(1)} {name}")
189 found.append(Dependency(kind, name, where))
190 return found, unresolvable
193def unique_names(deps: list[Dependency], kind: str) -> list[str]:
194 """Return the distinct names of `kind` declared in `deps`, sorted.
197 deps: Extracted declarations.
198 kind: ``K_KIND_CMD`` or ``K_KIND_MOD``.
201 Sorted, de-duplicated names.
203 return sorted({dep.name
for dep
in deps
if dep.kind == kind})
206def _probe_script(commands: list[str], modules: list[str]) -> str:
207 """Build the shell probe that reports which dependencies resolve.
209 The same script runs locally and inside a container, so the two paths
210 cannot answer the question differently.
213 commands: Executable names to look for on PATH.
214 modules: Python module names to import.
217 A POSIX shell script printing ``<kind> <name> <0|1>`` per dependency.
221 f
'if command -v {name} >/dev/null 2>&1; then echo "cmd {name} 1"; '
222 f
'else echo "cmd {name} 0"; fi'
226 f
'if python3 -c "import {name}" >/dev/null 2>&1; then echo "pymod {name} 1"; '
227 f
'else echo "pymod {name} 0"; fi'
230 return "\n".join(lines) +
"\n"
233def _parse_probe(output: str) -> dict[tuple[str, str], bool]:
234 """Turn the probe script's output back into a lookup table.
237 output: The probe's stdout.
240 Mapping of ``(kind, name)`` to whether it resolved.
242 table: dict[tuple[str, str], bool] = {}
243 for line
in output.splitlines():
244 fields = line.split()
246 if len(fields) != expected_fields:
248 table[(fields[0], fields[1])] = fields[2] ==
"1"
252def _run_probe(argv: list[str], script: str) -> str:
253 """Run the probe with `argv`, feeding `script` on stdin, and return stdout.
256 argv: The command that executes a shell reading the script from stdin.
257 script: The probe script.
263 proc = subprocess.run(
268 timeout=K_PROBE_TIMEOUT_S,
271 except subprocess.TimeoutExpired:
272 _fail(f
"the probe did not finish within {K_PROBE_TIMEOUT_S}s: {' '.join(argv)}")
273 except OSError
as error:
274 _fail(f
"could not run the probe ({' '.join(argv)}): {error}")
275 if proc.returncode != 0
and not proc.stdout.strip():
277 f
"the probe failed (rc={proc.returncode}) and produced nothing: "
278 f
"{' '.join(argv)}\n{proc.stderr.strip()}"
283def in_runner_image() -> bool:
284 """Report whether this process is running inside the fleet's runner image.
287 True when the image's own marker file is present.
289 return K_IMAGE_MARKER.is_file()
292def find_runtime(preferred: str |
None) -> str |
None:
293 """Locate a container runtime able to run a probe container.
296 preferred: A runtime named on the command line, or None to search.
299 The resolved executable path, or None when there is none.
301 for name
in [preferred]
if preferred
else list(K_RUNTIMES):
302 found = shutil.which(name)
303 if found
is not None:
308def probe_local(script: str) -> str:
309 """Run the probe on this machine's PATH.
312 script: The probe script.
317 shell = shutil.which(
"bash")
or shutil.which(
"sh")
319 _fail(
"no bash or sh on PATH; the probe cannot run")
320 return _run_probe([str(shell),
"-s"], script)
323def probe_image(runtime: str, image: str, script: str) -> str:
324 """Run the probe inside a throwaway container from `image`.
327 runtime: Path to the container runtime executable.
328 image: Image reference to probe.
329 script: The probe script.
334 argv = [runtime,
"run",
"--rm",
"--interactive",
"--entrypoint",
"bash", image,
"-s"]
335 return _run_probe(argv, script)
338def _resolve_probe(args: argparse.Namespace, script: str) -> tuple[str, str]:
339 """Choose how to reach the image, run the probe, and say what was probed.
342 args: Parsed command line.
343 script: The probe script.
346 A ``(subject, output)`` pair; `subject` names what was actually probed.
349 SystemExit: Exit 2 when no image can be reached, which is the whole
350 point of this function: it never falls back to "probed nothing".
352 if args.local
and not in_runner_image():
354 f
"--local was asked for, but {K_IMAGE_MARKER} is absent, so this is not the "
355 "fleet's runner image. Probing this PATH would answer a question nobody asked."
357 if args.local
or (args.image
is None and in_runner_image()):
358 marker = K_IMAGE_MARKER.read_text(encoding=
"utf-8").strip()
359 return f
"this process, inside {marker}", probe_local(script)
360 image = args.image
or K_DEFAULT_IMAGE
361 runtime = find_runtime(args.runtime)
364 f
"cannot reach an image to probe. This process is not inside the runner image "
365 f
"({K_IMAGE_MARKER} is absent) and no container runtime "
366 f
"({', '.join(K_RUNTIMES)}) is on PATH to start one from {image}. Run this "
367 "gate on `runs-on: ra8-ci`, where the step already executes in the image, or "
368 "give it a runtime that holds the image. It will not report a clean scan it "
371 return f
"{image} via {Path(runtime).name}", probe_image(runtime, image, script)
374def report(deps: list[Dependency], table: dict[tuple[str, str], bool], subject: str) -> int:
375 """Print the verdict for `deps` against a probe `table`.
378 deps: Extracted declarations.
379 table: Probe results keyed by ``(kind, name)``.
380 subject: What was probed, for the report header.
383 0 when every dependency resolved, 1 otherwise.
385 missing = [dep
for dep
in deps
if not table.get((dep.kind, dep.name),
False)]
386 commands = len(unique_names(deps, K_KIND_CMD))
387 modules = len(unique_names(deps, K_KIND_MOD))
388 print(f
"runner image dependency scan: {commands} command(s), {modules} python module(s)")
389 print(f
"probed: {subject}")
391 print(
"every dependency a gate declares resolves in the image.")
393 seen: set[tuple[str, str]] = set()
395 key = (dep.kind, dep.name)
399 kind =
"command" if dep.kind == K_KIND_CMD
else "python module"
400 wheres = sorted({other.where
for other
in missing
if (other.kind, other.name) == key})
401 print(f
"\nMISSING {kind} '{dep.name}'")
403 print(f
" declared at {where}")
405 f
"\nFAIL: {len(seen)} declared dependency/dependencies do not exist in {subject}. "
406 "Every gate that declares one fails there with a provisioning error instead of a "
407 "verdict. Add the tool to .devcontainer/Dockerfile and rebuild the runner image "
408 "(infra/images/README.md), or stop declaring it."
413def _selftest_extraction() -> None:
414 """Assert the extractor collects every real shape and no commented one.
417 AssertionError: When a shape the sources use stops being collected, or
418 a commented mention starts being collected.
422 " require_cmd cmake\n"
423 ' require_cmd clang-18 "the gate pins clang-18 to match CI"\n'
424 " require_cmd git || exit 1\n"
425 " require_cmd actionlint \\\n"
426 ' require_python_mod yaml "run just setup-python"\n'
427 " require_python_mod clang.cindex \\\n"
428 " # require_cmd never_declared_only_mentioned\n"
429 " # Use require_cmd / require_python_mod for every dependency.\n"
434 staged = REPO_ROOT /
".ra8-selftest-fragment.sh"
435 staged.write_text(fragment, encoding=
"utf-8")
437 deps, unresolvable = extract_dependencies([staged])
440 commands = unique_names(deps, K_KIND_CMD)
441 modules = unique_names(deps, K_KIND_MOD)
442 expected_commands = [
"actionlint",
"clang-18",
"cmake",
"git"]
443 expected_modules = [
"clang.cindex",
"yaml"]
444 if commands != expected_commands:
445 message = f
"selftest: extractor returned commands {commands}, expected {expected_commands}"
446 raise AssertionError(message)
447 if modules != expected_modules:
448 message = f
"selftest: extractor returned modules {modules}, expected {expected_modules}"
449 raise AssertionError(message)
451 message = f
"selftest: extractor reported {unresolvable} as unresolvable"
452 raise AssertionError(message)
455def _selftest_unresolvable() -> None:
456 """Assert a non-literal declaration is reported rather than skipped.
459 AssertionError: When a ``require_cmd "$tool"`` is silently dropped.
461 staged = REPO_ROOT /
".ra8-selftest-unresolvable.sh"
462 staged.write_text(
' require_cmd "$tool"\n', encoding=
"utf-8")
464 deps, unresolvable = extract_dependencies([staged])
467 if deps
or not unresolvable:
469 f
"selftest: a non-literal require_cmd produced deps={deps} "
470 f
"unresolvable={unresolvable}; it must be reported, never dropped"
472 raise AssertionError(message)
475def _selftest_verdict() -> None:
476 """Assert the verdict fires on an absent tool and stays quiet on a present one.
479 AssertionError: When either direction is wrong -- a checker that only
480 ever passes is the defect this file exists to prevent.
482 present = Dependency(K_KIND_CMD,
"sh",
"selftest:1")
483 absent = Dependency(K_KIND_CMD,
"ra8-tool-that-cannot-exist",
"selftest:2")
484 script = _probe_script([
"sh",
"ra8-tool-that-cannot-exist"], [])
485 table = _parse_probe(probe_local(script))
486 if not table.get((K_KIND_CMD,
"sh"),
False):
487 message =
"selftest: the probe did not find 'sh', so it can no longer find anything"
488 raise AssertionError(message)
489 if table.get((K_KIND_CMD,
"ra8-tool-that-cannot-exist"),
True):
490 message =
"selftest: the probe claimed a tool that cannot exist is present"
491 raise AssertionError(message)
496 quiet = io.StringIO()
497 with contextlib.redirect_stdout(quiet):
498 good = report([present], table,
"selftest")
499 bad = report([absent], table,
"selftest")
501 message =
"selftest: a present dependency was reported missing"
502 raise AssertionError(message)
504 message =
"selftest: an absent dependency was reported as fine"
505 raise AssertionError(message)
506 if "MISSING" not in quiet.getvalue():
507 message =
"selftest: the failing verdict printed no MISSING line to act on"
508 raise AssertionError(message)
511def selftest() -> int:
512 """Prove the extractor and the verdict, both directions, before any real scan.
515 0 when every assertion holds; an AssertionError escapes otherwise.
517 _selftest_extraction()
518 _selftest_unresolvable()
520 print(
"selftest: extraction, unresolvable-argument reporting and both verdict directions OK")
524def _parser() -> argparse.ArgumentParser:
525 """Build the command-line parser.
528 The configured parser.
530 parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
534 help=f
"image reference to probe with a container runtime (default {K_DEFAULT_IMAGE} "
535 "when this process is not itself inside the runner image)",
540 help=f
"container runtime to use ({' or '.join(K_RUNTIMES)}); auto-detected by default",
545 help=
"probe this process's own PATH; refuses unless the runner-image marker is present",
550 help=
"print the dependencies that would be probed, and exit",
552 parser.add_argument(
"--selftest", action=
"store_true", help=
"prove the checker, then exit")
556def _collect() -> list[Dependency]:
557 """Extract the dependency set and refuse a scan that has gone blind.
560 Every resolvable declaration in the gate sources.
563 SystemExit: Exit 2 when a declaration cannot be parsed, or when the
564 extractor found implausibly few -- both mean the scan is not
565 seeing its subject and a clean report would be a lie.
567 deps, unresolvable = extract_dependencies(gate_sources())
569 listing =
"\n ".join(unresolvable)
571 "these dependency declarations do not name a literal tool, so this gate cannot "
572 f
"probe them:\n {listing}\nDeclare the tool by name, or the gate that needs it "
575 if len(deps) < K_MIN_DEPENDENCIES:
577 f
"only {len(deps)} dependency declaration(s) found across {len(gate_sources())} "
578 f
"gate source file(s), below the floor of {K_MIN_DEPENDENCIES}. The extractor has "
579 "stopped seeing require_cmd / require_python_mod, which would report a clean "
585def main(argv: list[str] |
None =
None) -> int:
586 """Extract the declared dependencies and prove each resolves in the image.
589 argv: Command-line arguments, or None to read ``sys.argv``.
592 0 when every dependency resolves, 1 when any does not, 2 when no image
595 args = _parser().parse_args(argv)
599 commands = unique_names(deps, K_KIND_CMD)
600 modules = unique_names(deps, K_KIND_MOD)
602 for name
in commands:
605 print(f
"pymod {name}")
607 subject, output = _resolve_probe(args, _probe_script(commands, modules))
608 return report(deps, _parse_probe(output), subject)
611if __name__ ==
"__main__":
void main(void)
The application entry point Reset_Handler hands control to.