"""Controleer of elk image in de catalogus echt bestaat bij zijn registry. Aanleiding: `ghcr.io/plexguide/huntarr` stond in de ARR-stack terwijl dat project offline was gehaald en het image niet meer bestond. Daarna bleken ook Forgejo, Planka en Baby Buddy naar tags te wijzen die er niet zijn. Zulke fouten merk je anders pas als iemand op installeren drukt. python3 tools/controleer_images.py [--json] [--alles] **Waarom dit langzaam wás.** Registries knijpen af bij te veel verzoeken achter elkaar, en een afgeknepen verzoek ziet er precies zo uit als een image dat niet bestaat. Eén ronde met zestien tegelijk leverde negen valse meldingen op — waarop je bijna een werkend sjabloon gaat "repareren". Dus weinig tegelijk, en wat niet meteen lukt gaat opnieuw met oplopende pauzes. Bij 318 images kostte dat elf à twaalf minuten, élke keer, ook als er geen enkel sjabloon was gewijzigd. Drie dingen brengen dat terug: **Geslaagde controles gaan in een cache.** Een image dat gisteren bestond bestaat vandaag vrijwel zeker nog. De sleutel is de volledige naam mét tag, dus een sjabloon dat naar een andere tag gaat wijzen levert vanzelf een nieuwe sleutel op en wordt gewoon gecontroleerd. Wat je aanraakt wordt dus altijd geverifieerd. **Mislukkingen komen er nooit in.** Een ontbrekend image blijft elke ronde gemeld tot het is opgelost — precies waarvoor deze controle bestaat. **Het aantal tegelijk geldt per registry.** Het afknijpen gebeurt per host, en tweederde van de catalogus staat op Docker Hub; de andere registries hoeven daar niet achter in de rij te staan. Met `--alles` negeer je de cache en wordt alles opnieuw gecontroleerd. """ from __future__ import annotations import concurrent.futures as cf import hashlib import json import os import re import sys import threading import time from collections import defaultdict from pathlib import Path WORTEL = Path(__file__).resolve().parent.parent sys.path.insert(0, str(WORTEL / "server-up" / "core")) import registry # noqa: E402 TEGELIJK_PER_REGISTRY = 4 POGINGEN = 3 HOUDBAAR = 7 * 24 * 3600 # Zonder spreiding verloopt de hele catalogus op dezelfde dag, en heb je alsnog # één ronde van twaalf minuten. SPREIDING = 3 * 24 * 3600 IMAGE_RE = re.compile(r"^\s*image:\s*(\S+)\s*$", re.M) CACHE_PAD = Path(os.environ["SU_IMAGE_CACHE"]) if os.environ.get( "SU_IMAGE_CACHE") else None _sloten: dict[str, threading.Semaphore] = {} _sloten_lock = threading.Lock() def _slot(host: str) -> threading.Semaphore: with _sloten_lock: return _sloten.setdefault(host, threading.Semaphore(TEGELIJK_PER_REGISTRY)) def images() -> dict[str, list[str]]: """Elk image in de catalogus, met de apps die het gebruiken.""" uit: dict[str, list[str]] = {} for pad in sorted((WORTEL / "apps").glob("*/files/*.yaml")): for m in IMAGE_RE.finditer(pad.read_text(encoding="utf-8")): naam = m.group(1) if "<<" in naam: # nog niet gerenderd continue uit.setdefault(naam, []).append(pad.parent.parent.name) return uit def houdbaar_tot(image: str, ts: float) -> float: """Vervaldatum, met een vaste spreiding per image.""" h = int(hashlib.sha256(image.encode()).hexdigest()[:8], 16) return ts + HOUDBAAR + (h % SPREIDING) def lees_cache() -> dict: if not CACHE_PAD or not CACHE_PAD.is_file(): return {} try: d = json.loads(CACHE_PAD.read_text(encoding="utf-8")) return d if isinstance(d, dict) else {} except Exception: return {} def schrijf_cache(cache: dict) -> None: if not CACHE_PAD: return try: CACHE_PAD.parent.mkdir(parents=True, exist_ok=True) CACHE_PAD.write_text(json.dumps(cache, indent=1), encoding="utf-8") except OSError as e: print(f" (cache niet opgeslagen: {e})") def te_controleren(alles, cache: dict, nu: float) -> list[str]: """Wat is er niet recent al goed bevonden?""" return [i for i in alles if not isinstance(cache.get(i), (int, float)) or houdbaar_tot(i, cache[i]) < nu] def bestaat(image: str) -> bool: host = registry.parse_image(image)[0] or "?" with _slot(host): for poging in range(POGINGEN): try: if registry.manifest_digest(image): return True except Exception: pass if poging < POGINGEN - 1: time.sleep(2 ** poging) # 1s, 2s return False def main() -> int: alles = images() nu = time.time() cache = {} if "--alles" in sys.argv else lees_cache() te_doen = te_controleren(alles, cache, nu) uit_cache = len(alles) - len(te_doen) uitslag: dict[str, bool] = {} if te_doen: with cf.ThreadPoolExecutor(min(16, len(te_doen))) as ex: uitslag = dict(zip(te_doen, ex.map(bestaat, te_doen))) for image, ok in uitslag.items(): if ok: cache[image] = nu else: cache.pop(image, None) # Images die niet meer in de catalogus staan hoeven we niet te bewaren. schrijf_cache({k: v for k, v in cache.items() if k in alles}) fout = {i: alles[i] for i, ok in uitslag.items() if not ok} if "--json" in sys.argv: print(json.dumps({"gecontroleerd": len(te_doen), "uit_cache": uit_cache, "fout": fout}, indent=2)) else: print(f"{len(alles)} unieke images: {len(te_doen)} gecontroleerd, " f"{uit_cache} recent al goed bevonden") for image, apps in sorted(fout.items()): print(f" ✖ {image} ({', '.join(sorted(set(apps)))})") if not fout: print(" alle images bestaan") return 1 if fout else 0 if __name__ == "__main__": raise SystemExit(main())