Files
app-catalog-felhom.eu/scripts/check-image-pins.py
T
admin d3e14eb961 R-426: image-pins gate gets a decoy suite (and three holes closed)
check-image-pins.py now refuses a QUOTED `"image":` key (was not read at
all), an interpolated `${APP_IMAGE:-nginx}` ref (the tag cannot be read),
and `@sha256:` with no 64-hex digest behind it (the label of a pin). It
takes --root=<dir> (the decoy seam) and accepts the runner's --all.

test_gate_decoys.py: 17 image-pins cases — nine facts that must be
refused (untagged, a registry port read as a tag, quoted/capital :latest,
:edge, a comment claiming a pin, the quoted key, interpolation, a fake
digest), seven inert/genuine shapes that must pass, and the real catalog.
COVERS gains "image-pins".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 01:42:38 +02:00

78 lines
3.5 KiB
Python

#!/usr/bin/env python3
"""check-image-pins.py — catalog gate: no :latest / untagged images in templates.
Scans every templates/*/docker-compose.yml `image:` line and fails (exit 1) on:
- an explicit `:latest` tag (including `:latest@sha256:...` — the tag is a lie there,
but the digest pins it, so that shape is allowed and only the bare tag is banned),
- a floating alias tag (`dev`, `nightly`, `edge`, `main`, `master`),
- a missing tag entirely (`image: nginx` → implicit :latest).
A digest reference (`repo@sha256:<64 hex>`) counts as pinned — only a REAL digest: the label
`@sha256:` followed by anything else is not a pin (R-426 decoy). Registry ports
(`host:5000/img:1.2`) are handled: the tag is what follows the LAST colon of the
LAST path segment.
Also refused (R-426, found by the decoy suite `scripts/test_gate_decoys.py`): a QUOTED key
(`"image": nginx` is the same Compose field and was not read at all), and an INTERPOLATED ref
(`${APP_IMAGE:-nginx}` — the tag the box will run cannot be read from the file; write a literal).
`--root=<dir>` judges another checkout (the decoy suite's seam); default is this repo.
Standing rule (CLAUDE.md): never :latest or untagged images in templates — pin a
concrete version tag; deployed apps pin to their running digest.
"""
import re
import sys
from pathlib import Path
BANNED_TAGS = {"latest", "dev", "nightly", "edge", "main", "master"}
IMAGE_RE = re.compile(r"^\s*[\"']?image[\"']?\s*:\s*[\"']?([^\s\"'#]+)")
DIGEST_RE = re.compile(r"@sha256:[0-9a-f]{64}$")
def check(root: Path) -> int:
failures = []
files = sorted(root.glob("templates/*/docker-compose.yml"))
if not files:
print(f"ERROR: no templates found under {root}/templates/", file=sys.stderr)
return 2
for f in files:
for lineno, line in enumerate(f.read_text(encoding="utf-8").splitlines(), 1):
m = IMAGE_RE.match(line)
if not m:
continue
ref = m.group(1)
if "$" in ref:
failures.append((f, lineno, ref, "INTERPOLATED ref (the pin cannot be read; write a literal)"))
continue
if "@" in ref:
if DIGEST_RE.search(ref):
continue # digest-pinned — strongest pin there is
failures.append((f, lineno, ref, "NOT A DIGEST (@sha256: needs 64 hex characters)"))
continue
last_seg = ref.rsplit("/", 1)[-1]
if ":" not in last_seg:
failures.append((f, lineno, ref, "NO TAG (implicit :latest)"))
continue
tag = last_seg.rsplit(":", 1)[-1]
if tag.lower() in BANNED_TAGS:
failures.append((f, lineno, ref, f"floating tag :{tag}"))
if failures:
print("UNPINNED IMAGES FOUND:")
for f, lineno, ref, why in failures:
print(f" {f.as_posix()}:{lineno}: {ref} [{why}]")
return 1
print(f"image-pin gate OK — {len(files)} templates, 0 unpinned images")
return 0
if __name__ == "__main__":
root = Path(__file__).resolve().parent.parent
for a in sys.argv[1:]:
if a.startswith("--root="):
root = Path(a.split("=", 1)[1])
elif a == "--all":
pass # the runner passes it to every gate; this gate already reads every template, hidden ones too
else:
print(f"unknown argument: {a} (usage: check-image-pins.py [--root=<dir>])", file=sys.stderr)
sys.exit(2)
sys.exit(check(root))