test(docker): forbid ARG defaults in every Dockerfile

A defaulted build argument answers a question the caller failed to answer. The
build then succeeds against a base image, version or path nobody chose, and the
mistake surfaces as wrong behaviour at runtime instead of a failed build.

web-app-bridgy-fed shows the cost: its metadata declared python 3.14 while the
Dockerfile default silently built against 3.12. Both look plausible, so nothing
pointed at the drift. It and web-app-postmarks now take the base image from the
same services declaration the rest of the role reads.

The lint walks every tracked Dockerfile, template variants and paths outside
roles/ included, which the existing ARG-scope check does not. A second
assertion fails when the scan finds no Dockerfile at all, so the rule cannot
pass vacuously if the walk ever breaks.

Touching the two Dockerfiles brought their pre-existing narration under the
comment lint; what carried substance moved into the file header.
This commit is contained in:
kevinveenbirkenbach committed 2026-08-25 00:25:00 +02:00
1 parent 2ca09f03b3
commit f45df04bf1
6 files changed
+117 -5

No files matched your search

@@ -46,6 +46,7 @@ The placement rule is per check. The catalog "Position" column uses these labels
| `url` | same or above | [test_urls_reachable.py](../../../../tests/external/repository/test_urls_reachable.py) | Skips probing the literal HTTP(S) URL. Use for CDN roots and API base URLs that 4xx without a path. |
| `docker-version` | same or above | [test_image_versions.py](../../../../tests/external/update/docker/test_image_versions.py), [test_semver_pinning.py](../../../../tests/lint/ansible/services/test_semver_pinning.py) | Skips both the live version-update warning and the non-semver-pin lint for that image's `version:` key. |
| `repository-version` | same or above | [test_repository_versions.py](../../../../tests/external/update/repository/test_repository_versions.py), [test_semver_pinning.py](../../../../tests/lint/ansible/services/test_semver_pinning.py) | Skips both the live ref-update warning and the non-semver-pin lint for that entity's `ref:` key. |
| `dockerfile-arg-default` | same or above | [test_arg_without_default.py](../../../../tests/lint/docker/dockerfile/test_arg_without_default.py) | Allows the marked `ARG` to carry a default. The caller normally supplies every build argument so a missing one fails the build instead of selecting a base image nobody chose. |
| `direct-yaml` | same line / span | [test_no_direct_calls.py](../../../../tests/lint/repository/yaml/test_no_direct_calls.py) | Allows the marked `yaml.safe_load` / `safe_dump` call to bypass `utils.cache.yaml`. MUST use `nocheck:`, not `noqa:`. |
| `curl-timeout` | same or above | [test_curl_via_filter.py](../../../../tests/lint/repository/test_curl_via_filter.py) | Allows a bare curl invocation without the `curl` filter or inline `--max-time`. |
| `redundant-bool-ternary` | same line | [test_no_redundant_bool_patterns.py](../../../../tests/lint/filesystem/python/test_no_redundant_bool_patterns.py) | Allows the redundant `\| bool \| ternary('true','false')` pattern when a consumer requires the lowercase tokens. |
+10 -2
View File
@@ -1,5 +1,13 @@
# Runtime image for Bridgy Fed (Flask) with a build step that clones upstream
ARG PY_BASE="python:3.12-bookworm"
# Runtime image for Bridgy Fed (Flask), built from a clone of upstream.
#
# Both stages carry /usr/local over rather than reinstalling, so the
# python-version-specific site-packages path never has to be hardcoded, and
# gunicorn is present at runtime instead of the entrypoint dying with
# `gunicorn: not found` in a restart loop.
#
# Upstream serves oauth_dropins assets from /app/oauth_dropins_static and ships
# no such directory, so the package's own static tree is linked in.
ARG PY_BASE
FROM ${PY_BASE} AS build
ARG BRIDGY_REPO_URL
@@ -3,6 +3,7 @@
{{ service_name }}:
{{ lookup('template', 'roles/sys-svc-container/templates/build.yml.j2') | indent(4) }}
args:
PY_BASE: "{{ lookup('config', application_id, 'services.bridgy-fed.image') }}:{{ lookup('config', application_id, 'services.bridgy-fed.version') }}"
BRIDGY_REPO_URL: "{{ BRIDGY_REPO_URL }}"
BRIDGY_REF: "{{ BRIDGY_REF }}"
CONTAINER_PORT: "{{ container_port | string }}"
+7 -3
View File
@@ -1,6 +1,10 @@
# Runtime image for Postmarks (Node.js bookmarking app) with a build
# step that clones upstream from GitHub.
ARG NODE_BASE="node:26-bookworm"
# Runtime image for Postmarks (Node.js bookmarking app), built from a clone of
# upstream.
#
# The build stage patches in a trusted-header SSO bridge, gated at runtime on
# PROXY_HEADER_SSO, and the final stage carries the whole application directory
# over so no packages are refetched at start.
ARG NODE_BASE
FROM ${NODE_BASE} AS build
ARG POSTMARKS_REPO_URL
@@ -3,6 +3,7 @@
{{ service_name }}:
{{ lookup('template', 'roles/sys-svc-container/templates/build.yml.j2') | indent(4) }}
args:
NODE_BASE: "{{ lookup('config', application_id, 'services.postmarks.image') }}:{{ lookup('config', application_id, 'services.postmarks.version') }}"
POSTMARKS_REPO_URL: "{{ POSTMARKS_REPO_URL }}"
POSTMARKS_REF: "{{ POSTMARKS_REF }}"
CONTAINER_PORT: "{{ container_port | string }}"
@@ -0,0 +1,97 @@
"""Lint: no ``ARG`` in any Dockerfile carries a default value.
A defaulted ``ARG`` answers a question the caller failed to answer. The build
then succeeds against a base image, version or path nobody chose, and the
mistake surfaces as wrong behaviour at runtime instead of a failed build. Every
build argument MUST be supplied by the caller, so a missing one fails loudly:
ARG BASE_IMAGE # required, the compose build passes it
FROM ${BASE_IMAGE}
Docker itself does not enforce this. An undeclared ``ARG`` used in ``FROM``
expands to the empty string and yields ``invalid reference format``, which is
the loud failure this lint preserves by refusing the default that would have
hidden it.
Covers every Dockerfile the repository tracks, including ``.j2`` templates and
paths outside ``roles/``. A Jinja template MAY interpolate the value, which is
still the caller supplying it, not a default.
Suppression (see ``docs/contributing/actions/testing/suppression.md``):
* ``# nocheck: dockerfile-arg-default`` on, or directly above, the ARG line.
"""
from __future__ import annotations
import re
import unittest
from pathlib import Path
from utils.annotations.suppress import is_suppressed_at
from utils.cache.files import iter_non_ignored_files, read_text
from . import PROJECT_ROOT
_RULE = "dockerfile-arg-default"
_ARG_WITH_DEFAULT = re.compile(r"^\s*ARG\s+(\w+)\s*=", re.IGNORECASE)
def _is_dockerfile(path: str) -> bool:
name = Path(path).name
return name == "Dockerfile" or name.startswith("Dockerfile.")
def collect_dockerfiles() -> list[Path]:
"""Return every tracked Dockerfile, template variants included."""
return sorted(
Path(candidate)
for candidate in iter_non_ignored_files()
if _is_dockerfile(candidate)
)
def defaulted_args(dockerfile: Path) -> list[tuple[int, str]]:
"""Return ``(line_number, arg_name)`` for every defaulted ARG.
Args:
dockerfile: the Dockerfile to scan.
"""
lines = read_text(str(dockerfile)).splitlines()
findings = []
for index, line in enumerate(lines, start=1):
match = _ARG_WITH_DEFAULT.match(line)
if match and not is_suppressed_at(lines, index, _RULE):
findings.append((index, match.group(1)))
return findings
class TestDockerfileArgWithoutDefault(unittest.TestCase):
def test_dockerfiles_exist(self) -> None:
self.assertTrue(
collect_dockerfiles(),
"no Dockerfile found; the scan would pass vacuously",
)
def test_no_arg_carries_a_default(self) -> None:
failures = []
for dockerfile in collect_dockerfiles():
relative = dockerfile.relative_to(PROJECT_ROOT).as_posix()
failures.extend(
f"{relative}:{line}: ARG {name} carries a default"
for line, name in defaulted_args(dockerfile)
)
self.assertFalse(
failures,
f"{len(failures)} ARG default(s). A build argument MUST be supplied "
"by the caller so a missing one fails the build instead of "
"silently selecting a base image, version or path nobody chose. "
"Drop the default and pass the value from the compose build "
"arguments:\n\n" + "\n".join(f" {f}" for f in failures),
)
if __name__ == "__main__":
unittest.main()