mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-09 04:06:42 +00:00
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>
92 lines
3.3 KiB
Python
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()
|