Compare commits

..

3 Commits

Author SHA1 Message Date
duffyduck 1a1b4500d6 Korrupte/verwaiste Repos sichtbar machen + Live-GC sicher
Zwei Probleme nach dem alten --delete-untagged-Schaden:

1) Repos mit Tags aber ohne _layers (verwaist) tauchen nicht im
   Registry-Katalog auf -> waren in der Web-Oberflaeche unsichtbar und
   nicht loeschbar. images() listet jetzt zusaetzlich direkt aus dem
   Dateisystem (scan_fs_repositories/repo_tags), Vereinigung mit dem
   Katalog. So sind auch korrupte Repos sichtbar und ueber den
   Loeschen-Button entfernbar.

2) Die GC laeuft als separater Prozess auf dem gemounteten Speicher.
   Der In-Memory-Blob-Cache der laufenden Registry hielt geloeschte
   Blobs faelschlich fuer vorhanden -> erneuter Pull legte keine
   _layers-Verknuepfung an (Image fehlte im Katalog). Cache via
   REGISTRY_STORAGE_CACHE_BLOBDESCRIPTOR="" deaktiviert -> Live-GC sicher.

Verifiziert: korruptes Repo -> in Liste sichtbar -> Loeschen -> GC ->
erneut pushen -> Katalog korrekt und docker pull erfolgreich, ohne
Registry-Neustart.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 13:06:18 +02:00
duffyduck b24bd96917 GC: --delete-untagged entfernen (zerstoerte Multi-Arch-Images)
registry garbage-collect --delete-untagged loescht bei Multi-Arch-Images
die per-Architektur-Manifeste, die nur ueber die Manifest-Liste (Index)
referenziert sind. Die Liste/der Tag bleibt, die Kind-Manifeste sind weg
-> beim Pull "manifest unknown".

Reproduziert: zwei Multi-Arch-Images, garbage-collect --delete-untagged
markiert die Kind-Manifeste als "eligible for deletion" und loescht sie;
danach schlaegt "skopeo copy --all" mit manifest unknown fehl. Ohne das
Flag bleiben die Images intakt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 12:41:31 +02:00
duffyduck cd5beeedd3 Verwaiste Tags loeschbar machen
Ein Tag konnte im Katalog stehen, dessen Manifest aber nicht mehr
abrufbar war (Rest aus frueherem Loeschen/GC). Der Loeschen-Button
schlug dann mit "Tag nicht gefunden" fehl, der Eintrag liess sich nicht
entfernen.

