Files
computer-playbook/tests/lint/docker/dockerfile/test_from_parameterized.py
T
kevinveenbirkenbachandClaude Opus 4.7 20745cecbd test(lint): enforce parameterized FROM in role Dockerfiles
Add tests/lint/docker/dockerfile/test_from_parameterized.py to fail any
roles/*/{files,templates}/**/Dockerfile* whose FROM does not reference an
ARG (${VAR}) or Jinja variable ({{ var }}). FROM scratch and a
`# nocheck: from-parameterized` per-line marker are exempt.

Refactor web-app-bluesky login-broker Dockerfile to satisfy the rule:
surface the base image and version via services.broker.{base_image,base_version}
-> vars/main.yml -> docker-compose build args -> ARGs in the Dockerfile.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 20:43:18 +02:00

92 lines
3.3 KiB
Python

"""Enforce parameterized FROM lines in role Dockerfiles.
Every ``FROM`` instruction in ``roles/*/files/**/Dockerfile`` and
``roles/*/templates/**/Dockerfile.j2`` MUST reference at least one
variable (Docker ``${...}`` ARG or Jinja2 ``{{ ... }}`` placeholder).
Hardcoded image references like ``FROM python:3.13-slim`` are forbidden
because the base image and tag MUST be surfaced via the role's
``meta/services.yml`` -> ``vars/main.yml`` -> build-arg chain so
operators can pin or override the base without editing the Dockerfile.
The well-known sentinel ``FROM scratch`` is allowed since it has no
upstream image to parameterize. To opt a single FROM out of the rule
(e.g. a multi-stage helper that pins to a specific scratch-built tool
image), append the comment marker ``# nocheck: from-parameterized`` on
the same line.
"""
from __future__ import annotations
import re
import unittest
from typing import TYPE_CHECKING
from utils.cache.files import read_text
from . import PROJECT_ROOT
if TYPE_CHECKING:
from pathlib import Path
_REPO_ROOT = PROJECT_ROOT
_ROLES_ROOT = _REPO_ROOT / "roles"
_FROM_RE = re.compile(r"^\s*FROM\s+(.+?)\s*$", re.IGNORECASE)
_ARG_VAR_RE = re.compile(r"\$\{[A-Z_][A-Z0-9_]*\}|\{\{\s*[A-Za-z_][\w.]*\s*\}\}")
_NOCHECK_RE = re.compile(r"#\s*nocheck:\s*from-parameterized\b")
def _collect_role_dockerfiles() -> list[Path]:
candidates: list[Path] = []
candidates.extend(sorted(_ROLES_ROOT.glob("*/files/**/Dockerfile")))
candidates.extend(sorted(_ROLES_ROOT.glob("*/templates/**/Dockerfile.j2")))
return candidates
def _from_violations(dockerfile: Path) -> list[tuple[int, str]]:
violations: list[tuple[int, str]] = []
for lineno, raw in enumerate(read_text(str(dockerfile)).splitlines(), start=1):
# Strip the trailing AS <stage> alias so multi-stage builds don't
# confuse the variable detection on the image reference itself.
line = raw.split("#", 1)[0]
match = _FROM_RE.match(line)
if not match:
continue
image_ref = match.group(1).split(" AS ")[0].strip()
if image_ref.lower() == "scratch":
continue
if _NOCHECK_RE.search(raw):
continue
if _ARG_VAR_RE.search(image_ref):
continue
violations.append((lineno, raw.strip()))
return violations
class TestDockerfileFromParameterized(unittest.TestCase):
"""FROM in role Dockerfiles MUST reference an ARG or Jinja variable."""
def test_role_dockerfile_from_is_parameterized(self) -> None:
self.assertTrue(
_ROLES_ROOT.is_dir(),
f"'roles' directory not found at: {_ROLES_ROOT}",
)
findings: list[str] = []
for dockerfile in _collect_role_dockerfiles():
for lineno, line in _from_violations(dockerfile):
rel = dockerfile.relative_to(_REPO_ROOT).as_posix()
findings.append(f" {rel}:{lineno}: {line}")
self.assertFalse(
findings,
"Role Dockerfile FROM instructions must reference an ARG or Jinja "
"variable so the base image is surfaced via "
"meta/services.yml -> vars/main.yml -> build-arg. Hardcoded "
"images are forbidden. Offenders:\n" + "\n".join(findings),
)
if __name__ == "__main__":
unittest.main()