Compare commits

...

3 Commits

Author SHA1 Message Date
duffyduck cd9e33bdfe Auge zum Ein-/Ausblenden bei Kennwort und Access Token
Kennwort- und Access-Token-Feld haben jetzt ein Augen-Symbol, mit dem
die Eingabe sichtbar gemacht und kontrolliert werden kann. Umgesetzt
ueber ein wiederverwendbares pw_field-Macro (gilt fuer Einzel- und
Bulk-Formular).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:30:37 +02:00
duffyduck 4a11aabdf3 Leeres Repository nach Loeschen des letzten Tags entfernen
Die Registry-API loescht nur Manifeste/Tags, das Repository-Verzeichnis
bleibt im Speicher und damit im Katalog ("keine Tags") stehen. Da der
Auth-Container den Registry-Speicher gemountet hat, wird das leere
Verzeichnis jetzt entfernt:
- beim Loeschen des letzten Tags direkt
- bei der Garbage Collection werden zusaetzlich alle Tag-losen
  Repositories aufgeraeumt (fuer bereits vorhandene Reste)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:10:27 +02:00
duffyduck b273098b50 Compose: Variablen mit Default aufloesen (${VAR:-default})
Viele docker-compose-Dateien (z. B. Graylog) setzen Images per
${IMAGE:-default}. Bisher wurde alles mit "$" uebersprungen, sodass genau
diese Images nicht gefunden wurden. Jetzt werden ${VAR:-default} und
${VAR-default} mit ihrem Standardwert aufgeloest; nur Variablen ohne
Default (${VAR}, ${VAR:?...}) bleiben uebersprungen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:59:01 +02:00
4 changed files with 126 additions and 20 deletions
+4 -3
View File
@@ -113,9 +113,10 @@ uebersprungen &ndash; so wird ein Doppel-Upload vermieden, auch wenn der Tag (z.
Im selben Tab kann der Inhalt einer `docker-compose.yml` in ein Textfeld eingefuegt Im selben Tab kann der Inhalt einer `docker-compose.yml` in ein Textfeld eingefuegt
oder eine Datei hochgeladen werden. Mit **Alle Images holen** werden alle oder eine Datei hochgeladen werden. Mit **Alle Images holen** werden alle
`image:`-Eintraege der Services in die Registry kopiert. Eintraege mit nicht `image:`-Eintraege der Services in die Registry kopiert. Variablen mit Standardwert
aufgeloesten Variablen (z. B. `${TAG}`) oder die bereits auf diese Registry zeigen, (z. B. `${GRAYLOG_IMAGE:-graylog/graylog:7.1}`) werden mit ihrem Standardwert
werden uebersprungen. aufgeloest. Eintraege mit Variablen *ohne* Standardwert (z. B. `${TAG}`) oder die
bereits auf diese Registry zeigen, werden uebersprungen.
## Images loeschen und Speicher aufraeumen (Web-Oberflaeche) ## Images loeschen und Speicher aufraeumen (Web-Oberflaeche)
+77 -8
View File
@@ -1,6 +1,7 @@
import os import os
import re import re
import secrets import secrets
import shutil
import sqlite3 import sqlite3
import subprocess import subprocess
from functools import wraps from functools import wraps
@@ -26,6 +27,7 @@ ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "changeme")
DB_PATH = os.environ.get("DB_PATH", "/data/users.db") DB_PATH = os.environ.get("DB_PATH", "/data/users.db")
HTPASSWD_PATH = os.environ.get("HTPASSWD_PATH", "/auth/htpasswd") HTPASSWD_PATH = os.environ.get("HTPASSWD_PATH", "/auth/htpasswd")
GC_CONFIG = os.environ.get("GC_CONFIG", "/app/registry-gc-config.yml") GC_CONFIG = os.environ.get("GC_CONFIG", "/app/registry-gc-config.yml")
REGISTRY_DATA = os.environ.get("REGISTRY_DATA", "/var/lib/registry")
REGISTRY_URL = os.environ.get("REGISTRY_URL", "http://registry:5000") REGISTRY_URL = os.environ.get("REGISTRY_URL", "http://registry:5000")
# Host:Port ohne Schema, fuer skopeo-Ziel (z. B. "registry:5000") # Host:Port ohne Schema, fuer skopeo-Ziel (z. B. "registry:5000")
REGISTRY_HOST = REGISTRY_URL.split("://", 1)[-1] REGISTRY_HOST = REGISTRY_URL.split("://", 1)[-1]
@@ -126,12 +128,22 @@ def split_dest(dest):
return repo, tag return repo, tag
# Loest ${VAR:-default} und ${VAR-default} zum Default-Wert auf.
# ${VAR}, ${VAR:?...} usw. (ohne Default) werden NICHT aufgeloest.
_VAR_DEFAULT_RE = re.compile(r"\$\{[A-Za-z_][A-Za-z0-9_]*:?-([^}]*)\}")
def resolve_compose_image(image):
"""Ersetzt Compose-Variablen mit Default (${VAR:-default}) durch den Default."""
return _VAR_DEFAULT_RE.sub(lambda m: m.group(1), image)
def extract_compose_images(content): def extract_compose_images(content):
"""Liest alle 'image:'-Eintraege aus den Services einer docker-compose. """Liest alle 'image:'-Eintraege aus den Services einer docker-compose.
Gibt (images, ignored) zurueck. images ist None bei ungueltigem YAML. Gibt (images, ignored) zurueck. images ist None bei ungueltigem YAML.
Eintraege mit Variablen (${...}) oder auf die eigene Registry werden Variablen mit Default (${VAR:-wert}) werden aufgeloest. Eintraege mit
uebersprungen und in ignored gesammelt. nicht aufloesbaren Variablen oder auf die eigene Registry landen in ignored.
""" """
try: try:
data = yaml.safe_load(content) data = yaml.safe_load(content)
@@ -147,13 +159,14 @@ def extract_compose_images(content):
for svc in services.values(): for svc in services.values():
if not isinstance(svc, dict): if not isinstance(svc, dict):
continue continue
image = svc.get("image") raw = svc.get("image")
if not isinstance(image, str) or not image.strip(): if not isinstance(raw, str) or not raw.strip():
continue continue
image = image.strip() raw = raw.strip()
image = resolve_compose_image(raw)
host = image.split("/", 1)[0] if "/" in image else "" host = image.split("/", 1)[0] if "/" in image else ""
if "$" in image or "{" in image: if not image or "$" in image or "{" in image or "}" in image:
ignored.append(image) # nicht aufgeloeste Variable ignored.append(raw) # Variable ohne Default - nicht aufloesbar
elif host and host in (REGISTRY_HOST, request.host): elif host and host in (REGISTRY_HOST, request.host):
ignored.append(image) # zeigt bereits auf unsere Registry ignored.append(image) # zeigt bereits auf unsere Registry
elif not _IMAGE_REF_RE.match(image): elif not _IMAGE_REF_RE.match(image):
@@ -202,6 +215,51 @@ def delete_manifest(name, digest):
return False return False
def repo_has_tags(name):
"""True, wenn das Repository noch mindestens einen Tag hat."""
data = query_registry(f"/v2/{name}/tags/list")
return bool(data and data.get("tags"))
def remove_repository_storage(name):
"""Entfernt das leere Repository-Verzeichnis aus dem Registry-Speicher.
Die Registry-API kennt kein "Repository loeschen"; nach dem Loeschen des
letzten Tags bleibt sonst ein leerer Eintrag im Katalog stehen.
"""
base = os.path.realpath(
os.path.join(REGISTRY_DATA, "docker", "registry", "v2", "repositories")
)
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
shutil.rmtree(target, ignore_errors=True)
# Leere Eltern-Verzeichnisse (z. B. "bitnami" nach "bitnami/redis") aufraeumen
parent = os.path.dirname(target)
while parent != base and os.path.isdir(parent) and not os.listdir(parent):
try:
os.rmdir(parent)
except OSError:
break
parent = os.path.dirname(parent)
return True
def prune_empty_repositories():
"""Entfernt alle Repositories ohne Tags aus dem Speicher. Gibt Anzahl zurueck."""
data = query_registry("/v2/_catalog")
if not data:
return 0
removed = 0
for name in data.get("repositories", []):
if not repo_has_tags(name) and remove_repository_storage(name):
removed += 1
return removed
def remote_digest(source, src_creds=None, src_token=None): def remote_digest(source, src_creds=None, src_token=None):
"""Liefert den Manifest-Digest eines Quell-Images, ohne es herunterzuladen.""" """Liefert den Manifest-Digest eines Quell-Images, ohne es herunterzuladen."""
cmd = ["skopeo", "inspect", "--format", "{{.Digest}}", f"docker://{source}"] cmd = ["skopeo", "inspect", "--format", "{{.Digest}}", f"docker://{source}"]
@@ -428,6 +486,15 @@ def delete_image():
return redirect(url_for("images")) return redirect(url_for("images"))
if delete_manifest(name, digest): if delete_manifest(name, digest):
# War das der letzte Tag? Dann das leere Repository entfernen, damit
# es nicht weiter (ohne Tags) im Katalog stehen bleibt.
if not repo_has_tags(name) and remove_repository_storage(name):
flash(
f'Image "{name}:{tag}" geloescht - Repository "{name}" entfernt '
"(keine Tags mehr).",
"success",
)
else:
flash(f'Image "{name}:{tag}" wurde geloescht.', "success") 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}" konnte nicht geloescht werden.', "error")
@@ -454,9 +521,11 @@ def garbage_collect():
for line in result.stdout.splitlines() for line in result.stdout.splitlines()
if line.startswith("blob eligible for deletion") if line.startswith("blob eligible for deletion")
) )
pruned = prune_empty_repositories()
extra = f" {pruned} leere(s) Repository entfernt." if pruned else ""
flash( flash(
"Garbage Collection abgeschlossen. " "Garbage Collection abgeschlossen. "
f"{freed} nicht mehr benoetigte Blob(s) wurden entfernt.", f"{freed} nicht mehr benoetigte Blob(s) wurden entfernt.{extra}",
"success", "success",
) )
else: else:
+4
View File
@@ -25,6 +25,10 @@
input:focus { outline: none; border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37,99,235,0.1); } input:focus { outline: none; border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37,99,235,0.1); }
textarea { padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.85rem; width: 100%; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; resize: vertical; } textarea { padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.85rem; width: 100%; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; resize: vertical; }
textarea:focus { outline: none; border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37,99,235,0.1); } textarea:focus { outline: none; border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37,99,235,0.1); }
.pw-wrap { position: relative; }
.pw-wrap input { padding-right: 2.5rem; }
.pw-toggle { position: absolute; top: 50%; right: 0.5rem; transform: translateY(-50%); background: none; border: none; padding: 0.25rem; cursor: pointer; color: #6b7280; display: flex; align-items: center; }
.pw-toggle:hover { color: #374151; }
.btn { padding: 0.5rem 1rem; border: none; border-radius: 6px; font-size: 0.85rem; cursor: pointer; font-weight: 500; } .btn { padding: 0.5rem 1rem; border: none; border-radius: 6px; font-size: 0.85rem; cursor: pointer; font-weight: 500; }
.btn-primary { background: #2563eb; color: #fff; } .btn-primary { background: #2563eb; color: #fff; }
.btn-primary:hover { background: #1d4ed8; } .btn-primary:hover { background: #1d4ed8; }
+40 -8
View File
@@ -1,6 +1,27 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Images - Docker Registry{% endblock %} {% block title %}Images - Docker Registry{% endblock %}
{# Passwort-Feld mit Auge zum Ein-/Ausblenden der Eingabe. #}
{% macro pw_field(fid, ph='') %}
<div class="pw-wrap">
<input type="password" id="{{ fid }}" name="{{ fid }}" autocomplete="new-password"
{% if ph %}placeholder="{{ ph }}"{% endif %}>
<button type="button" class="pw-toggle" onclick="togglePw('{{ fid }}', this)"
title="Anzeigen/Verbergen" aria-label="Eingabe anzeigen oder verbergen">
<svg class="eye-on" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
<svg class="eye-off" style="display: none;" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path>
<line x1="1" y1="1" x2="23" y2="23"></line>
</svg>
</button>
</div>
{% endmacro %}
{# Wiederverwendbarer Zugangsdaten-Block (Benutzername/Kennwort ODER Access Token). {# Wiederverwendbarer Zugangsdaten-Block (Benutzername/Kennwort ODER Access Token).
prefix unterscheidet die Felder von Einzel- und Bulk-Formular. #} prefix unterscheidet die Felder von Einzel- und Bulk-Formular. #}
{% macro cred_fields(prefix) %} {% macro cred_fields(prefix) %}
@@ -22,7 +43,7 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="{{ prefix }}src_password">Kennwort</label> <label for="{{ prefix }}src_password">Kennwort</label>
<input type="password" id="{{ prefix }}src_password" name="{{ prefix }}src_password" autocomplete="new-password"> {{ pw_field(prefix ~ 'src_password') }}
</div> </div>
</div> </div>
</div> </div>
@@ -36,8 +57,7 @@
<div id="{{ prefix }}token_box" style="display: none;"> <div id="{{ prefix }}token_box" style="display: none;">
<div class="form-group" style="margin-top: 0.5rem;"> <div class="form-group" style="margin-top: 0.5rem;">
<label for="{{ prefix }}src_token">Access Token</label> <label for="{{ prefix }}src_token">Access Token</label>
<input type="password" id="{{ prefix }}src_token" name="{{ prefix }}src_token" autocomplete="new-password" {{ pw_field(prefix ~ 'src_token', 'Bearer-Token der Quell-Registry') }}
placeholder="Bearer-Token der Quell-Registry">
</div> </div>
</div> </div>
{% endmacro %} {% endmacro %}
@@ -121,9 +141,11 @@
<code>image:</code>-Eintrag gelesen und das Image in diese Registry kopiert. <code>image:</code>-Eintrag gelesen und das Image in diese Registry kopiert.
</p> </p>
<p class="help-step"> <p class="help-step">
Eintraege mit nicht aufgeloesten Variablen (z. B. <code>${TAG}</code>) oder die bereits Variablen mit Standardwert (z. B. <code>${GRAYLOG_IMAGE:-graylog/graylog:7.1}</code>)
auf diese Registry zeigen, werden uebersprungen. Bereits vorhandene Images (gleicher werden automatisch mit ihrem Standardwert aufgeloest. Eintraege mit Variablen
Digest) werden ebenfalls nicht erneut geladen. <em>ohne</em> Standardwert (z. B. <code>${TAG}</code>) oder die bereits auf diese
Registry zeigen, werden uebersprungen. Bereits vorhandene Images (gleicher Digest)
werden ebenfalls nicht erneut geladen.
</p> </p>
<p class="help-step"> <p class="help-step">
Etwaige Zugangsdaten gelten fuer <em>alle</em> Images der Compose-Datei. Etwaige Zugangsdaten gelten fuer <em>alle</em> Images der Compose-Datei.
@@ -186,8 +208,9 @@
<p style="margin-top: 0.6rem; font-size: 0.85rem; color: #6b7280; line-height: 1.6;"> <p style="margin-top: 0.6rem; font-size: 0.85rem; color: #6b7280; line-height: 1.6;">
Beim Loeschen eines Images verschwindet der Tag sofort, der belegte Speicherplatz wird Beim Loeschen eines Images verschwindet der Tag sofort, der belegte Speicherplatz wird
aber erst hierdurch freigegeben. Entfernt alle Daten, die von keinem Image mehr aber erst hierdurch freigegeben. Entfernt alle Daten, die von keinem Image mehr
referenziert werden. <strong>Wichtig:</strong> waehrend der Aufraeumung keine Images referenziert werden, sowie leere Repositories ohne Tags.
hochladen, sonst koennen frisch hochgeladene Daten verloren gehen. <strong>Wichtig:</strong> waehrend der Aufraeumung keine Images hochladen, sonst
koennen frisch hochgeladene Daten verloren gehen.
</p> </p>
</div> </div>
</div> </div>
@@ -213,6 +236,15 @@
return true; return true;
} }
// Auge: Eingabe ein-/ausblenden.
function togglePw(id, btn) {
var inp = document.getElementById(id);
var show = inp.type === 'password';
inp.type = show ? 'text' : 'password';
btn.querySelector('.eye-on').style.display = show ? 'none' : '';
btn.querySelector('.eye-off').style.display = show ? '' : 'none';
}
// Benutzername/Kennwort und Access Token schliessen sich gegenseitig aus. // Benutzername/Kennwort und Access Token schliessen sich gegenseitig aus.
function toggleCredMode(prefix, mode) { function toggleCredMode(prefix, mode) {
var creds = document.getElementById(prefix + 'use_creds'); var creds = document.getElementById(prefix + 'use_creds');