Files
app-catalog-felhom.eu/scripts/check-image-resolvable.py
T
admin a32541684a catalog: lifecycle field replaces the retired/ directory move
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.
2026-07-21 16:19:49 +02:00

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))