Files
app-catalog-felhom.eu/scripts/test_check_image_resolvable.py
T
admin b3eabfd611 catalog: re-pin wanderer to the current upstream shape, retire plant-it, add the resolvability gate
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.
2026-07-21 15:30:15 +02:00

149 lines
6.9 KiB
Python

#!/usr/bin/env python3
"""Fixture tests for check-image-resolvable.py. NO NETWORK — the resolver is injected.
Run: python3 scripts/test_check_image_resolvable.py
"""
import importlib.util
import sys
import tempfile
import unittest
from pathlib import Path
_spec = importlib.util.spec_from_file_location(
"cir", Path(__file__).resolve().parent / "check-image-resolvable.py")
cir = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(cir)
# One ref that a fake registry "has", one that is knowingly invalid — `.invalid` is RFC-2606
# reserved, so this can never accidentally succeed even if someone runs the suite online.
GOOD = "getmeili/meilisearch:v1.49"
DEAD = "flomp-nonexistent.invalid/wanderer:0.16.0"
def fake_resolver(ref: str):
"""Resolves exactly one ref. Everything else — including the canary — is reported GONE."""
return (cir.OK, "") if ref == GOOD else (cir.ABSENT, "manifest unknown")
def throttled_resolver(ref: str):
"""Docker Hub throttling: rc=0 AND an error on stderr — the live 2026-07-21 failure."""
return (cir.INCONCLUSIVE, "toomanyrequests: You have reached your unauthenticated pull rate limit")
def make_catalog(tmp: Path, apps: dict[str, list[str]]) -> Path:
for app, images in apps.items():
d = tmp / "templates" / app
d.mkdir(parents=True)
body = "services:\n" + "".join(
f" svc{i}:\n image: {img}\n" for i, img in enumerate(images))
(d / "docker-compose.yml").write_text(body, encoding="utf-8")
return tmp
class TestResolvabilityGate(unittest.TestCase):
def test_dead_ref_fails_and_is_named(self):
"""The whole point: one dead pin must fail the gate, and say which app pins it."""
with tempfile.TemporaryDirectory() as td:
root = make_catalog(Path(td), {"alive": [GOOD], "rotten": [DEAD]})
sites = cir.collect_images(root)
absent, inconclusive = cir.check_images(sites, fake_resolver)
self.assertEqual(absent, [DEAD], "a dead image pin MUST fail the gate")
self.assertEqual(inconclusive, [])
self.assertEqual(sites[DEAD], ["rotten:3"],
"the failure must point at the app+line that pins it")
def test_all_resolvable_passes(self):
with tempfile.TemporaryDirectory() as td:
root = make_catalog(Path(td), {"alive": [GOOD], "also": [GOOD]})
self.assertEqual(cir.check_images(cir.collect_images(root), fake_resolver), ([], []))
def test_same_ref_in_two_apps_is_resolved_once_but_both_sites_reported(self):
with tempfile.TemporaryDirectory() as td:
root = make_catalog(Path(td), {"a": [DEAD], "b": [DEAD]})
sites = cir.collect_images(root)
calls = []
def counting(ref):
calls.append(ref)
return fake_resolver(ref)
absent, _ = cir.check_images(sites, counting)
self.assertEqual(calls, [DEAD], "each unique ref must be resolved exactly once")
self.assertEqual(sorted(sites[DEAD]), ["a:3", "b:3"])
self.assertEqual(absent, [DEAD])
def test_multi_service_app_collects_every_image(self):
"""A repo split (wanderer web+db) means one app pins several images — miss one and the
gate would pass an app that cannot start."""
with tempfile.TemporaryDirectory() as td:
root = make_catalog(Path(td), {"wanderer": [GOOD, DEAD]})
sites = cir.collect_images(root)
self.assertEqual(sorted(sites), sorted([GOOD, DEAD]))
absent, _ = cir.check_images(sites, fake_resolver)
self.assertEqual(absent, [DEAD])
def test_untrustworthy_resolver_refuses_to_report(self):
"""RED-PROOF companion for the false-all-clear mode: a resolver that says yes to
everything (broken docker, an intercepting proxy) must NOT yield a clean bill of health."""
with tempfile.TemporaryDirectory() as td:
root = make_catalog(Path(td), {"rotten": [DEAD]})
rc = cir.check(root, resolver=lambda ref: (cir.OK, ""))
self.assertEqual(rc, 2, "a resolver that resolves the canary must abort, not pass")
def test_only_filter_restricts_to_named_apps(self):
with tempfile.TemporaryDirectory() as td:
root = make_catalog(Path(td), {"a": [GOOD], "b": [DEAD]})
self.assertEqual(sorted(cir.collect_images(root, only=["a"])), [GOOD])
def test_check_returns_1_end_to_end_on_a_dead_pin(self):
with tempfile.TemporaryDirectory() as td:
root = make_catalog(Path(td), {"alive": [GOOD], "rotten": [DEAD]})
self.assertEqual(cir.check(root, resolver=fake_resolver), 1)
def test_empty_catalog_is_an_error_not_a_pass(self):
with tempfile.TemporaryDirectory() as td:
(Path(td) / "templates").mkdir()
self.assertEqual(cir.check(Path(td), resolver=fake_resolver), 2)
class TestClassifyGuardsAgainstFalseAlarms(unittest.TestCase):
"""RED-PROOF for the defect the first live sweep actually had: Docker Hub throttling was
reported as 24 dead images (postgres:16-alpine, redis:7-alpine among them). A gate that
accuses healthy images gets ignored, so ambiguity must resolve to INCONCLUSIVE."""
def test_rate_limit_on_rc0_is_inconclusive_not_ok(self):
"""The live shape: rc=0 WITH an error on stderr. Trusting rc alone reports a false PASS."""
self.assertEqual(
cir.classify(0, "toomanyrequests: You have reached your unauthenticated pull rate limit"),
cir.INCONCLUSIVE)
def test_rate_limit_is_never_absent(self):
for rc in (0, 1):
self.assertEqual(
cir.classify(rc, "toomanyrequests: rate limit exceeded"), cir.INCONCLUSIVE,
"a throttle must NEVER be reported as a missing image")
def test_genuine_absence_is_absent(self):
self.assertEqual(cir.classify(1, "manifest unknown"), cir.ABSENT)
self.assertEqual(cir.classify(1, "errors:\n denied: requested access to the resource is denied"),
cir.INCONCLUSIVE, "an auth wall is not proof of absence")
def test_clean_success_is_ok(self):
self.assertEqual(cir.classify(0, ""), cir.OK)
def test_unrecognised_failure_is_inconclusive(self):
self.assertEqual(cir.classify(7, "something nobody has seen before"), cir.INCONCLUSIVE)
def test_throttled_sweep_reports_incomplete_not_failure(self):
with tempfile.TemporaryDirectory() as td:
root = make_catalog(Path(td), {"a": [GOOD], "b": [DEAD]})
rc = cir.check(root, resolver=lambda ref: (
(cir.ABSENT, "manifest unknown") if ref == cir.CANARY_REF
else throttled_resolver(ref)))
self.assertEqual(rc, 2, "a fully-throttled sweep is INCOMPLETE (2), not a failure (1) "
"and not a pass (0)")
if __name__ == "__main__":
unittest.main(verbosity=2)