Files
computer-playbook/tests/lint/docker/dockerfile/test_from_parameterized.py
kevinveenbirkenbachandClaude Fable 5 c3d6657311 fix(suitecrm): seed the app tree from the image instead of volume copy-up
Baking the multi-GB tree into /var/www/html made dockerd's volume
copy-on-first-use flood the shared NFS export from every swarm node in
parallel, so the stack never converged. The Dockerfile becomes a
multi-stage build (shared base with PHP/Apache setup, builder producing
the tree without node_modules, final stage with one COPY --chown layer
under /usr/src/suitecrm) and the entrypoint seeds the empty webroot
volume once, under the existing cross-replica boot lock; installed
volumes are only stamped, never overwritten. Ownership and the
writable-dir modes are baked at build, dropping the runtime chown pass
that crashed on the read-only ldap.yaml config mount. start_period
grows to 20m with the lock ceilings recoupled (live lab NFS measures
the seed at roughly a minute). App dir, seed dir and web user/group are
single-point role vars threaded through build args, entrypoint and
php.ini; the FROM lint now accepts file-internal stage references.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 22:44:17 +02:00

98 lines
3.5 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, and so is a ``FROM`` that references a
stage declared earlier in the same file (``FROM <base> AS build`` ...
``FROM build``): stage references are file-internal, not image pins.
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]] = []
stage_names: set[str] = set()
for lineno, raw in enumerate(read_text(str(dockerfile)).splitlines(), start=1):
line = raw.split("#", 1)[0]
match = _FROM_RE.match(line)
if not match:
continue
parts = re.split(r"\s+AS\s+", match.group(1), flags=re.IGNORECASE)
image_ref = parts[0].strip()
if len(parts) > 1:
stage_names.add(parts[1].strip().lower())
if image_ref.lower() == "scratch":
continue
if image_ref.lower() in stage_names:
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()