b3eabfd611
wanderer: ghcr.io/flomp/wanderer:0.16.0 is a ghost - upstream split the app into web+db images, moved registry and renamed the org. Restructured to upstream's own v0.20.0 compose (3 services, new /data/plugins volume, second public hostname for PocketBase, meilisearch pinned DOWN to upstream's v1.36.0 per the R-42 ruling). plant-it: retired. The repo name was wrong (plant-it-server) but upstream has DELETED self-hosting; last server image is 2024-12-10 and it needs MySQL+Redis the template never had. Moved to retired/ rather than deleted - reversible. R-41 slice 1: check-image-resolvable.py. Encodes two traps - manifest inspect exits 0 while printing toomanyrequests, and the inverse, where the first sweep called 24 of 65 pins dead because Hub throttled it. Ambiguity is INCONCLUSIVE, never an accusation.
178 lines
8.3 KiB
Python
178 lines
8.3 KiB
Python
#!/usr/bin/env python3
|
|
"""check-image-resolvable.py — catalog gate: every pinned image must still EXIST upstream.
|
|
|
|
The companion to `check-image-pins.py`, which is purely syntactic: it proves a template pins a
|
|
concrete tag, never that the tag is still there. That gap is how `plant-it` and `wanderer` sat behind
|
|
a working "Telepítés" button for months with images that did not resolve at all — the templates were
|
|
perfectly well-formed and pointed at nothing (Campaign 7, §6.2). **Silent rot is the real risk**
|
|
(ROADMAP R-41): an upstream rename, a repo split, or a pruned tag breaks a template without touching
|
|
this repo, so nothing in a change-triggered gate would ever notice.
|
|
|
|
This resolves each unique `image:` pin against its registry with
|
|
`docker manifest inspect <ref>` and exits non-zero listing everything that did not resolve.
|
|
|
|
python3 scripts/check-image-resolvable.py # whole catalog
|
|
python3 scripts/check-image-resolvable.py wanderer … # only these app dirs
|
|
|
|
THE TRAP THIS SCRIPT IS BUILT AROUND: gate on EACH `docker manifest inspect`'s OWN exit code, one
|
|
image at a time. Never pipe the run through anything that summarises (`| grep`, `| tee`, `&&` chains,
|
|
a wrapping shell) and then read the exit code of THAT — you get the pipeline's status, which is the
|
|
last element's, and unresolvable images sail straight through reporting success. This is the same
|
|
class of defect as the `validate-answer` trap in the ISO tooling (exits 0 on failure, so the build
|
|
gates on its OUTPUT text instead).
|
|
|
|
Requires network + a working `docker`. It is therefore a PERIODIC/manual gate, not a pre-commit one
|
|
— run it at the start of every catalog campaign, and before any publish train that vouches the
|
|
catalog. Unit tests inject `resolver` and never touch the network.
|
|
"""
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
IMAGE_RE = re.compile(r"^\s*image:\s*[\"']?([^\s\"'#]+)") # same shape as check-image-pins.py
|
|
|
|
# A ref that must never resolve, for self-testing the resolver end of the gate. `.invalid` is
|
|
# reserved by RFC 2606 and can never be a real registry.
|
|
CANARY_REF = "felhom-nonexistent.invalid/no/such:image"
|
|
|
|
|
|
def collect_images(root: Path, only: list[str] | None = None) -> dict[str, list[str]]:
|
|
"""Map each unique image ref -> the ['app:line'] sites that pin it. Pure; no network."""
|
|
sites: dict[str, list[str]] = {}
|
|
for f in sorted(root.glob("templates/*/docker-compose.yml")):
|
|
app = f.parent.name
|
|
if only and app not in only:
|
|
continue
|
|
for lineno, line in enumerate(f.read_text(encoding="utf-8").splitlines(), 1):
|
|
m = IMAGE_RE.match(line)
|
|
if m:
|
|
sites.setdefault(m.group(1), []).append(f"{app}:{lineno}")
|
|
return sites
|
|
|
|
|
|
OK, ABSENT, INCONCLUSIVE = "ok", "absent", "inconclusive"
|
|
|
|
# Substrings that mean the registry positively answered "that image is not here". ONLY these
|
|
# justify failing the gate.
|
|
ABSENT_MARKERS = (
|
|
"manifest unknown", "not found", "no such manifest", "does not exist",
|
|
"repository name not known", "unknown: unknown", "manifest_unknown",
|
|
"name unknown", "no such host", "unsupported protocol scheme",
|
|
)
|
|
# Substrings that mean "we could not find out" — a throttle, an auth wall, a network fault. These
|
|
# must NEVER be reported as a dead image.
|
|
INCONCLUSIVE_MARKERS = (
|
|
"toomanyrequests", "rate limit", "too many requests",
|
|
"unauthorized", "authentication required", "denied",
|
|
"timeout", "timed out", "temporary failure", "connection refused",
|
|
"i/o timeout", "tls handshake", "service unavailable", "500 internal",
|
|
)
|
|
|
|
|
|
def classify(returncode: int, err: str) -> str:
|
|
"""Turn one `docker manifest inspect` result into ok / absent / inconclusive.
|
|
|
|
TWO TRAPS, BOTH LIVE-OBSERVED, BOTH LOAD-BEARING:
|
|
|
|
1. `docker manifest inspect` prints `toomanyrequests: You have reached your unauthenticated
|
|
pull rate limit` and **still exits 0**. Same shape as the ISO tooling's `validate-answer`.
|
|
So a non-empty stderr is checked even on rc=0, or a throttled run reports a clean bill of
|
|
health for images it never actually resolved.
|
|
2. The inverse, which is what a naive gate does: treating that throttle as a failure. On
|
|
2026-07-21 the first full sweep called 24 of 65 pins dead — including `postgres:16-alpine`
|
|
and `redis:7-alpine` — purely because Docker Hub started throttling partway through. A gate
|
|
that cries wolf gets ignored, and then it protects nothing.
|
|
|
|
Ambiguity therefore resolves to INCONCLUSIVE, never to a failure: this gate may only accuse an
|
|
image when the registry positively said it is gone.
|
|
"""
|
|
low = err.lower()
|
|
if any(m in low for m in INCONCLUSIVE_MARKERS):
|
|
return INCONCLUSIVE
|
|
if returncode == 0:
|
|
# rc=0 WITH error text is trap 1 — do not trust it as success.
|
|
return OK if not low.strip() else INCONCLUSIVE
|
|
if any(m in low for m in ABSENT_MARKERS):
|
|
return ABSENT
|
|
return INCONCLUSIVE # an unrecognised failure is not evidence of absence
|
|
|
|
|
|
def docker_resolver(ref: str) -> tuple[str, str]:
|
|
"""(status, detail) from `docker manifest inspect ref`.
|
|
|
|
LOAD-BEARING: the decision comes from THIS call, for THIS one image — never from a piped or
|
|
aggregated summary, whose exit status is the last element's.
|
|
"""
|
|
try:
|
|
r = subprocess.run(
|
|
["docker", "manifest", "inspect", ref],
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=120, text=True,
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
return INCONCLUSIVE, "timed out after 120s"
|
|
except OSError as e:
|
|
return INCONCLUSIVE, f"could not run docker: {e}"
|
|
err = (r.stderr or "").strip()
|
|
return classify(r.returncode, err), err.splitlines()[0] if err else ""
|
|
|
|
|
|
def check_images(sites: dict[str, list[str]], resolver) -> tuple[list[str], list[tuple[str, str]]]:
|
|
"""Resolve every ref once. Returns (absent_refs, [(inconclusive_ref, why)]). No I/O of its own."""
|
|
absent, inconclusive = [], []
|
|
for ref in sorted(sites):
|
|
status, detail = resolver(ref)
|
|
if status == ABSENT:
|
|
absent.append(ref)
|
|
elif status != OK:
|
|
inconclusive.append((ref, detail))
|
|
return absent, inconclusive
|
|
|
|
|
|
def check(root: Path, only: list[str] | None = None, resolver=docker_resolver) -> int:
|
|
sites = collect_images(root, only)
|
|
if not sites:
|
|
print(f"ERROR: no images found under {root}/templates/", file=sys.stderr)
|
|
return 2
|
|
|
|
# Self-test the resolver before trusting a green result: if it says a ref that CANNOT exist
|
|
# resolves, it is broken (or something is intercepting the registry) and a clean run would be a
|
|
# false all-clear — the exact failure this gate exists to prevent.
|
|
if resolver(CANARY_REF)[0] == OK:
|
|
print(f"ERROR: resolver returned success for {CANARY_REF} — it is not trustworthy; "
|
|
"refusing to report a result", file=sys.stderr)
|
|
return 2
|
|
|
|
print(f"resolving {len(sites)} unique image pin(s)…")
|
|
absent, inconclusive = check_images(sites, resolver)
|
|
|
|
if absent:
|
|
print("\nUNRESOLVABLE IMAGES (the registry says these are GONE):")
|
|
for ref in absent:
|
|
print(f" {ref}")
|
|
for site in sites[ref]:
|
|
print(f" pinned at templates/{site}")
|
|
|
|
if inconclusive:
|
|
print("\nINCONCLUSIVE (could NOT be checked — this is not an accusation):")
|
|
for ref, why in inconclusive:
|
|
print(f" {ref} [{why}]")
|
|
print("\n Docker Hub throttles unauthenticated manifest lookups, and a large sweep will hit")
|
|
print(" the ceiling partway through. Re-run after `docker login`, or wait out the window —")
|
|
print(" the result above is NOT evidence that these images are missing.")
|
|
|
|
if absent:
|
|
print(f"\n{len(absent)} of {len(sites)} image pin(s) are GONE"
|
|
f"{f'; {len(inconclusive)} could not be checked' if inconclusive else ''}.")
|
|
return 1
|
|
if inconclusive:
|
|
print(f"\nINCOMPLETE: {len(sites) - len(inconclusive)} of {len(sites)} pins verified, "
|
|
f"{len(inconclusive)} unchecked. No dead images among those checked.")
|
|
return 2
|
|
print(f"image-resolvability gate OK — {len(sites)} unique pins, all resolve")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(check(Path(__file__).resolve().parent.parent, only=sys.argv[1:] or None))
|