ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_runner_image_deps.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""Gate: every tool a gate declares must exist in the image the gates run in (#513).
5
6Why this exists
7---------------
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.
16
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.
22
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:
28
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.
35
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.
39
40Non-vacuity
41-----------
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.
49
50Run::
51
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
57
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.
60"""
61
62from __future__ import annotations
63
64import argparse
65import contextlib
66import io
67import re
68import shutil
69import subprocess
70import sys
71from dataclasses import dataclass
72from pathlib import Path
73
74REPO_ROOT = Path(__file__).resolve().parents[2]
75CI_SH = REPO_ROOT / "scripts" / "ci.sh"
76GATES_DIR = REPO_ROOT / "scripts" / "ci" / "gates"
77
78EXIT_OK = 0
79EXIT_FAIL = 1
80EXIT_UNREACHABLE = 2
81
82# Written by infra/images/runner/Dockerfile. Its only job is to answer "is this
83# process running inside the fleet's runner image" with evidence instead of a
84# guess -- an Ubuntu that happens to have the tools is not the subject.
85K_IMAGE_MARKER = Path("/etc/ra8-ci-runner")
86
87# The ref the ARC scale set and both Docker hosts boot. Kept as a default so
88# the developer path is one flag shorter; the CI path never uses it, because
89# there the marker means the probe is already inside the image.
90K_DEFAULT_IMAGE = "localhost/ra8-ci-runner:v2"
91
92# Container runtimes that can run a throwaway probe container, in preference
93# order. Nothing else in this tree needs one, so absence is ordinary.
94K_RUNTIMES = ("docker", "podman")
95
96K_PROBE_TIMEOUT_S = 300
97
98# Floor for the extracted declaration count. The tree carries ~24; a scan that
99# finds fewer than this has stopped seeing its subject, which reads as a clean
100# run and is the failure this whole file is written against.
101K_MIN_DEPENDENCIES = 10
102
103K_KIND_CMD = "cmd"
104K_KIND_MOD = "pymod"
105
106# A declaration and its argument. The argument is captured loosely on purpose:
107# a `require_cmd "$tool"` is not a dependency this checker can resolve, and it
108# has to say so rather than skip the line and report a clean scan.
109_DECL_RE = re.compile(r"^[ \t]*require_(cmd|python_mod)[ \t]+(\S+)")
110
111# What a resolvable argument looks like: a literal command or module name.
112_LITERAL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]*$")
113
114
115@dataclass(frozen=True)
116class Dependency:
117 """One ``require_cmd`` / ``require_python_mod`` declaration found in a gate.
118
119 Attributes:
120 kind: ``K_KIND_CMD`` for an executable, ``K_KIND_MOD`` for a Python
121 module.
122 name: The executable or module name as declared.
123 where: ``<path>:<line>`` of the declaration, for failure messages.
124 """
125
126 kind: str
127 name: str
128 where: str
129
130
131def _fail(message: str) -> None:
132 """Print a fatal message and exit 2 -- scan impossible, never scan clean.
133
134 Args:
135 message: What could not be done, and what would make it possible.
136 """
137 print(f"ERROR: {message}", file=sys.stderr)
138 sys.exit(EXIT_UNREACHABLE)
139
140
141def gate_sources() -> list[Path]:
142 """Return the shell files that declare gate dependencies, in scan order.
143
144 Returns:
145 ``scripts/ci.sh`` followed by every ``scripts/ci/gates/*.sh`` fragment.
146 """
147 return [CI_SH, *sorted(GATES_DIR.glob("*.sh"))]
148
149
150def extract_dependencies(paths: list[Path]) -> tuple[list[Dependency], list[str]]:
151 """Collect every dependency declaration in `paths`.
152
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.
156
157 Args:
158 paths: Shell files to scan.
159
160 Returns:
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.
164 """
165 found: list[Dependency] = []
166 unresolvable: list[str] = []
167 for path in paths:
168 if not path.is_file():
169 continue
170 rel = path.relative_to(REPO_ROOT)
171 try:
172 text = path.read_text(encoding="utf-8")
173 except UnicodeDecodeError as error:
174 # Every tracked file in this tree is 7-bit ASCII and the `ascii`
175 # gate keeps it that way, so this is a foreign file in the scan
176 # path rather than a source. Say which, instead of a traceback.
177 _fail(f"{rel} is not UTF-8 text and cannot be scanned for dependencies: {error}")
178 raise # unreachable: _fail() exits
179 for number, line in enumerate(text.splitlines(), start=1):
180 match = _DECL_RE.match(line)
181 if match is None:
182 continue
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}")
188 continue
189 found.append(Dependency(kind, name, where))
190 return found, unresolvable
191
192
193def unique_names(deps: list[Dependency], kind: str) -> list[str]:
194 """Return the distinct names of `kind` declared in `deps`, sorted.
195
196 Args:
197 deps: Extracted declarations.
198 kind: ``K_KIND_CMD`` or ``K_KIND_MOD``.
199
200 Returns:
201 Sorted, de-duplicated names.
202 """
203 return sorted({dep.name for dep in deps if dep.kind == kind})
204
205
206def _probe_script(commands: list[str], modules: list[str]) -> str:
207 """Build the shell probe that reports which dependencies resolve.
208
209 The same script runs locally and inside a container, so the two paths
210 cannot answer the question differently.
211
212 Args:
213 commands: Executable names to look for on PATH.
214 modules: Python module names to import.
215
216 Returns:
217 A POSIX shell script printing ``<kind> <name> <0|1>`` per dependency.
218 """
219 lines = ["set -u"]
220 lines.extend(
221 f'if command -v {name} >/dev/null 2>&1; then echo "cmd {name} 1"; '
222 f'else echo "cmd {name} 0"; fi'
223 for name in commands
224 )
225 lines.extend(
226 f'if python3 -c "import {name}" >/dev/null 2>&1; then echo "pymod {name} 1"; '
227 f'else echo "pymod {name} 0"; fi'
228 for name in modules
229 )
230 return "\n".join(lines) + "\n"
231
232
233def _parse_probe(output: str) -> dict[tuple[str, str], bool]:
234 """Turn the probe script's output back into a lookup table.
235
236 Args:
237 output: The probe's stdout.
238
239 Returns:
240 Mapping of ``(kind, name)`` to whether it resolved.
241 """
242 table: dict[tuple[str, str], bool] = {}
243 for line in output.splitlines():
244 fields = line.split()
245 expected_fields = 3
246 if len(fields) != expected_fields:
247 continue
248 table[(fields[0], fields[1])] = fields[2] == "1"
249 return table
250
251
252def _run_probe(argv: list[str], script: str) -> str:
253 """Run the probe with `argv`, feeding `script` on stdin, and return stdout.
254
255 Args:
256 argv: The command that executes a shell reading the script from stdin.
257 script: The probe script.
258
259 Returns:
260 The probe's stdout.
261 """
262 try:
263 proc = subprocess.run( # noqa: S603 -- fixed argv, no shell, tools via shutil.which
264 argv,
265 input=script,
266 capture_output=True,
267 text=True,
268 timeout=K_PROBE_TIMEOUT_S,
269 check=False,
270 )
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():
276 _fail(
277 f"the probe failed (rc={proc.returncode}) and produced nothing: "
278 f"{' '.join(argv)}\n{proc.stderr.strip()}"
279 )
280 return proc.stdout
281
282
283def in_runner_image() -> bool:
284 """Report whether this process is running inside the fleet's runner image.
285
286 Returns:
287 True when the image's own marker file is present.
288 """
289 return K_IMAGE_MARKER.is_file()
290
291
292def find_runtime(preferred: str | None) -> str | None:
293 """Locate a container runtime able to run a probe container.
294
295 Args:
296 preferred: A runtime named on the command line, or None to search.
297
298 Returns:
299 The resolved executable path, or None when there is none.
300 """
301 for name in [preferred] if preferred else list(K_RUNTIMES):
302 found = shutil.which(name)
303 if found is not None:
304 return found
305 return None
306
307
308def probe_local(script: str) -> str:
309 """Run the probe on this machine's PATH.
310
311 Args:
312 script: The probe script.
313
314 Returns:
315 The probe's stdout.
316 """
317 shell = shutil.which("bash") or shutil.which("sh")
318 if shell is None:
319 _fail("no bash or sh on PATH; the probe cannot run")
320 return _run_probe([str(shell), "-s"], script)
321
322
323def probe_image(runtime: str, image: str, script: str) -> str:
324 """Run the probe inside a throwaway container from `image`.
325
326 Args:
327 runtime: Path to the container runtime executable.
328 image: Image reference to probe.
329 script: The probe script.
330
331 Returns:
332 The probe's stdout.
333 """
334 argv = [runtime, "run", "--rm", "--interactive", "--entrypoint", "bash", image, "-s"]
335 return _run_probe(argv, script)
336
337
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.
340
341 Args:
342 args: Parsed command line.
343 script: The probe script.
344
345 Returns:
346 A ``(subject, output)`` pair; `subject` names what was actually probed.
347
348 Raises:
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".
351 """
352 if args.local and not in_runner_image():
353 _fail(
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."
356 )
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)
362 if runtime is None:
363 _fail(
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 "
369 "did not perform."
370 )
371 return f"{image} via {Path(runtime).name}", probe_image(runtime, image, script)
372
373
374def report(deps: list[Dependency], table: dict[tuple[str, str], bool], subject: str) -> int:
375 """Print the verdict for `deps` against a probe `table`.
376
377 Args:
378 deps: Extracted declarations.
379 table: Probe results keyed by ``(kind, name)``.
380 subject: What was probed, for the report header.
381
382 Returns:
383 0 when every dependency resolved, 1 otherwise.
384 """
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}")
390 if not missing:
391 print("every dependency a gate declares resolves in the image.")
392 return EXIT_OK
393 seen: set[tuple[str, str]] = set()
394 for dep in missing:
395 key = (dep.kind, dep.name)
396 if key in seen:
397 continue
398 seen.add(key)
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}'")
402 for where in wheres:
403 print(f" declared at {where}")
404 print(
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."
409 )
410 return EXIT_FAIL
411
412
413def _selftest_extraction() -> None:
414 """Assert the extractor collects every real shape and no commented one.
415
416 Raises:
417 AssertionError: When a shape the sources use stops being collected, or
418 a commented mention starts being collected.
419 """
420 fragment = (
421 "gate_example() (\n"
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"
430 ")\n"
431 )
432 # extract_dependencies reports each finding relative to the repo root, so
433 # the fragment is staged under it rather than in a temporary directory.
434 staged = REPO_ROOT / ".ra8-selftest-fragment.sh"
435 staged.write_text(fragment, encoding="utf-8")
436 try:
437 deps, unresolvable = extract_dependencies([staged])
438 finally:
439 staged.unlink()
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)
450 if unresolvable:
451 message = f"selftest: extractor reported {unresolvable} as unresolvable"
452 raise AssertionError(message)
453
454
455def _selftest_unresolvable() -> None:
456 """Assert a non-literal declaration is reported rather than skipped.
457
458 Raises:
459 AssertionError: When a ``require_cmd "$tool"`` is silently dropped.
460 """
461 staged = REPO_ROOT / ".ra8-selftest-unresolvable.sh"
462 staged.write_text(' require_cmd "$tool"\n', encoding="utf-8")
463 try:
464 deps, unresolvable = extract_dependencies([staged])
465 finally:
466 staged.unlink()
467 if deps or not unresolvable:
468 message = (
469 f"selftest: a non-literal require_cmd produced deps={deps} "
470 f"unresolvable={unresolvable}; it must be reported, never dropped"
471 )
472 raise AssertionError(message)
473
474
475def _selftest_verdict() -> None:
476 """Assert the verdict fires on an absent tool and stays quiet on a present one.
477
478 Raises:
479 AssertionError: When either direction is wrong -- a checker that only
480 ever passes is the defect this file exists to prevent.
481 """
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)
492 # The verdict is exercised with its output swallowed. Printing a real
493 # "FAIL: 1 declared dependency..." block from a PASSING selftest would
494 # teach a reader to skim past that exact line in a log, which is the line
495 # this gate exists to make them read.
496 quiet = io.StringIO()
497 with contextlib.redirect_stdout(quiet):
498 good = report([present], table, "selftest")
499 bad = report([absent], table, "selftest")
500 if good != EXIT_OK:
501 message = "selftest: a present dependency was reported missing"
502 raise AssertionError(message)
503 if bad != EXIT_FAIL:
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)
509
510
511def selftest() -> int:
512 """Prove the extractor and the verdict, both directions, before any real scan.
513
514 Returns:
515 0 when every assertion holds; an AssertionError escapes otherwise.
516 """
517 _selftest_extraction()
518 _selftest_unresolvable()
519 _selftest_verdict()
520 print("selftest: extraction, unresolvable-argument reporting and both verdict directions OK")
521 return EXIT_OK
522
523
524def _parser() -> argparse.ArgumentParser:
525 """Build the command-line parser.
526
527 Returns:
528 The configured parser.
529 """
530 parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
531 parser.add_argument(
532 "--image",
533 default=None,
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)",
536 )
537 parser.add_argument(
538 "--runtime",
539 default=None,
540 help=f"container runtime to use ({' or '.join(K_RUNTIMES)}); auto-detected by default",
541 )
542 parser.add_argument(
543 "--local",
544 action="store_true",
545 help="probe this process's own PATH; refuses unless the runner-image marker is present",
546 )
547 parser.add_argument(
548 "--list",
549 action="store_true",
550 help="print the dependencies that would be probed, and exit",
551 )
552 parser.add_argument("--selftest", action="store_true", help="prove the checker, then exit")
553 return parser
554
555
556def _collect() -> list[Dependency]:
557 """Extract the dependency set and refuse a scan that has gone blind.
558
559 Returns:
560 Every resolvable declaration in the gate sources.
561
562 Raises:
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.
566 """
567 deps, unresolvable = extract_dependencies(gate_sources())
568 if unresolvable:
569 listing = "\n ".join(unresolvable)
570 _fail(
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 "
573 "goes unchecked."
574 )
575 if len(deps) < K_MIN_DEPENDENCIES:
576 _fail(
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 "
580 "image forever."
581 )
582 return deps
583
584
585def main(argv: list[str] | None = None) -> int:
586 """Extract the declared dependencies and prove each resolves in the image.
587
588 Args:
589 argv: Command-line arguments, or None to read ``sys.argv``.
590
591 Returns:
592 0 when every dependency resolves, 1 when any does not, 2 when no image
593 could be reached.
594 """
595 args = _parser().parse_args(argv)
596 if args.selftest:
597 return selftest()
598 deps = _collect()
599 commands = unique_names(deps, K_KIND_CMD)
600 modules = unique_names(deps, K_KIND_MOD)
601 if args.list:
602 for name in commands:
603 print(f"cmd {name}")
604 for name in modules:
605 print(f"pymod {name}")
606 return EXIT_OK
607 subject, output = _resolve_probe(args, _probe_script(commands, modules))
608 return report(deps, _parse_probe(output), subject)
609
610
611if __name__ == "__main__":
612 sys.exit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298