Compare commits
3 Commits
cd9e33bdfe
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a1b4500d6 | |||
| b24bd96917 | |||
| cd5beeedd3 |
+87
-27
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user