a32541684a
Moving a template out of templates/ un-offers it but also makes the controller's orphan detector see it as GONE for anyone already running the app - flagging their working install Elavult with a Torles button. Withdrawing an app must never take a working app away from a customer. Optional lifecycle: available|hidden|abandoned in .felhom.yml instead. plant-it returns to templates/ as the first abandoned app; retired/ removed. Resolvability gate skips (and reports) non-available apps.
221 lines
10 KiB
Python
221 lines
10 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 # every AVAILABLE app
|
|
python3 scripts/check-image-resolvable.py --all # include hidden/abandoned apps too
|
|
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"
|
|
|
|
|
|
# `lifecycle:` at the top level of .felhom.yml. Matched with a line regex rather than a YAML parse so
|
|
# this gate keeps working with no dependencies (the repo ships no requirements file).
|
|
LIFECYCLE_RE = re.compile(r"""^lifecycle:\s*["']?([a-z]+)""", re.MULTILINE)
|
|
|
|
|
|
def app_lifecycle(app_dir: Path) -> str:
|
|
"""available / hidden / abandoned. Absent, empty or unknown ≡ available (see CLAUDE.md)."""
|
|
f = app_dir / ".felhom.yml"
|
|
if not f.is_file():
|
|
return "available"
|
|
m = LIFECYCLE_RE.search(f.read_text(encoding="utf-8"))
|
|
if not m:
|
|
return "available"
|
|
v = m.group(1)
|
|
return v if v in ("available", "hidden", "abandoned") else "available"
|
|
|
|
|
|
def collect_images(root: Path, only: list[str] | None = None,
|
|
include_unavailable: bool = False) -> tuple[dict[str, list[str]], list[str]]:
|
|
"""Map each unique image ref -> the ['app:line'] sites that pin it. Pure; no network.
|
|
|
|
Returns (sites, skipped_apps). Apps whose `lifecycle:` is not `available` are SKIPPED by default:
|
|
they are not offered for new installs, so a dead upstream image is the expected end state, not a
|
|
finding. Including them would leave the gate permanently red for a reason nobody intends to fix —
|
|
and a gate that is always red is a gate nobody reads. They are reported, never silently dropped.
|
|
"""
|
|
sites: dict[str, list[str]] = {}
|
|
skipped: list[str] = []
|
|
for f in sorted(root.glob("templates/*/docker-compose.yml")):
|
|
app = f.parent.name
|
|
if only and app not in only:
|
|
continue
|
|
lc = app_lifecycle(f.parent)
|
|
if lc != "available" and not include_unavailable:
|
|
skipped.append(f"{app} ({lc})")
|
|
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, skipped
|
|
|
|
|
|
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,
|
|
include_unavailable: bool = False) -> int:
|
|
sites, skipped = collect_images(root, only, include_unavailable)
|
|
if skipped:
|
|
print(f"skipping {len(skipped)} app(s) not offered for new installs: {', '.join(skipped)}")
|
|
print(" (their images are not expected to resolve; re-run with --all to check them anyway)")
|
|
if not sites:
|
|
if skipped:
|
|
# Everything in scope was deliberately skipped. That is a clean result, not a broken
|
|
# catalog — saying "ERROR: no images found" here would be a false alarm of its own.
|
|
print("nothing to check — every app in scope is out of circulation")
|
|
return 0
|
|
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__":
|
|
argv = sys.argv[1:]
|
|
all_apps = "--all" in argv
|
|
argv = [a for a in argv if a != "--all"]
|
|
sys.exit(check(Path(__file__).resolve().parent.parent, only=argv or None,
|
|
include_unavailable=all_apps))
|