ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_ci_image_single_builder.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"""Fail if anything but the one blessed script builds the ``ra8-ci`` image.
5
6WHY THIS EXISTS
7===============
8``just ci`` boots a locally-built image tagged ``ra8-ci:latest``. #521 made
9that image a pure function of its allowlisted build context -- it carries the
10context's sha256 as an OCI label, and ``scripts/ci/devcontainer_image.sh`` is
11the ONE thing that builds it, rebuilding rather than reusing a cached image
12whose label disagrees with the tree. That guarantee holds only while
13``devcontainer_image.sh`` is the *only* builder. It was not before: the
14deleted ``inner-local.sh`` -- unreferenced, predating
15``RA8_GATE_REGISTRY``, carrying hand-copied gate bodies -- also ran
16``docker build -t ra8-ci:latest`` with the old "present, so reuse it forever"
17logic. Nothing would have noticed a new one appearing (#528).
18
19This is the same hole ``check_ci_parity.py`` closes for workflow ``run:``
20bodies: a second, drifting home for a thing that must have exactly one. The
21image needs the equivalent.
22
23WHAT IT FORBIDS, PRECISELY
24--------------------------
25A container-image BUILD (``docker build`` / ``podman build`` /
26``buildah bud`` / the ``"${RUNTIME[@]}" build`` array form) whose ``-t`` / ``--tag``
27target names the ``ra8-ci`` image, in any first-party file under ``scripts/``,
28``infra/``, ``.github/`` or ``just/`` OTHER than
29``scripts/ci/devcontainer_image.sh``.
30
31Naming it precisely matters, because ``ra8-ci`` is also a runner LABEL
32(``runs-on: ra8-ci``), a filesystem PATH (``/var/lib/ra8-ci/``) and a scale-set
33NAME all over the tree -- none of which build anything. So the rule keys on a
34build invocation AND a tag argument, not on the string appearing:
35
36 * ``ra8-ci`` and ``ra8-ci:latest`` match; the tag may be given literally or
37 through a shell variable this checker resolves within the same file (that
38 is how ``devcontainer_image.sh`` itself spells it:
39 ``IMAGE_TAG="${RA8_CI_IMAGE:-ra8-ci:latest}"`` then ``-t "$IMAGE_TAG"``).
40 * ``ra8-ci-runner:v2`` and ``ra8-devcontainer:latest`` do NOT match -- the
41 ``ci_runner`` role builds those and is a different, legitimate subject.
42 * The retired ``ra8-firmware-dev`` and ``ra8-firmware-test`` tags are
43 forbidden even on a run-only command: either one would recreate a second,
44 unversioned developer image beside the digest-labelled ``ra8-ci`` image.
45 * Any other direct build from ``.devcontainer/Dockerfile`` is forbidden
46 outside the deployed runner-image role. A new tag cannot evade the rule.
47
48SCOPE, HONESTLY
49---------------
50Command reconstruction joins shell backslash continuations and YAML
51``cmd:``/``run:`` block scalars, so both a resurrected shell builder and a new
52Ansible ``command:`` one are caught. Markdown is out of scope: a doc is not a
53build path. A NON-VACUITY FLOOR asserts the one known builder is still
54detected on every real run, so a reconstruction that quietly stopped matching
55fails loudly instead of reporting a clean, empty tree.
56
57Run with ``--selftest`` to prove both directions; ``--list`` to print the
58builders it currently sees.
59
60Exit 0 when the sole builder is the only one, 1 when a second builder exists,
612 when the scan itself collapsed (the floor is not met).
62"""
63
64from __future__ import annotations
65
66import argparse
67import re
68import subprocess
69import sys
70import tempfile
71from pathlib import Path
72
73sys.path.insert(0, str(Path(__file__).resolve().parent))
74
75import runner_image_cleanup_policy as runner_cleanup
76from selftest_assert import expect, report
77
78# The single blessed builder, repo-relative. If this file is renamed, update
79# this constant IN THE SAME CHANGE -- a stale value makes the floor below fail
80# loudly rather than letting the check silently trust the wrong file.
81SOLE_BUILDER = "scripts/ci/devcontainer_image.sh"
82
83# This checker embeds ``docker build -t ra8-ci:latest`` fixtures in its own
84# selftest strings; it is a detector, not a build path, so it excludes itself.
85# The fixtures are exercised through the temp-dir selftest, so nothing is lost.
86SELF = "scripts/checks/check_ci_image_single_builder.py"
87
88# The four trees a builder could hide in. Markdown is deliberately not here.
89SCOPE_DIRS = ("scripts/", "infra/", ".github/", "just/")
90SCOPE_FILES = frozenset({"justfile"})
91
92# Vendored SOUP is governed by its upstream boundary, never by this checker.
93THIRD_PARTY_PREFIXES = ("libs/third_party/", "apps/shared_libs/third_party/")
94
95# The deployed Actions runner intentionally layers its own image from the same
96# context. It is provisioned infrastructure rather than a developer image and
97# carries a separately checked contract.
98DEPLOYED_RUNNER_BUILDER = "infra/ansible/roles/ci_runner/tasks/main.yml"
99DOCKER_RUNNER_DEPLOY = "infra/ansible/roles/ci_runner_docker/tasks/deploy.yml"
100CAPACITY_HELPER = "scripts/ci/fleet_capacity.sh"
101
102# A build verb: docker/podman [buildx] build, buildah bud, or a runtime taken
103# from a shell array/variable (``"${RUNTIME[@]}" build``) as devcontainer_image
104# itself spells it.
105BUILD_RE = re.compile(
106 r"\b(?:docker|podman)(?:\s+buildx)?\s+build\b"
107 r"|\bbuildah\s+bud\b"
108 r'|\}"?\s+build\b'
109)
110
111# A -t / --tag argument and its value (quoted, or up to the next space).
112TAG_RE = re.compile(r"""(?:--tag|(?<![\w-])-t)(?:=|\s+)("[^"]*"|'[^']*'|\S+)""")
113
114# A shell VAR=value assignment, for resolving ``-t "$VAR"``.
115ASSIGN_RE = re.compile(r"^\s*(?:export\s+)?([A-Za-z_]\w*)=(.*)$")
116
117# A ``$VAR`` / ``${VAR}`` / ``${VAR:-default}`` reference, whole-token.
118VARREF_RE = re.compile(r"\$\{?(?P<var>[A-Za-z_]\w*)(?::-(?P<default>[^}]*))?\}?")
119
120# The forbidden image, as an IMAGE NAME (before any ``:tag``), anchored so
121# ``ra8-ci-runner`` and ``ra8-devcontainer`` do not match but a registry
122# prefix (``localhost/ra8-ci``) does.
123CI_IMAGE_RE = re.compile(r"(?:^|/)ra8-ci(?::|$)")
124LEGACY_DEV_IMAGE_RE = re.compile(r"(?<![\w-])ra8-firmware-(?:dev|test)(?::|\b)")
125DEVCONTAINER_CONTEXT_RE = re.compile(r"(?<![\w.])\.devcontainer(?:[/\"'\s]|$)")
126
127# A ``cmd:``/``run:``/``shell:``/``script:`` YAML block scalar opener.
128BLOCK_KEY_RE = re.compile(
129 r"^(?P<indent>\s*)(?:-\s+)?(?:[\w.]+\s+)?(?:cmd|run|shell|script):\s*[|>][+-]?\s*$"
130)
131
132# A surrounding quote pair is at least the two quote characters themselves.
133MIN_QUOTED_LEN = 2
134
135# Variable-resolution recursion bound: deep enough for ``$A -> $B -> literal``,
136# shallow enough that a reference cycle terminates instead of looping.
137MAX_RESOLVE_DEPTH = 6
138
139# The tree cannot plausibly have zero build invocations under these three dirs:
140# devcontainer_image.sh, the ci_runner role and the report scripts all build
141# images. A scan that finds none has broken. Floor is expressed as "the sole
142# builder must be found", which is stronger and self-describing.
143
144
145def _repo_root() -> Path:
146 """The repository root, via git."""
147 return Path(
148 subprocess.run(
149 ["git", "rev-parse", "--show-toplevel"], # noqa: S607 -- fixed argv
150 capture_output=True,
151 text=True,
152 check=True,
153 ).stdout.strip()
154 )
155
156
157def strip_comment(line: str) -> str:
158 """Drop a shell/YAML ``#`` comment, respecting single and double quotes.
159
160 A ``#`` starts a comment only at the start of the line or after
161 whitespace; one glued to a word (``foo#bar``, a URL fragment) is data.
162 """
163 in_s = in_d = False
164 for i, char in enumerate(line):
165 if char == "'" and not in_d:
166 in_s = not in_s
167 elif char == '"' and not in_s:
168 in_d = not in_d
169 elif char == "#" and not in_s and not in_d and (i == 0 or line[i - 1].isspace()):
170 return line[:i]
171 return line
172
173
174def logical_commands(text: str) -> list[str]:
175 """Reconstruct whole shell commands from `text`.
176
177 Joins backslash line-continuations and YAML ``cmd:``/``run:`` block
178 scalars, so a ``-t`` argument on a different physical line than its
179 ``build`` verb is still seen as one command. Comments are stripped first.
180 """
181 lines = text.split("\n")
182 total = len(lines)
183 out: list[str] = []
184 i = 0
185 while i < total:
186 block = BLOCK_KEY_RE.match(lines[i])
187 if block:
188 key_indent = len(block.group("indent"))
189 body: list[str] = []
190 j = i + 1
191 while j < total:
192 bl = lines[j]
193 if bl.strip() == "":
194 j += 1
195 continue
196 if (len(bl) - len(bl.lstrip())) <= key_indent:
197 break
198 body.append(strip_comment(bl).strip().rstrip("\\").strip())
199 j += 1
200 out.append(" ".join(part for part in body if part))
201 i = j
202 continue
203 cur = strip_comment(lines[i])
204 while cur.rstrip().endswith("\\") and i + 1 < total:
205 cur = cur.rstrip()[:-1] + " " + strip_comment(lines[i + 1])
206 i += 1
207 out.append(cur)
208 i += 1
209 return out
210
211
212def collect_assignments(text: str) -> dict[str, str]:
213 """Every ``VAR=value`` in `text`, values with one layer of quotes removed."""
214 assigns: dict[str, str] = {}
215 for raw in text.split("\n"):
216 match = ASSIGN_RE.match(strip_comment(raw))
217 if match:
218 assigns[match.group(1)] = _unquote(match.group(2).strip())
219 return assigns
220
221
222def _unquote(token: str) -> str:
223 """Strip one balanced pair of surrounding single or double quotes."""
224 token = token.strip()
225 if len(token) >= MIN_QUOTED_LEN and token[0] == token[-1] and token[0] in "\"'":
226 return token[1:-1]
227 return token
228
229
230def resolve(token: str, assigns: dict[str, str], depth: int = 0) -> str:
231 """Resolve a ``$VAR`` / ``${VAR:-default}`` token against `assigns`.
232
233 Bounded recursion; an unknown variable with no default resolves to itself,
234 so an unresolvable tag simply fails to match rather than crashing.
235 """
236 token = _unquote(token)
237 if depth > MAX_RESOLVE_DEPTH:
238 return token
239 ref = VARREF_RE.fullmatch(token)
240 if not ref:
241 return token
242 var, default = ref.group("var"), ref.group("default")
243 if var in assigns:
244 return resolve(assigns[var], assigns, depth + 1)
245 if default is not None:
246 return resolve(default, assigns, depth + 1)
247 return token
248
249
250def ci_tags_built(text: str) -> list[str]:
251 """The resolved ``ra8-ci`` tags this file's build commands target.
252
253 Empty when the file builds no ``ra8-ci`` image -- whether it builds nothing,
254 builds a different image, or references ``ra8-ci`` only as a label or path.
255 """
256 assigns = collect_assignments(text)
257 hits: list[str] = []
258 for command in logical_commands(text):
259 if not BUILD_RE.search(command):
260 continue
261 for raw_tag in TAG_RE.findall(command):
262 resolved = resolve(raw_tag, assigns)
263 if CI_IMAGE_RE.search(resolved):
264 hits.append(resolved)
265 return hits
266
267
268def legacy_dev_images(text: str) -> list[str]:
269 """Retired developer image names in active commands or assignments."""
270 commands = "\n".join(logical_commands(text))
271 return sorted(set(LEGACY_DEV_IMAGE_RE.findall(commands)))
272
273
274def builds_devcontainer_context(text: str) -> bool:
275 """Whether a build command names the repository devcontainer context."""
276 assignments = collect_assignments(text)
277 context_vars = {
278 name
279 for name, value in assignments.items()
280 if DEVCONTAINER_CONTEXT_RE.search(resolve(value, assignments))
281 or DEVCONTAINER_CONTEXT_RE.search(value)
282 }
283 for command in logical_commands(text):
284 if not BUILD_RE.search(command):
285 continue
286 if DEVCONTAINER_CONTEXT_RE.search(command):
287 return True
288 if any(re.search(rf"\$\{{?{re.escape(name)}(?:\}}|\b)", command) for name in context_vars):
289 return True
290 return False
291
292
293def in_scope(rel: str) -> bool:
294 """True for a first-party non-Markdown file under one of the scope dirs."""
295 return (
296 (rel.startswith(SCOPE_DIRS) or rel in SCOPE_FILES)
297 and not rel.endswith(".md")
298 and rel != SELF
299 and not rel.startswith(THIRD_PARTY_PREFIXES)
300 )
301
302
303def scoped_files(root: Path) -> list[str]:
304 """Every in-scope git-tracked file, repo-relative and sorted."""
305 proc = subprocess.run(
306 ["git", "ls-files", "-z"], # noqa: S607 -- git from PATH is intended
307 cwd=root,
308 capture_output=True,
309 text=True,
310 check=True,
311 )
312 return sorted(rel for rel in proc.stdout.split("\0") if rel and in_scope(rel))
313
314
315def find_builders(root: Path, rels: list[str]) -> dict[str, list[str]]:
316 """Map each in-scope file that builds ``ra8-ci`` to the tags it targets."""
317 builders: dict[str, list[str]] = {}
318 for rel in rels:
319 try:
320 text = (root / rel).read_text(encoding="utf-8")
321 except (UnicodeDecodeError, OSError):
322 continue # a binary or unreadable file is not a build script
323 tags = ci_tags_built(text)
324 if tags:
325 builders[rel] = tags
326 return builders
327
328
329def find_context_builders(root: Path, rels: list[str]) -> list[str]:
330 """Files directly building the repository devcontainer context."""
331 hits: list[str] = []
332 for rel in rels:
333 try:
334 text = (root / rel).read_text(encoding="utf-8")
335 except (UnicodeDecodeError, OSError):
336 continue
337 if builds_devcontainer_context(text):
338 hits.append(rel)
339 return sorted(hits)
340
341
342def find_legacy_images(root: Path, rels: list[str]) -> dict[str, list[str]]:
343 """Map files still naming a retired developer image."""
344 hits: dict[str, list[str]] = {}
345 for rel in rels:
346 try:
347 text = (root / rel).read_text(encoding="utf-8")
348 except (UnicodeDecodeError, OSError):
349 continue
350 names = legacy_dev_images(text)
351 if names:
352 hits[rel] = names
353 return hits
354
355
356# --------------------------------------------------------------------------
357# selftest
358# --------------------------------------------------------------------------
359
360# The real builder's shape: an indirected tag with a ``:-`` default, exactly as
361# devcontainer_image.sh writes it. The floor and the "allowed" case both lean
362# on this being detected.
363GOOD_SOLE = (
364 'IMAGE_TAG="${RA8_CI_IMAGE:-ra8-ci:latest}"\n'
365 "require_runtime\n"
366 '"${RUNTIME[@]}" build \\\n'
367 ' --label "$LABEL_KEY=$want" \\\n'
368 ' -t "$IMAGE_TAG" \\\n'
369 ' -f "$CONTEXT_DIR/Dockerfile" \\\n'
370 ' "$CONTEXT_DIR"\n'
371)
372
373# A resurrected standalone builder, the #528 threat, literal tag.
374BAD_LITERAL = (
375 "#!/usr/bin/env bash\ndocker build -t ra8-ci:latest -f .devcontainer/Dockerfile .devcontainer\n"
376)
377
378# The same threat, hiding the tag behind a variable.
379BAD_INDIRECT = 'IMG="ra8-ci:latest"\npodman build -t "$IMG" .devcontainer\n'
380
381# A new Ansible role building ra8-ci through a folded block scalar.
382BAD_ANSIBLE = (
383 "- name: Build it\n"
384 " ansible.builtin.command:\n"
385 " cmd: >-\n"
386 " buildah bud --isolation chroot\n"
387 " -t ra8-ci:latest\n"
388 " -f ctx/.devcontainer/Dockerfile\n"
389 " ctx/.devcontainer\n"
390)
391
392# The ci_runner role: a legitimate, different subject via Jinja variables.
393OK_RUNNER = (
394 "- name: Build the runner image\n"
395 " ansible.builtin.command:\n"
396 " cmd: >-\n"
397 " buildah bud --isolation chroot\n"
398 " -t {{ ci_runner_image }}\n"
399 " -f ctx/runner/Dockerfile ctx/runner\n"
400)
401
402# A different image tag and context entirely is unrelated.
403OK_OTHER_TAG = (
404 'IMAGE_TAG="ra8-firmware-docs:latest"\n'
405 'docker build -t "$IMAGE_TAG" -f docs/container/Dockerfile docs/container\n'
406)
407
408BAD_LEGACY_RUN = "docker run --rm ra8-firmware-dev:latest just tests::build\n"
409BAD_OTHER_CONTEXT_TAG = (
410 "docker build -t local-dev:latest -f .devcontainer/Dockerfile .devcontainer\n"
411)
412BAD_INDIRECT_CONTEXT = (
413 'CONTEXT_DIR="$REPO_ROOT/.devcontainer"\n'
414 'docker build -t local-dev:latest -f "$CONTEXT_DIR/Dockerfile" "$CONTEXT_DIR"\n'
415)
416
417# ra8-ci as a runner label and a path -- no build at all.
418OK_LABEL_PATH = "runs-on: ra8-ci\nlabels: [ra8-ci]\ndir: /var/lib/ra8-ci/build-context\n"
419
420# Uses (not builds) the image, and the -t there is a tty flag, not a tag.
421OK_RUN_ONLY = "docker run -t ra8-ci:latest just ci\n"
422
423
424GOOD_RUNNER_CLEANUP = f"""
425- name: Build the devcontainer toolchain image (single source of truth)
426 ansible.builtin.command:
427 cmd: >-
428 buildah bud --label {runner_cleanup.MANAGED_IMAGE_LABEL}
429 --label {runner_cleanup.MANAGED_IMAGE_KIND}=devcontainer -t devcontainer .
430- name: Build the runner image (devcontainer + actions-runner)
431 ansible.builtin.command:
432 cmd: >-
433 buildah bud --label {runner_cleanup.MANAGED_IMAGE_LABEL}
434 --label {runner_cleanup.MANAGED_IMAGE_KIND}=runner -t runner .
435- name: Find superseded managed runner images
436 ansible.builtin.command:
437 argv:
438 - buildah
439 - images
440 - --filter
441 - label={runner_cleanup.MANAGED_IMAGE_LABEL}
442 - --filter
443 - label={runner_cleanup.MANAGED_IMAGE_KIND}=runner
444 - --filter
445 - dangling=true
446 - --quiet
447 - --no-trunc
448 register: ci_runner_dangling_runner_images
449 changed_when: false
450- name: Remove superseded managed runner images
451 ansible.builtin.command:
452 argv:
453 - buildah
454 - rmi
455 - "{{{{ item }}}}"
456 loop: "{{{{ ci_runner_dangling_runner_images.stdout_lines }}}}"
457 when: item | length > 0
458 changed_when: true
459- name: Find superseded managed devcontainer images
460 ansible.builtin.command:
461 argv:
462 - buildah
463 - images
464 - --filter
465 - label={runner_cleanup.MANAGED_IMAGE_LABEL}
466 - --filter
467 - label={runner_cleanup.MANAGED_IMAGE_KIND}=devcontainer
468 - --filter
469 - dangling=true
470 - --quiet
471 - --no-trunc
472 register: ci_runner_dangling_devcontainer_images
473 changed_when: false
474- name: Remove superseded managed devcontainer images
475 ansible.builtin.command:
476 argv:
477 - buildah
478 - rmi
479 - "{{{{ item }}}}"
480 loop: "{{{{ ci_runner_dangling_devcontainer_images.stdout_lines }}}}"
481 when: item | length > 0
482 changed_when: true
483- name: Inventory CRI images after publishing the current runner
484 ansible.builtin.command:
485 argv:
486 - k3s
487 - crictl
488 - images
489 - -o
490 - json
491 register: ci_runner_cri_images
492 when: not ansible_check_mode
493 changed_when: false
494- name: Reset the superseded runner image selection
495 ansible.builtin.set_fact:
496 ci_runner_stale_cri_images: []
497 ci_runner_image_repository: "{{{{ ci_runner_image | regex_replace(':[^/:]+$', '') }}}}"
498 when: not ansible_check_mode
499 changed_when: false
500- name: Select superseded untagged runner images
501 ansible.builtin.set_fact:
502 ci_runner_stale_cri_images: "{{{{ ci_runner_stale_cri_images + [item.id] }}}}"
503 loop: "{{{{ (ci_runner_cri_images.stdout | from_json).images }}}}"
504 when:
505 - not ansible_check_mode
506 - item.repoTags | default([]) | length == 0
507 - >-
508 item.repoDigests | default([])
509 | select(
510 'match',
511 '^' ~ (ci_runner_image_repository | regex_escape)
512 ~ '@sha256:[0-9a-f]{{64}}$'
513 )
514 | list | length > 0
515 changed_when: false
516- name: Remove superseded untagged runner images
517 ansible.builtin.command:
518 argv:
519 - k3s
520 - crictl
521 - rmi
522 - "{{{{ item }}}}"
523 loop: "{{{{ ci_runner_stale_cri_images }}}}"
524 when: not ansible_check_mode
525 changed_when: true
526- name: Install the ra8-ci runner scale set
527 kubernetes.core.helm:
528 force_conflicts: true
529"""
530
531
532GOOD_DOCKER_CLEANUP = f"""
533- name: Assert every container uses the validated Docker-native image
534 ansible.builtin.assert:
535 that: [true]
536- name: Find superseded managed Docker runner images
537 when: not ansible_check_mode
538 ansible.builtin.command:
539 argv:
540 - docker
541 - image
542 - ls
543 - --filter
544 - dangling=true
545 - --filter
546 - label={runner_cleanup.MANAGED_IMAGE_LABEL}
547 - --filter
548 - label={runner_cleanup.MANAGED_IMAGE_KIND}=runner
549 - --quiet
550 - --no-trunc
551 register: ci_runner_docker_dangling_images
552 changed_when: false
553- name: Remove superseded managed Docker runner images
554 when: not ansible_check_mode
555 ansible.builtin.command:
556 argv:
557 - docker
558 - image
559 - rm
560 - "{{{{ item }}}}"
561 loop: "{{{{ ci_runner_docker_dangling_images.stdout_lines }}}}"
562 changed_when: true
563- name: Read back the caps the kernel is actually enforcing
564 ansible.builtin.command:
565 argv: [docker, inspect]
566"""
567
568
569# Each case: (should the detector fire?, fixture text, assertion label).
570_DETECTION_CASES = (
571 (True, GOOD_SOLE, "the sole-builder shape is detected as a builder"),
572 (True, BAD_LITERAL, "a literal 'docker build -t ra8-ci:latest' is detected"),
573 (True, BAD_INDIRECT, "a variable-indirected ra8-ci build is detected"),
574 (True, BAD_ANSIBLE, "a folded-YAML buildah bud of ra8-ci is detected"),
575 (False, OK_RUNNER, "the ci_runner Jinja-var build is NOT flagged"),
576 (False, OK_OTHER_TAG, "an unrelated image build is NOT flagged"),
577 (False, OK_LABEL_PATH, "ra8-ci as a label/path is NOT flagged"),
578 (False, OK_RUN_ONLY, "'docker run -t ra8-ci' (run, not build) is NOT flagged"),
579)
580
581
582def _selftest_detection(failures: list[str]) -> None:
583 """Assert ci_tags_built fires and stays quiet on the right inputs."""
584 for should_fire, text, label in _DETECTION_CASES:
585 expect(bool(ci_tags_built(text)) is should_fire, label, failures)
586 expect(
587 bool(legacy_dev_images(BAD_LEGACY_RUN)),
588 "a retired developer image is detected even on run-only use",
589 failures,
590 )
591 expect(
592 not legacy_dev_images(OK_RUN_ONLY),
593 "the canonical ra8-ci run is not a retired-image finding",
594 failures,
595 )
596 expect(
597 builds_devcontainer_context(BAD_OTHER_CONTEXT_TAG),
598 "a differently-tagged devcontainer build is detected",
599 failures,
600 )
601 expect(
602 builds_devcontainer_context(BAD_INDIRECT_CONTEXT),
603 "a variable-indirected devcontainer context is detected",
604 failures,
605 )
606 expect(
607 not builds_devcontainer_context(OK_OTHER_TAG),
608 "an unrelated image context stays out of scope",
609 failures,
610 )
611
612
613def _selftest_runner_cleanup(failures: list[str]) -> None:
614 """Assert ownership and dangling-only cleanup are both mandatory."""
615 expect(
616 not runner_cleanup.errors(GOOD_RUNNER_CLEANUP),
617 "an owned dangling-image cleanup is accepted",
618 failures,
619 )
620 unsafe = GOOD_RUNNER_CLEANUP.replace("dangling=true", "dangling=false", 1)
621 expect(
622 bool(runner_cleanup.errors(unsafe)),
623 "a non-dangling cleanup selector is rejected",
624 failures,
625 )
626 pin_filtered = GOOD_RUNNER_CLEANUP.replace(
627 " - item.repoTags | default([]) | length == 0\n",
628 " - item.repoTags | default([]) | length == 0\n - not item.pinned | default(false)\n",
629 )
630 expect(
631 bool(runner_cleanup.errors(pin_filtered)),
632 "a cleanup that preserves pinned stale generations is rejected",
633 failures,
634 )
635 unsafe_helm = GOOD_RUNNER_CLEANUP.replace("force_conflicts: true", "force_conflicts: false")
636 expect(
637 bool(runner_cleanup.errors(unsafe_helm)),
638 "a Helm deploy that cannot reclaim transient field ownership is rejected",
639 failures,
640 )
641 good_capacity = (
642 "kc patch autoscalingrunnerset -n namespace scale-set \\\n"
643 ' --field-manager=helm --type=merge -p \'{"spec":{"maxRunners":0}}\'\n'
644 )
645 expect(
646 not runner_cleanup.capacity_field_manager_errors(good_capacity),
647 "the ARC capacity patch shares Helm's field manager",
648 failures,
649 )
650 expect(
651 bool(
652 runner_cleanup.capacity_field_manager_errors(
653 good_capacity.replace("--field-manager=helm ", "")
654 )
655 ),
656 "an independently managed ARC capacity patch is rejected",
657 failures,
658 )
659 expect(
660 not runner_cleanup.consumer_errors(GOOD_DOCKER_CLEANUP),
661 "an owned dangling Docker image cleanup is accepted",
662 failures,
663 )
664 broad_consumer = GOOD_DOCKER_CLEANUP.replace("dangling=true", "dangling=false")
665 expect(
666 bool(runner_cleanup.consumer_errors(broad_consumer)),
667 "a non-dangling Docker consumer cleanup is rejected",
668 failures,
669 )
670
671
672def _selftest_end_to_end(failures: list[str]) -> None:
673 """Assert the offender computation over a synthetic tree, both directions."""
674 # A stand-in for a resurrected second builder; no such file exists.
675 second = "scripts/ci/inner-local.sh" # PATHREF-OK: selftest fixture path
676 with tempfile.TemporaryDirectory() as tmp:
677 root = Path(tmp)
678 (root / "scripts/ci").mkdir(parents=True)
679 (root / "scripts/checks").mkdir(parents=True)
680 (root / SOLE_BUILDER).write_text(GOOD_SOLE, encoding="utf-8")
681 rels = [SOLE_BUILDER]
682
683 builders = find_builders(root, rels)
684 offenders = sorted(set(builders) - {SOLE_BUILDER})
685 expect(
686 SOLE_BUILDER in builders,
687 "clean tree: the sole builder is detected (the floor)",
688 failures,
689 )
690 expect(not offenders, "clean tree: no offenders", failures)
691
692 (root / second).write_text(BAD_LITERAL, encoding="utf-8")
693 rels.append(second)
694 builders = find_builders(root, rels)
695 offenders = sorted(set(builders) - {SOLE_BUILDER})
696 expect(
697 offenders == [second],
698 "a second builder is reported as an offender",
699 failures,
700 )
701
702
703def _selftest_extended_end_to_end(failures: list[str]) -> None:
704 """Assert context/tag regressions are reported over a synthetic tree."""
705 context_rel = "just/devcontainer.just" # PATHREF-OK: selftest fixture path
706 legacy_rel = "scripts/ci/old-wrapper.sh" # PATHREF-OK: selftest fixture path
707 with tempfile.TemporaryDirectory() as tmp:
708 root = Path(tmp)
709 (root / "just").mkdir()
710 (root / "scripts/ci").mkdir(parents=True)
711 (root / context_rel).write_text(BAD_OTHER_CONTEXT_TAG, encoding="utf-8")
712 (root / legacy_rel).write_text(BAD_LEGACY_RUN, encoding="utf-8")
713 rels = [context_rel, legacy_rel]
714 expect(
715 find_context_builders(root, rels) == [context_rel],
716 "a second devcontainer-context builder is reported",
717 failures,
718 )
719 expect(
720 sorted(find_legacy_images(root, rels)) == [legacy_rel],
721 "a retired developer image use is reported",
722 failures,
723 )
724
725
726def _selftest_floor_on_real_tree(failures: list[str]) -> None:
727 """The real tree must still contain the sole builder -- the non-vacuity floor."""
728 resolved = True
729 try:
730 root = _repo_root()
731 except (subprocess.CalledProcessError, FileNotFoundError):
732 resolved = False
733 expect(resolved, "git rev-parse resolves the repo root", failures)
734 if not resolved:
735 return
736 builders = find_builders(root, scoped_files(root))
737 offenders = sorted(set(builders) - {SOLE_BUILDER})
738 context_builders = find_context_builders(root, scoped_files(root))
739 allowed_context_builders = {SOLE_BUILDER, DEPLOYED_RUNNER_BUILDER}
740 context_offenders = sorted(set(context_builders) - allowed_context_builders)
741 legacy = find_legacy_images(root, scoped_files(root))
742 expect(
743 SOLE_BUILDER in builders,
744 f"the real tree still detects {SOLE_BUILDER} as the builder",
745 failures,
746 )
747 expect(not offenders, f"the real tree has no second builder (saw {offenders})", failures)
748 expect(
749 not context_offenders,
750 f"the real tree has no second devcontainer-context builder (saw {context_offenders})",
751 failures,
752 )
753 expect(not legacy, f"the real tree has no retired developer image tag (saw {legacy})", failures)
754 runner_source = (root / DEPLOYED_RUNNER_BUILDER).read_text(encoding="utf-8")
755 cleanup_errors = runner_cleanup.errors(runner_source)
756 expect(
757 not cleanup_errors,
758 f"the deployed runner producer cleans only owned dangling images ({cleanup_errors})",
759 failures,
760 )
761 capacity_errors = runner_cleanup.capacity_field_manager_errors(
762 (root / CAPACITY_HELPER).read_text(encoding="utf-8")
763 )
764 expect(
765 not capacity_errors,
766 f"the ARC capacity patch shares Helm field ownership ({capacity_errors})",
767 failures,
768 )
769 consumer_errors = runner_cleanup.consumer_errors(
770 (root / DOCKER_RUNNER_DEPLOY).read_text(encoding="utf-8")
771 )
772 expect(
773 not consumer_errors,
774 f"Docker runner consumers clean only owned dangling images ({consumer_errors})",
775 failures,
776 )
777
778
779def selftest() -> int:
780 """Prove the detector fires and stays quiet, and that the floor holds."""
781 print("check_ci_image_single_builder.py --selftest")
782 failures: list[str] = []
783 _selftest_detection(failures)
784 _selftest_runner_cleanup(failures)
785 _selftest_end_to_end(failures)
786 _selftest_extended_end_to_end(failures)
787 _selftest_floor_on_real_tree(failures)
788 return report(failures)
789
790
791def report_extended_violations(root: Path, rels: list[str]) -> bool:
792 """Report direct context builders and retired developer image names."""
793 failed = False
794 context_builders = find_context_builders(root, rels)
795 allowed_context_builders = {SOLE_BUILDER, DEPLOYED_RUNNER_BUILDER}
796 context_offenders = sorted(set(context_builders) - allowed_context_builders)
797 for rel in context_offenders:
798 print(
799 f" {rel}: directly builds .devcontainer under a second image contract",
800 file=sys.stderr,
801 )
802 failed = True
803 legacy = find_legacy_images(root, rels)
804 for rel, names in sorted(legacy.items()):
805 print(f" {rel}: uses retired developer image {' '.join(names)}", file=sys.stderr)
806 failed = True
807 cleanup_errors = runner_cleanup.errors(
808 (root / DEPLOYED_RUNNER_BUILDER).read_text(encoding="utf-8")
809 )
810 cleanup_errors.extend(
811 runner_cleanup.capacity_field_manager_errors(
812 (root / CAPACITY_HELPER).read_text(encoding="utf-8")
813 )
814 )
815 cleanup_errors.extend(
816 runner_cleanup.consumer_errors((root / DOCKER_RUNNER_DEPLOY).read_text(encoding="utf-8"))
817 )
818 for message in cleanup_errors:
819 print(f" {message}", file=sys.stderr)
820 failed = True
821 if failed:
822 print(
823 f"Route writable developer runs through scripts/ci/devcontainer_run.sh;\n"
824 f"only {SOLE_BUILDER} may build its digest-labelled image.",
825 file=sys.stderr,
826 )
827 return failed
828
829
830def main(argv: list[str]) -> int:
831 """Fail if any file but the sole builder builds the ``ra8-ci`` image."""
832 ap = argparse.ArgumentParser(description="One builder for ra8-ci:latest, and only one.")
833 ap.add_argument("--selftest", action="store_true", help="assert both directions")
834 ap.add_argument("--list", action="store_true", help="print every detected ra8-ci builder")
835 args = ap.parse_args(argv[1:])
836
837 if args.selftest:
838 return selftest()
839
840 root = _repo_root()
841 rels = scoped_files(root)
842 builders = find_builders(root, rels)
843
844 if args.list:
845 for rel in sorted(builders):
846 print(f"{rel}: {' '.join(builders[rel])}")
847 return 0
848
849 # Non-vacuity floor: if the one builder we KNOW exists is not detected, the
850 # reconstruction has broken and every "clean" verdict below is worthless.
851 if SOLE_BUILDER not in builders:
852 print(
853 "check_ci_image_single_builder.py: FATAL -- the known builder\n"
854 f" {SOLE_BUILDER}\n"
855 " was NOT detected building ra8-ci. Either the file was renamed (update\n"
856 " SOLE_BUILDER in this checker) or the command reconstruction stopped\n"
857 " matching (a collapsed scan must fail, not report clean).",
858 file=sys.stderr,
859 )
860 return 2
861
862 offenders = sorted(set(builders) - {SOLE_BUILDER})
863 if offenders:
864 print(
865 f"\n{len(offenders)} file(s) build the ra8-ci image besides {SOLE_BUILDER}:\n",
866 file=sys.stderr,
867 )
868 for rel in offenders:
869 print(f" {rel}: builds {' '.join(builders[rel])}", file=sys.stderr)
870 print(
871 "\nra8-ci:latest must have exactly one builder so its context-digest\n"
872 "staleness guarantee cannot be bypassed (#521, #528). Route this build\n"
873 f"through {SOLE_BUILDER}, or -- if it is a different image -- give it a\n"
874 "different tag (the ci_runner role builds ra8-ci-runner / ra8-devcontainer).",
875 file=sys.stderr,
876 )
877 return 1
878
879 if report_extended_violations(root, rels):
880 return 1
881
882 print(
883 "check_ci_image_single_builder.py: one digest-labelled developer image; "
884 f"{SOLE_BUILDER} is its only builder."
885 )
886 return 0
887
888
889if __name__ == "__main__":
890 sys.exit(main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298