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
165 lines
5.7 KiB
Python
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())
|