Files
computer-playbook/tests/lint/ansible/services/test_semver_pinning.py
kevinveenbirkenbachandClaude Opus 5 be6a25517c feat(docker): classify image pins and build pull references from the class
A digest pin was emitted as `name:sha256:...`, which no registry accepts:
image_source('ghcr.io/.../csp-checker', 'sha256:d559...') returned
'ghcr.io/.../csp-checker:3.0.0'. The separator depends on the pin class, so
the class has to be decided before the reference is built.

utils/docker/image/pin.py names the three classes in preference order
(semver, then digest, then a general ref that no registry can order) and
maps each to the nocheck marker that declares it. Both discovery entry
points now end in pull_reference(), so '@' versus ':' is decided in one
place instead of at every call site.

The semver pin lint points a digest at 'docker-digest' instead of
'docker-version', so the two fallbacks stay distinguishable in the
suppression catalog. It stays warn-only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 16:39:16 +02:00

223 lines
8.1 KiB
Python

"""Lint guard: every ``version:`` value paired with an ``image:`` key and
every ``ref:`` value paired with a ``repository:`` key inside a
``meta/services.yml`` SHOULD be semver-compatible.
Why
---
Semver pinning is the precondition for the daily auto-update job at
[update-image-versions](../../../../.github/workflows/cron-update.yml) and
[update-repository-refs](../../../../.github/workflows/cron-update.yml):
those jobs only bump entries whose current value matches the semver
shape (``v?\\d+(\\.\\d+){0,3}(-flavor)?``). A pin like ``latest``,
``stable``, ``master``, ``main``, or a commit SHA silently opts the
entry out of the freshness pipeline. That MAY be intentional (e.g. a
vendor image that does not publish semver tags, or a plugin tracked
against ``master``), but it MUST be explicit so freshness coverage
stays auditable.
Warn-only
---------
This check is **warn-only**: it never fails the test session. Each
unmarked non-semver pin surfaces twice: once via :mod:`warnings`
(pytest "warnings summary"), once via a GitHub Actions
``::warning file=...,line=...::...`` annotation (PR-side yellow
marker). Suppression removes the entry from both surfaces.
Suppression
-----------
Per-line opt-out via ``# nocheck:`` on the same line as the pin key
or on the immediately preceding non-empty line:
* ``# nocheck: docker-version`` above (or on) a ``version:`` line for
intentional non-semver image pins. Same marker as the daily
external check at
[test_image_versions.py](../../../../external/update/docker/test_image_versions.py).
* ``# nocheck: repository-version`` above (or on) a ``ref:`` line for
intentional non-semver git ref pins. Same marker as the daily
external check at
[test_repository_versions.py](../../../../external/update/repository/test_repository_versions.py).
Scope
-----
Recursive scan over every ``roles/*/meta/services.yml``. Both top-level
entities and nested sub-entities (plugin maps, list items) are
inspected.
"""
from __future__ import annotations
import unittest
import warnings
from typing import TYPE_CHECKING
from utils.annotations.message import in_github_actions
from utils.annotations.message import warning as gha_warning
from utils.annotations.suppress import is_suppressed_at
from utils.cache.files import read_text
from utils.cache.yaml import load_yaml_any
from utils.docker.image.pin import is_digest
from utils.roles.mapping import ROLE_FILE_META_SERVICES
from utils.update.base import is_semver
from . import PROJECT_ROOT
if TYPE_CHECKING:
from collections.abc import Iterator
_DOCKER_RULE = "docker-version"
_DIGEST_RULE = "docker-digest"
_REPO_RULE = "repository-version"
_DOCKER_TITLE = "Non-semver Docker image version"
_REPO_TITLE = "Non-semver repository ref"
class NonSemverImageVersionWarning(UserWarning):
"""Warning category for non-semver ``image: <name>`` + ``version:`` pins."""
class NonSemverRepositoryRefWarning(UserWarning):
"""Warning category for non-semver ``repository: <url>`` + ``ref:`` pins."""
def _walk_pinned_pairs(
node, path: tuple[str, ...]
) -> Iterator[tuple[str, tuple[str, ...], str, str]]:
"""Yield ``(kind, entity_path, lhs, value)`` for every dict that pairs
``image:`` + ``version:`` (kind=``image``) or ``repository:`` + ``ref:``
(kind=``ref``). The walker recurses through nested dicts and lists so
sub-entities and plugin maps are also covered."""
if isinstance(node, dict):
image = node.get("image")
version = node.get("version")
if isinstance(image, str) and image and isinstance(version, str) and version:
yield ("image", path, image.strip(), version.strip())
repo = node.get("repository")
ref = node.get("ref")
if isinstance(repo, str) and repo and isinstance(ref, str) and ref:
yield ("ref", path, repo.strip(), ref.strip())
for key, value in node.items():
if key in ("image", "version", "repository", "ref"):
continue
yield from _walk_pinned_pairs(value, (*path, str(key)))
elif isinstance(node, list):
for index, item in enumerate(node):
yield from _walk_pinned_pairs(item, (*path, f"[{index}]"))
def _locate_value_line(
lines: list[str], key_name: str, target_value: str, used: set[int]
) -> int | None:
"""Return the 1-indexed line number of the first ``<key>: <target_value>``
line whose extracted value matches ``target_value`` and that has not
yet been consumed via ``used``."""
for idx, raw_line in enumerate(lines, start=1):
if idx in used:
continue
stripped = raw_line.strip()
if not stripped.startswith(f"{key_name}:") and not stripped.startswith(
f"{key_name} :"
):
continue
try:
_, value = stripped.split(":", 1)
except ValueError:
continue
value = value.split("#", 1)[0].strip().strip("'\"")
if value == target_value:
return idx
return None
def _emit_warning(
path_rel: str,
line_no: int,
body: str,
title: str,
category: type[Warning],
) -> None:
msg = f"{path_rel}:{line_no}: {body}"
warnings.warn(msg, category, stacklevel=2)
if in_github_actions():
gha_warning(body, title=title, file=path_rel, line=line_no)
class TestSemverPinning(unittest.TestCase):
"""Warn-only lint: surfaces non-semver ``version:`` and ``ref:`` pins."""
def test_pinned_versions_and_refs_are_semver(self) -> None:
roles_root = PROJECT_ROOT / "roles"
if not roles_root.is_dir():
return
findings_emitted = False
for role_dir in sorted(p for p in roles_root.iterdir() if p.is_dir()):
config_path = role_dir / ROLE_FILE_META_SERVICES
if not config_path.is_file():
continue
data = load_yaml_any(str(config_path), default_if_missing=None)
if not isinstance(data, dict):
continue
raw = read_text(str(config_path))
lines = raw.splitlines()
rel = config_path.relative_to(PROJECT_ROOT).as_posix()
used_version_lines: set[int] = set()
used_ref_lines: set[int] = set()
for kind, entity_path, lhs, value in _walk_pinned_pairs(data, ()):
if is_semver(value):
continue
if kind == "image":
line_no = _locate_value_line(
lines, "version", value, used_version_lines
)
rule = _DIGEST_RULE if is_digest(value) else _DOCKER_RULE
title = _DOCKER_TITLE
category: type[Warning] = NonSemverImageVersionWarning
key_label = "version"
else:
line_no = _locate_value_line(lines, "ref", value, used_ref_lines)
rule = _REPO_RULE
title = _REPO_TITLE
category = NonSemverRepositoryRefWarning
key_label = "ref"
if line_no is None:
continue
if kind == "image":
used_version_lines.add(line_no)
else:
used_ref_lines.add(line_no)
if is_suppressed_at(lines, line_no, rule):
continue
entity = ".".join(entity_path) or "<root>"
body = (
f"{role_dir.name}/{entity}: {lhs} is pinned at "
f"{key_label} '{value}' which is not semver-compatible."
)
_emit_warning(rel, line_no, body, title, category)
findings_emitted = True
if findings_emitted:
print(
"\n💡 Non-semver pins: a digest is the preferred fallback and "
"declares itself with '# nocheck: docker-digest'; a general "
"tag uses '# nocheck: docker-version' and a general git ref "
"'# nocheck: repository-version', above or on the pinned line.",
flush=True,
)
if __name__ == "__main__": # pragma: no cover
unittest.main()