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
This commit is contained in:
@@ -7,10 +7,17 @@ Scans every templates/*/docker-compose.yml `image:` line and fails (exit 1) on:
|
||||
- a floating alias tag (`dev`, `nightly`, `edge`, `main`, `master`),
|
||||
- a missing tag entirely (`image: nginx` → implicit :latest).
|
||||
|
||||
A digest reference (`repo@sha256:...`) counts as pinned. Registry ports
|
||||
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.
|
||||
"""
|
||||
@@ -19,7 +26,8 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
BANNED_TAGS = {"latest", "dev", "nightly", "edge", "main", "master"}
|
||||
IMAGE_RE = re.compile(r"^\s*image:\s*[\"']?([^\s\"'#]+)")
|
||||
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 = []
|
||||
@@ -33,8 +41,14 @@ def check(root: Path) -> int:
|
||||
if not m:
|
||||
continue
|
||||
ref = m.group(1)
|
||||
if "@sha256:" in ref:
|
||||
continue # digest-pinned — strongest pin there is
|
||||
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)"))
|
||||
@@ -51,4 +65,13 @@ def check(root: Path) -> int:
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(check(Path(__file__).resolve().parent.parent))
|
||||
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))
|
||||
|
||||
@@ -47,6 +47,7 @@ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
# Read by felhom.eu/scripts/decoy_coverage_gate.py, which AST-parses this literal. A gate named here
|
||||
# MUST have a decoy below that has been seen to fail.
|
||||
COVERS = {
|
||||
"image-pins": "the pin's LABEL without the pin: a registry PORT (`:5000`) read as a tag, `@sha256:` with no digest behind it, a QUOTED `\"image\":` key, an interpolated `${APP_IMAGE:-nginx}`, `:LATEST`/`:latest` in quotes - vs the inert shapes that must pass (a commented `# image: nginx`, an `x-image:` extension field, a README line) and the genuine pins (a tag, a real 64-hex digest, `:latest@sha256:` pinned by its digest, a port with a tag) (R-426)",
|
||||
"engine-major": "the major moved in a comment/env var/README/app image, not on an engine's image: line",
|
||||
"catalog-since": "the date bumped in a comment/README while .felhom.yml's field stayed; or only a comment/env moved, no image (R-452)",
|
||||
"probe-matches-compose": "the probe TARGET resolves by exact name, explicit `container`, or a UNIQUE prefix - an ambiguity is refused, not guessed (R-630); the DEGRADED no-PyYAML mode CI actually runs; the port/path moved in a COMMENT, in traefik's loadbalancer label, in "
|
||||
@@ -688,6 +689,67 @@ def family_gate_cases():
|
||||
shutil.rmtree(ws, ignore_errors=True)
|
||||
|
||||
|
||||
def image_pins_cases():
|
||||
"""check-image-pins.py reads templates/*/docker-compose.yml under --root (R-426).
|
||||
|
||||
Each case is ONE planted template in a scratch catalog, so a case's verdict is that line's verdict
|
||||
and nothing else. The real tree is also judged (it must pass), so a gate that convicts everything
|
||||
fails here too.
|
||||
"""
|
||||
global ran
|
||||
ws = tempfile.mkdtemp(prefix="catalog-pins-")
|
||||
DIG = "@sha256:" + "0123456789abcdef" * 4
|
||||
try:
|
||||
def run(name, image_line, expect_rc, must=(), extra_file=None):
|
||||
global ran
|
||||
cat = os.path.join(ws, "cat")
|
||||
shutil.rmtree(cat, ignore_errors=True)
|
||||
d = os.path.join(cat, "templates", "demo")
|
||||
os.makedirs(d)
|
||||
io.open(os.path.join(d, "docker-compose.yml"), "w", encoding="utf-8").write(
|
||||
"services:\n demo:\n image: demo/demo:1.0\n demo-web:\n" + image_line + "\n"
|
||||
" restart: unless-stopped\n")
|
||||
if extra_file:
|
||||
io.open(os.path.join(d, extra_file[0]), "w", encoding="utf-8").write(extra_file[1])
|
||||
r = sh([sys.executable, os.path.join(ROOT, "scripts", "check-image-pins.py"), "--root=" + cat], ROOT)
|
||||
out = r.stdout + r.stderr
|
||||
ran += 1
|
||||
if r.returncode == expect_rc and all(m in out for m in must):
|
||||
print(" ok %-52s rc=%d (expected %d)" % ("image-pins: " + name, r.returncode, expect_rc))
|
||||
else:
|
||||
fails.append("image-pins: %s: rc=%d expected %d; missing %s\n%s" % (
|
||||
name, r.returncode, expect_rc, [m for m in must if m not in out], out[-600:]))
|
||||
|
||||
# ── THE FACTS: an image the box would pull floating. Each must be REFUSED. ───────────────
|
||||
run("FACT: untagged", " image: nginx", 1, ("NO TAG",))
|
||||
run("FACT: a registry PORT is not a tag", " image: registry.local:5000/nginx", 1, ("NO TAG",))
|
||||
run("FACT: quoted :latest", ' image: "nginx:latest"', 1, ("floating tag :latest",))
|
||||
run("FACT: :LATEST in capitals", " image: nginx:LATEST", 1, ("floating tag",))
|
||||
run("FACT: a floating alias (:edge)", " image: alpine:edge", 1, ("floating tag :edge",))
|
||||
run("FACT: untagged with a comment saying pinned", " image: nginx # pinned 1.27", 1, ("NO TAG",))
|
||||
run("FACT: a QUOTED key is the same field", ' "image": nginx', 1, ("NO TAG",))
|
||||
run("FACT: an interpolated ref cannot be read", " image: ${APP_IMAGE:-nginx}", 1, ("INTERPOLATED",))
|
||||
run("FACT: @sha256: with no digest behind it", " image: nginx@sha256:pinned", 1, ("NOT A DIGEST",))
|
||||
# ── INERT / GENUINE: must PASS ───────────────────────────────────────────────────────────
|
||||
run("INERT: a commented-out untagged image", " # image: nginx", 0, ("image-pin gate OK",))
|
||||
run("INERT: an x- extension field (Compose ignores it)", " x-image: nginx", 0, ("image-pin gate OK",))
|
||||
run("INERT: an untagged image in README.md", " image: nginx:1.27.3", 0, ("image-pin gate OK",),
|
||||
extra_file=("README.md", "image: nginx\n"))
|
||||
run("GENUINE: a concrete tag", " image: nginx:1.27.3-alpine", 0, ("image-pin gate OK",))
|
||||
run("GENUINE: a registry port WITH a tag", " image: registry.local:5000/nginx:1.27", 0, ("image-pin gate OK",))
|
||||
run("GENUINE: a real digest", " image: nginx" + DIG, 0, ("image-pin gate OK",))
|
||||
run("GENUINE: :latest pinned by its digest", " image: nginx:latest" + DIG, 0, ("image-pin gate OK",))
|
||||
# the real catalog must pass too — a gate that convicts everything is not a gate
|
||||
r = sh([sys.executable, os.path.join(ROOT, "scripts", "check-image-pins.py")], ROOT)
|
||||
ran += 1
|
||||
if r.returncode == 0:
|
||||
print(" ok %-52s rc=0 (expected 0)" % "image-pins: GENUINE: the real catalog")
|
||||
else:
|
||||
fails.append("image-pins: the real catalog was refused rc=%d\n%s" % (r.returncode, (r.stdout + r.stderr)[-600:]))
|
||||
finally:
|
||||
shutil.rmtree(ws, ignore_errors=True)
|
||||
|
||||
|
||||
def mem_sum_cases():
|
||||
"""check-mem-limit-sum.py reads FILES: templates/*/docker-compose.yml + .felhom.yml, via --root (R-758)."""
|
||||
global ran
|
||||
@@ -1311,6 +1373,7 @@ i18n:
|
||||
finally:
|
||||
shutil.rmtree(clone, ignore_errors=True)
|
||||
|
||||
image_pins_cases()
|
||||
onboarding_cases()
|
||||
family_gate_cases()
|
||||
mem_sum_cases()
|
||||
|
||||
Reference in New Issue
Block a user