server-up/tools/controleer_images.py
Ramon 331475415a
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 15m41s
Deploy server-up (prod) / deploy (push) Successful in 13m14s
v0.8.12-beta - image-controle van twaalf minuten naar seconden
Gemeten: 709 seconden voor 318 images, terwijl de netwerktijd zo'n 48 seconden
is. Twee oorzaken.

- het aantal tegelijk gold in totaal in plaats van per registry. Registries
  knijpen per host af en tweederde staat op Docker Hub, dus ghcr, lscr en quay
  stonden daarachter te wachten zonder dat dat iets oploste. Nu vier per
  registry, registries naast elkaar
- elke ronde controleerde alles opnieuw. Geslaagde controles blijven een week
  staan, met spreiding per image zodat niet alles op dezelfde dag verloopt. De
  sleutel is de naam met tag, dus een gewijzigde tag wordt altijd
  gecontroleerd; mislukkingen komen nooit in de cache

Gemeten na de wijziging: koude cache 43s, warme cache 0s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9eqpADJSRs49SoGGr4NAy
2026-08-04 07:49:52 +02:00

165 lines
5.7 KiB
Python

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