ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_devcontainer.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"""Lint and format-check the devcontainer: the Dockerfile and the zshrc.
5
6WHY THESE TWO FILES, TOGETHER
7=============================
8`.devcontainer/Dockerfile` pins every tool version CI resolves -- clang-format
922, the ARM toolchain by sha256, ruff, shellcheck, shfmt, cmake-format,
10yamllint, actionlint. A defect in it changes what every other gate in this
11repository runs. It was, until #371, linted and formatted by nothing at all.
12`.devcontainer/zshrc` is copied to ~/.zshrc at image build time and is the
13other half of the same artefact. They are checked as one unit because they are
14one deliverable: the container.
15
16THE TOOLS, AND WHY EACH ONE
17---------------------------
18Dockerfile -> hadolint (pinned 2.14.0). The obvious and only serious candidate:
19 a single static binary, the same provisioning shape actionlint and shfmt
20 already use. It found a real defect on its first run -- DL4006, eight
21 `curl -fsSL <url> | tar -x` pipelines running under `/bin/sh -c`, where the
22 pipeline's status is that of `tar` alone. A failed download produced an empty
23 tarball, tar succeeded on it, and the image was built with the pinned tool
24 silently absent. That is precisely the class check_errexit_masking.py exists
25 to catch in shell, and it was live in the file that provisions CI. Fixed by
26 the `SHELL ["/bin/bash", "-o", "pipefail", "-c"]` directive. Rule
27 configuration and the one deliberate ignore live in .hadolint.yaml.
28
29zshrc -> `zsh -n` (zsh 5.9, already in the image; no new provisioning).
30 There is no zsh linter and no zsh formatter, and this was checked rather than
31 assumed:
32
33 * ShellCheck refuses zsh outright ("ShellCheck only supports sh/bash/dash/
34 ksh"). Running it as `--shell=bash` is worse than not running it: this
35 very file uses `${(%):-%n}` and `plugins=(...)`, zsh parameter-expansion
36 flags and array syntax that bash cannot parse, so bash-mode ShellCheck
37 reports errors that are not errors. A linter that is wrong about the
38 file is not partial coverage, it is noise that trains people to ignore
39 the gate. That is why lint_coverage_rules.py classifies `.zsh` as its
40 own class rather than folding it into `shell`.
41 * shfmt likewise handles sh/bash/mksh only.
42 * `zsh -n` parses the file with the real zsh grammar and reports syntax
43 errors without executing anything. It is a genuine check, it is the only
44 one that exists, and it is what runs here.
45
46 Being honest about the limit: `zsh -n` catches syntax, not semantics. It
47 would not notice a misspelled plugin name. That is the ceiling of what is
48 available for the language, and it is still strictly more than the nothing
49 this file had before.
50
51FORMAT ROLE
52-----------
53Neither tool rewrites its input, so the formatter half is a canonical-form
54checker -- the same arrangement lint_coverage_rules.py already documents for
55justfiles and linker scripts, where nothing in the ecosystem rewrites the file
56either. DC001 rejects the layout deviations that a formatter would otherwise
57fix: non-ASCII bytes, CRLF, a missing final newline, tab indentation and
58trailing whitespace.
59
60Run with --selftest to prove both linters fire on deliberately broken input and
61stay quiet on the real files.
62"""
63
64from __future__ import annotations
65
66import argparse
67import re
68import shutil
69import subprocess
70import sys
71import tempfile
72from pathlib import Path
73
74sys.path.insert(0, str(Path(__file__).resolve().parent))
75
76from lint_coverage_rules import EXT_CLASS, NAME_CLASS
77from selftest_assert import expect, report
78
79# Vendored SOUP is governed by its upstream boundary, never by this checker.
80THIRD_PARTY_PREFIXES = ("libs/third_party/", "apps/shared_libs/third_party/")
81
82REPO_ROOT = Path(
83 subprocess.run(
84 ["git", "rev-parse", "--show-toplevel"], # noqa: S607 -- trusted: fixed git argv
85 capture_output=True,
86 text=True,
87 check=True,
88 ).stdout.strip()
89)
90
91# The classes this checker claims. Membership is decided by the SHARED tables in
92# lint_coverage_rules.py, never by a private copy of them here: a second
93# classification map is how the coverage question became unanswerable in the
94# first place (#296, #332, #358, #359, #360).
95OWNED_CLASSES = ("dockerfile", "zsh")
96
97HADOLINT_VERSION = "2.14.0"
98
99# Both files must be found. An empty scan means the enumeration broke, and a
100# gate must never mistake that for a clean tree.
101FILE_FLOOR = 2
102
103
104class Finding:
105 """One rule violation, reported as path:line: [CODE] message."""
106
107 def __init__(self, rel: str, line: int, code: str, msg: str) -> None:
108 """Record one finding; all four fields are required and none is derived."""
109 self.rel, self.line, self.code, self.msg = rel, line, code, msg
110
111 def __str__(self) -> str:
112 """Render as ``rel:line: [CODE] message`` -- editor-jumpable."""
113 return f"{self.rel}:{self.line}: [{self.code}] {self.msg}"
114
115
116def classify(rel: str) -> str | None:
117 """The shared class of `rel`, by exact name then extension."""
118 name = rel.rsplit("/", 1)[-1]
119 if name in NAME_CLASS:
120 return NAME_CLASS[name]
121 if "." in name[1:]:
122 suffix = "." + name.rsplit(".", 1)[-1]
123 return EXT_CLASS.get(suffix.lower())
124 return None
125
126
127def targets() -> list[str]:
128 """Every first-party Dockerfile and zsh file, repo-relative and sorted."""
129 proc = subprocess.run(
130 ["git", "ls-files", "-z"], # noqa: S607 -- git from PATH is intended
131 cwd=REPO_ROOT,
132 capture_output=True,
133 text=True,
134 check=True,
135 )
136 # The vendored powerlevel10k theme config is not a project script; the same
137 # exemption lint_coverage_rules.py records for it.
138 return sorted(
139 rel
140 for rel in proc.stdout.split("\0")
141 if rel
142 and not rel.startswith(THIRD_PARTY_PREFIXES)
143 and rel != ".devcontainer/p10k.zsh"
144 and classify(rel) in OWNED_CLASSES
145 )
146
147
148def check_format(rel: str, raw: bytes) -> list[Finding]:
149 """DC001 -- the canonical-form rules, on raw bytes."""
150 findings: list[Finding] = []
151
152 def add(line: int, msg: str) -> None:
153 """Append one DC001 finding, closing over this file's relative path."""
154 findings.append(Finding(rel, line, "DC001", msg))
155
156 try:
157 text = raw.decode("ascii")
158 except UnicodeDecodeError as exc:
159 add(raw[: exc.start].count(b"\n") + 1, f"non-ASCII byte 0x{raw[exc.start]:02x}")
160 text = raw.decode("ascii", errors="replace")
161 if b"\r\n" in raw:
162 add(raw.split(b"\r\n")[0].count(b"\n") + 1, "CRLF line ending")
163 if raw and not raw.endswith(b"\n"):
164 add(text.count("\n") + 1, "no final newline")
165 for num, line in enumerate(text.splitlines(), start=1):
166 if line.startswith("\t"):
167 add(num, "tab indentation (use spaces)")
168 if line != line.rstrip():
169 add(num, "trailing whitespace")
170 return findings
171
172
173def check_uv_execution(rel: str, text: str) -> list[Finding]:
174 """DC004 -- execute uv only through the authenticated bootstrap runner."""
175 boundary = "WORKDIR /opt/ra8-python-project\n"
176 if boundary not in text:
177 return []
178 start = text.find(boundary) + len(boundary)
179 environment = 'ENV PYTHONDONTWRITEBYTECODE="1"'
180 end = text.find(f"\n{environment}", start)
181 if text.count(boundary) != 1 or text.count(environment) != 1 or end < 0:
182 return [Finding(rel, 1, "DC004", "uv provisioning block is not structurally bounded")]
183 block = text[start:end]
184 normalized = " ".join(block.replace("\\\n", " ").split())
185 expected = " ".join(EXPECTED_UV_RUN_BLOCK.replace("\\\n", " ").split())
186 raw_uv = re.compile(r"(?:^|[;&|]\s*)(?:[^\s;]+/)?uv(?:\s|$)")
187 other_raw = any(
188 raw_uv.search(" ".join(instruction.replace("\\\n", " ").split()))
189 for instruction in re.findall(r"(?ms)^RUN .*?(?=\n(?:RUN|ENV|ARG|COPY|WORKDIR|#)|\Z)", text)
190 if instruction != block
191 )
192 unsafe = normalized != expected or other_raw
193 if not unsafe:
194 return []
195 line = text[:start].count("\n") + 1
196 return [Finding(rel, line, "DC004", "uv execution bypasses authenticated bytes")]
197
198
199def run_hadolint(rel: str, path: Path) -> list[Finding]:
200 """DC002 -- hadolint over one Dockerfile. Config comes from .hadolint.yaml.
201
202 hadolint prints ``<file>:<line> <CODE> <severity>: <message>``. That leading
203 ``<file>:<line>`` is re-parsed rather than passed through, so a finding reads
204 like every other checker here instead of carrying a second, absolute copy of
205 the path inside the message.
206 """
207 proc = subprocess.run( # noqa: S603 -- fixed argv
208 ["hadolint", "--no-color", str(path)], # noqa: S607 -- provisioned on PATH
209 cwd=REPO_ROOT,
210 capture_output=True,
211 text=True,
212 check=False,
213 )
214 findings: list[Finding] = []
215 prefix = f"{path}:"
216 for raw in (proc.stdout + proc.stderr).splitlines():
217 line = raw.strip()
218 if not line:
219 continue
220 num = 0
221 if line.startswith(prefix):
222 rest = line[len(prefix) :]
223 head, _, tail = rest.partition(" ")
224 if head.isdigit():
225 num, line = int(head), tail
226 findings.append(Finding(rel, num, "DC002", line))
227 return findings
228
229
230def run_zsh_syntax(rel: str, path: Path) -> list[Finding]:
231 """DC003 -- `zsh -n` over one zsh file. Parses only; never executes."""
232 proc = subprocess.run( # noqa: S603 -- fixed argv
233 ["zsh", "-n", str(path)], # noqa: S607 -- zsh is in the image
234 cwd=REPO_ROOT,
235 capture_output=True,
236 text=True,
237 check=False,
238 )
239 if proc.returncode == 0:
240 return []
241 detail = (proc.stderr + proc.stdout).strip() or f"zsh -n exited {proc.returncode}"
242 # zsh reports `<file>:<line>: <message>`; drop its copy of the path for the
243 # same reason as hadolint's above.
244 num = 0
245 prefix = f"{path}:"
246 if detail.startswith(prefix):
247 rest = detail[len(prefix) :]
248 head, _, tail = rest.partition(":")
249 if head.isdigit():
250 num, detail = int(head), tail.strip()
251 return [Finding(rel, num, "DC003", f"zsh syntax error: {detail}")]
252
253
254def check_one(rel: str, root: Path = REPO_ROOT) -> list[Finding]:
255 """Every finding for one devcontainer file."""
256 path = root / rel
257 findings = check_format(rel, path.read_bytes())
258 cls = classify(rel)
259 if cls == "dockerfile":
260 findings.extend(check_uv_execution(rel, path.read_text(encoding="ascii")))
261 findings.extend(run_hadolint(rel, path))
262 elif cls == "zsh":
263 findings.extend(run_zsh_syntax(rel, path))
264 return findings
265
266
267def require_tools() -> int:
268 """Fail loudly when a linter is absent. A gate must never degrade to a no-op."""
269 missing = []
270 if shutil.which("hadolint") is None:
271 missing.append(
272 f"hadolint (pinned {HADOLINT_VERSION}) -- https://github.com/hadolint/hadolint/releases"
273 )
274 if shutil.which("zsh") is None:
275 missing.append("zsh -- apt-get install zsh (already in the devcontainer image)")
276 if missing:
277 print("check_devcontainer.py: FATAL -- required tool(s) absent:", file=sys.stderr)
278 for item in missing:
279 print(f" {item}", file=sys.stderr)
280 print(
281 " This gate FAILS rather than skipping: a checker whose tool is\n"
282 " missing reports nothing, and nothing is indistinguishable from clean.",
283 file=sys.stderr,
284 )
285 return 1
286 return 0
287
288
289GOOD_DOCKERFILE = b"""FROM ubuntu:24.04
290SHELL ["/bin/bash", "-o", "pipefail", "-c"]
291RUN echo hello
292"""
293
294# DL3020 (use COPY not ADD) and DL4006 (a pipe with no pipefail): two families,
295# so a single over-broad ignore in .hadolint.yaml cannot silence the selftest.
296BAD_DOCKERFILE = b"""FROM ubuntu:24.04
297ADD ./x /x
298RUN curl -fsSL http://example.com/a.tgz | tar -xz
299"""
300
301EXPECTED_UV_RUN_BLOCK = (
302 "RUN set -eux; "
303 'UV_PROJECT_ENVIRONMENT="${PYTHON_TOOL_VENV}" UV_PYTHON_DOWNLOADS=never '
304 "/usr/bin/python3 -I -S /opt/ra8-uv-bootstrap/bootstrap_uv.py "
305 "--cache-root /opt/ra8-uv-cache --ensure-and-run --no-config sync "
306 "--locked --only-group ci --no-install-project --python /usr/bin/python3; "
307 "UV_PYTHON_DOWNLOADS=never "
308 "/usr/bin/python3 -I -S /opt/ra8-uv-bootstrap/bootstrap_uv.py "
309 "--cache-root /opt/ra8-uv-cache --run --no-config lock --check; "
310 "UV_PYTHON_DOWNLOADS=never "
311 "/usr/bin/python3 -I -S /opt/ra8-uv-bootstrap/bootstrap_uv.py "
312 "--cache-root /opt/ra8-uv-cache --run --no-config pip check "
313 '--python "${PYTHON_TOOL_VENV}/bin/python3"; '
314 '"${PYTHON_TOOL_VENV}/bin/python3" -c '
315 '"import PIL, clang.cindex, dotenv, kasa, serial, usb.core, yaml"; '
316 '"${PYTHON_TOOL_VENV}/bin/ruff" --version; '
317 '"${PYTHON_TOOL_VENV}/bin/cmake-format" --version; '
318 '"${PYTHON_TOOL_VENV}/bin/yamllint" --version; '
319 '"${PYTHON_TOOL_VENV}/bin/gcovr" --version; '
320 'rm -f -- "${PYTHON_TOOL_VENV}/.lock"'
321)
322
323GOOD_UV_BLOCK = (
324 "WORKDIR /opt/ra8-python-project\n"
325 f"{EXPECTED_UV_RUN_BLOCK}\n"
326 'ENV PYTHONDONTWRITEBYTECODE="1" \\\n'
327 ' PYTHONNOUSERSITE="1" \\\n'
328 ' RA8_TOOL_VENV="${PYTHON_TOOL_VENV}"\n'
329)
330
331GOOD_ZSHRC = b"""export PATH="$HOME/.local/bin:$PATH"
332if [[ -r ~/.p10k.zsh ]]; then
333 source ~/.p10k.zsh
334fi
335"""
336
337BAD_ZSHRC = b"if [[ -r ~/.p10k.zsh ]; then\n source ~/.p10k.zsh\n"
338
339
340def _assert_dockerfile(root: Path, failures: list[str]) -> None:
341 """Assert hadolint fires on a bad Dockerfile and is silent on a clean one."""
342 (root / ".devcontainer/Dockerfile").write_bytes(GOOD_DOCKERFILE)
343 got = check_one(".devcontainer/Dockerfile", root)
344 expect(not got, f"a clean Dockerfile yields no findings (got {got})", failures)
345
346 (root / ".devcontainer/Dockerfile").write_bytes(BAD_DOCKERFILE)
347 got = check_one(".devcontainer/Dockerfile", root)
348 blob = " ".join(f.msg for f in got)
349 expect(any(f.code == "DC002" for f in got), "hadolint fires on a bad Dockerfile", failures)
350 expect("DL3020" in blob, " ... reporting DL3020 (ADD instead of COPY)", failures)
351 expect("DL4006" in blob, " ... reporting DL4006 (pipe without pipefail)", failures)
352
353
354def _assert_uv_execution(failures: list[str]) -> None:
355 """Assert authenticated uv execution passes and captured-path execution fails."""
356 got = check_uv_execution(".devcontainer/Dockerfile", GOOD_UV_BLOCK)
357 expect(not got, f"authenticated uv Docker execution is clean (got {got})", failures)
358 mutations = (
359 ("RUN set -eux;", "RUN set -eux; /tmp/uv --version;", "raw uv execution"),
360 ("--run --no-config lock", "--verify-cache --no-config lock", "mode replacement"),
361 ("--run --no-config pip", "--no-config --run pip", "argv reordering"),
362 ("--no-config lock --check;", "--no-config lock --check || true;", "status masking"),
363 (
364 'ENV PYTHONDONTWRITEBYTECODE="1"',
365 'ENV PYTHONDONTWRITEBYTECODE="0"',
366 "bytecode protection removal",
367 ),
368 )
369 for old, new, label in mutations:
370 bad = GOOD_UV_BLOCK.replace(old, new, 1)
371 got = check_uv_execution(".devcontainer/Dockerfile", bad)
372 expect(any(finding.code == "DC004" for finding in got), f"{label} fires DC004", failures)
373
374
375def _assert_zshrc(root: Path, failures: list[str]) -> None:
376 """Assert zsh -n fires on a broken zshrc and accepts zsh-only syntax.
377
378 The negative case is the whole reason this file's class is ``zsh`` and not
379 ``shell``: bash cannot parse either of those lines, so checking them with
380 bash would report a syntax error in a correct file.
381 """
382 (root / ".devcontainer/zshrc").write_bytes(GOOD_ZSHRC)
383 got = check_one(".devcontainer/zshrc", root)
384 expect(not got, f"a clean zshrc yields no findings (got {got})", failures)
385
386 (root / ".devcontainer/zshrc").write_bytes(BAD_ZSHRC)
387 got = check_one(".devcontainer/zshrc", root)
388 expect(
389 any(f.code == "DC003" for f in got),
390 "zsh -n fires on a zshrc with a syntax error",
391 failures,
392 )
393
394 (root / ".devcontainer/zshrc").write_bytes(
395 b'H=${(%):-%n}\nplugins=(git gh docker)\nprint -r -- "$H ${#plugins}"\n'
396 )
397 got = check_one(".devcontainer/zshrc", root)
398 expect(
399 not got,
400 f"zsh-specific syntax is accepted, not flagged (got {got})",
401 failures,
402 )
403
404
405def _assert_format_rules(failures: list[str]) -> None:
406 """Assert each DC001 byte-level formatting rule fires on its own payload."""
407 for payload, want, label in (
408 (b"FROM x\n\tRUN y\n", "tab indentation", "tab indentation fires DC001"),
409 (b"FROM x \n", "trailing whitespace", "trailing whitespace fires DC001"),
410 (b"FROM x", "no final newline", "a missing final newline fires DC001"),
411 (b"FROM x\r\ny\n", "CRLF", "a CRLF ending fires DC001"),
412 (b"FROM \xc2\xa9\n", "non-ASCII", "a non-ASCII byte fires DC001"),
413 ):
414 got = check_format("f", payload)
415 expect(any(want in f.msg for f in got), label, failures)
416
417
418def _assert_scope(failures: list[str]) -> None:
419 """Assert the real scope resolves both files and excludes the vendored theme."""
420 found = targets()
421 expect(
422 len(found) >= FILE_FLOOR,
423 f"the real scope resolves both files (got {found})",
424 failures,
425 )
426 expect(
427 ".devcontainer/p10k.zsh" not in found,
428 "the vendored p10k theme config stays out of scope",
429 failures,
430 )
431
432
433def selftest() -> int:
434 """Assert both linters fire on broken input and stay quiet on good input."""
435 print("check_devcontainer.py --selftest")
436 failures: list[str] = []
437
438 if require_tools() != 0:
439 return 1
440
441 with tempfile.TemporaryDirectory() as tmp:
442 root = Path(tmp)
443 (root / ".devcontainer").mkdir()
444 _assert_dockerfile(root, failures)
445 _assert_uv_execution(failures)
446 _assert_zshrc(root, failures)
447 _assert_format_rules(failures)
448
449 _assert_scope(failures)
450 return report(failures)
451
452
453def main(argv: list[str]) -> int:
454 """Lint and format-check the devcontainer definition.
455
456 The devcontainer is what gives the Mac an Ubuntu userland to run the CI
457 suite in, so a broken definition does not fail loudly -- it makes `just
458 ci` unable to run at all, on the one platform that cannot verify natively.
459
460 Returns 0 when clean, 1 on any finding or a failing selftest.
461 """
462 ap = argparse.ArgumentParser(description="Lint and format-check the devcontainer files")
463 ap.add_argument("--selftest", action="store_true", help="assert both directions")
464 ap.add_argument("--list-files", action="store_true", help="print the scanned file list")
465 args = ap.parse_args(argv[1:])
466
467 if args.selftest:
468 return selftest()
469
470 files = targets()
471 if args.list_files:
472 print("\n".join(files))
473 return 0
474
475 if require_tools() != 0:
476 return 2
477 if len(files) < FILE_FLOOR:
478 print(
479 f"check_devcontainer.py: FATAL -- {len(files)} file(s) found, floor is "
480 f"{FILE_FLOOR}. An empty scan reports success because it saw nothing.",
481 file=sys.stderr,
482 )
483 return 2
484
485 findings: list[Finding] = []
486 for rel in files:
487 findings.extend(check_one(rel))
488 if findings:
489 print(
490 f"\n{len(findings)} finding(s) in {len(files)} devcontainer file(s):\n", file=sys.stderr
491 )
492 for finding in findings:
493 print(f" {finding}", file=sys.stderr)
494 return 1
495 print(f"check_devcontainer.py: {len(files)} file(s), no findings.")
496 return 0
497
498
499if __name__ == "__main__":
500 sys.exit(main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298