mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
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:
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. |
|
||||
|
||||
@@ -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 }}"
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user