delete_image entfernt jetzt zusaetzlich den Tag-Verweis direkt aus dem
Speicher: faellt der Manifest-Lookup ueber die API aus, wird der
verwaiste Tag trotzdem entfernt (und bei letztem Tag das Repository).
Pfad-Sicherheit in _safe_repo_path() gebuendelt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 12:15:58 +02:00
2 changed files with 93 additions and 27 deletions
+87 -27
View File
@@ -221,23 +221,63 @@ def repo_has_tags(name):
return bool(data and data.get("tags")) return bool(data and data.get("tags"))
def _repositories_base():
return os.path.realpath(
os.path.join(REGISTRY_DATA, "docker", "registry", "v2", "repositories")
)
def _safe_repo_path(*parts):
"""Absoluter Pfad innerhalb des repositories-Verzeichnisses oder None (Schutz vor ../)."""
base = _repositories_base()
target = os.path.realpath(os.path.join(base, *parts))
if target != base and not target.startswith(base + os.sep):
return None
return target
def scan_fs_repositories():
"""Listet alle Repositories direkt aus dem Speicher (Dateisystem).
Erfasst auch Repositories, die der Registry-Katalog (/v2/_catalog) nicht
zeigt - etwa nach Korruption, wenn das _layers-Verzeichnis fehlt.
"""
base = _repositories_base()
repos = []
if not os.path.isdir(base):
return repos
for root, dirs, _files in os.walk(base):
if "_manifests" in dirs:
name = os.path.relpath(root, base).replace(os.sep, "/")
if name != ".":
repos.append(name)
dirs[:] = [] # innerhalb eines Repos nicht weiter absteigen
return repos
def repo_tags(name):
"""Tags eines Repositories - aus dem Dateisystem, mit API als Fallback."""
tags_dir = _safe_repo_path(*name.split("/"), "_manifests", "tags")
if tags_dir and os.path.isdir(tags_dir):
return sorted(
d for d in os.listdir(tags_dir) if os.path.isdir(os.path.join(tags_dir, d))
)
data = query_registry(f"/v2/{name}/tags/list")
return sorted(data.get("tags") or []) if data else []
def remove_repository_storage(name): def remove_repository_storage(name):
"""Entfernt das leere Repository-Verzeichnis aus dem Registry-Speicher. """Entfernt das leere Repository-Verzeichnis aus dem Registry-Speicher.
Die Registry-API kennt kein "Repository loeschen"; nach dem Loeschen des Die Registry-API kennt kein "Repository loeschen"; nach dem Loeschen des
letzten Tags bleibt sonst ein leerer Eintrag im Katalog stehen. letzten Tags bleibt sonst ein leerer Eintrag im Katalog stehen.
""" """
base = os.path.realpath( target = _safe_repo_path(*name.split("/"))
os.path.join(REGISTRY_DATA, "docker", "registry", "v2", "repositories") if not target or not os.path.isdir(target):
)
target = os.path.realpath(os.path.join(base, *name.split("/")))
# Sicherheit: Ziel muss innerhalb des repositories-Verzeichnisses liegen
if target != base and not target.startswith(base + os.sep):
return False
if not os.path.isdir(target):
return False return False
shutil.rmtree(target, ignore_errors=True) shutil.rmtree(target, ignore_errors=True)
# Leere Eltern-Verzeichnisse (z. B. "bitnami" nach "bitnami/redis") aufraeumen # Leere Eltern-Verzeichnisse (z. B. "bitnami" nach "bitnami/redis") aufraeumen
base = _repositories_base()
parent = os.path.dirname(target) parent = os.path.dirname(target)
while parent != base and os.path.isdir(parent) and not os.listdir(parent): while parent != base and os.path.isdir(parent) and not os.listdir(parent):
try: try:
@@ -248,6 +288,19 @@ def remove_repository_storage(name):
return True return True
def remove_tag_storage(name, tag):
"""Entfernt den Tag-Verweis direkt aus dem Speicher.
Noetig fuer verwaiste Tags, die noch im Katalog stehen, deren Manifest
aber nicht mehr abrufbar ist (Loeschen ueber die API schlaegt dann fehl).
"""
target = _safe_repo_path(*name.split("/"), "_manifests", "tags", tag)
if not target or not os.path.isdir(target):
return False
shutil.rmtree(target, ignore_errors=True)
return True
def prune_empty_repositories(): def prune_empty_repositories():
"""Entfernt alle Repositories ohne Tags aus dem Speicher. Gibt Anzahl zurueck.""" """Entfernt alle Repositories ohne Tags aus dem Speicher. Gibt Anzahl zurueck."""
data = query_registry("/v2/_catalog") data = query_registry("/v2/_catalog")
@@ -366,13 +419,13 @@ def users():
@app.route("/images") @app.route("/images")
@login_required @login_required
def images(): def images():
repos = [] # Repos aus dem Dateisystem UND dem Katalog (Vereinigung), damit auch
# Repositories sichtbar/loeschbar sind, die der Katalog nicht zeigt.
names = set(scan_fs_repositories())
data = query_registry("/v2/_catalog") data = query_registry("/v2/_catalog")
if data: if data:
for name in sorted(data.get("repositories", [])): names.update(data.get("repositories", []))
tags_data = query_registry(f"/v2/{name}/tags/list") repos = [{"name": name, "tags": repo_tags(name)} for name in sorted(names)]
tags = sorted(tags_data.get("tags", [])) if tags_data and tags_data.get("tags") else []
repos.append({"name": name, "tags": tags})
return render_template("images.html", repos=repos) return render_template("images.html", repos=repos)
@@ -481,23 +534,26 @@ def delete_image():
return redirect(url_for("images")) return redirect(url_for("images"))
digest = get_manifest_digest(name, tag) digest = get_manifest_digest(name, tag)
if not digest: # Normalfall: Manifest per Digest loeschen. Faellt der API-Lookup aus
# (verwaister Tag: im Katalog gelistet, Manifest aber nicht abrufbar),
# den Tag-Verweis direkt aus dem Speicher entfernen.
manifest_deleted = delete_manifest(name, digest) if digest else False
tag_removed = remove_tag_storage(name, tag)
if not manifest_deleted and not tag_removed:
flash(f'Tag "{tag}" von "{name}" wurde nicht gefunden.', "error") flash(f'Tag "{tag}" von "{name}" wurde nicht gefunden.', "error")
return redirect(url_for("images")) return redirect(url_for("images"))
if delete_manifest(name, digest): # War das der letzte Tag? Dann das leere Repository entfernen, damit
# War das der letzte Tag? Dann das leere Repository entfernen, damit # es nicht weiter (ohne Tags) im Katalog stehen bleibt.
# es nicht weiter (ohne Tags) im Katalog stehen bleibt. if not repo_has_tags(name) and remove_repository_storage(name):
if not repo_has_tags(name) and remove_repository_storage(name): flash(
flash( f'Image "{name}:{tag}" geloescht - Repository "{name}" entfernt '
f'Image "{name}:{tag}" geloescht - Repository "{name}" entfernt ' "(keine Tags mehr).",
"(keine Tags mehr).", "success",
"success", )
)
else:
flash(f'Image "{name}:{tag}" wurde geloescht.', "success")
else: else:
flash(f'Image "{name}:{tag}" konnte nicht geloescht werden.', "error") flash(f'Image "{name}:{tag}" wurde geloescht.', "success")
return redirect(url_for("images")) return redirect(url_for("images"))
@@ -508,9 +564,13 @@ def garbage_collect():
# Entfernt Blobs/Layer, die von keinem Manifest mehr referenziert werden # Entfernt Blobs/Layer, die von keinem Manifest mehr referenziert werden
# (z. B. nach dem Loeschen von Tags). Nutzt die registry-Binary direkt auf # (z. B. nach dem Loeschen von Tags). Nutzt die registry-Binary direkt auf
# dem gemounteten Speicher - kein Docker-Socket noetig. # dem gemounteten Speicher - kein Docker-Socket noetig.
#
# WICHTIG: KEIN --delete-untagged! Dieses Flag loescht bei Multi-Arch-Images
# die per-Architektur-Manifeste (die nur ueber die Manifest-Liste referenziert
# sind) und macht die Images damit unbrauchbar ("manifest unknown" beim Pull).
try: try:
result = subprocess.run( result = subprocess.run(
["registry", "garbage-collect", GC_CONFIG, "--delete-untagged"], ["registry", "garbage-collect", GC_CONFIG],
capture_output=True, capture_output=True,
text=True, text=True,
timeout=600, timeout=600,
+6
View File
@@ -34,6 +34,12 @@ services:
REGISTRY_AUTH_HTPASSWD_REALM: Docker Registry REGISTRY_AUTH_HTPASSWD_REALM: Docker Registry
REGISTRY_AUTH_HTPASSWD_PATH: /auth/htpasswd REGISTRY_AUTH_HTPASSWD_PATH: /auth/htpasswd
REGISTRY_STORAGE_DELETE_ENABLED: "true" REGISTRY_STORAGE_DELETE_ENABLED: "true"
# Blob-Cache deaktivieren: Die Garbage Collection laeuft als separater
# Prozess auf dem gemounteten Speicher. Mit aktivem In-Memory-Cache wuerde
# die laufende Registry geloeschte Blobs faelschlich fuer vorhanden halten
# und beim erneuten Pull keine Layer-Verknuepfung anlegen (Image fehlt dann
# im Katalog / "manifest unknown"). Ohne Cache ist die Live-GC sicher.
REGISTRY_STORAGE_CACHE_BLOBDESCRIPTOR: ""
volumes: volumes:
- ./data/registry:/var/lib/registry - ./data/registry:/var/lib/registry
- ./data/htpasswd:/auth:ro - ./data/htpasswd:/auth:ro