Images aus Fremd-Registries holen, loeschen und Speicher aufraeumen

Tab "Images" um Verwaltungsfunktionen erweitert:
- Einzel-Pull: Image aus anderer Registry (z. B. Docker Hub) per skopeo
  in die eigene Registry kopieren, optional mit Benutzername/Kennwort
  oder Access Token fuer private Quell-Registries
- Bulk-Pull: alle image-Eintraege einer docker-compose (Textfeld oder
  Datei-Upload) auf einmal holen; Variablen/eigene Registry werden ignoriert
- Digest-Dedup: bereits mit gleichem Digest vorhandene Images werden
  uebersprungen (kopiert mit --all, damit Multi-Arch-Digests matchen)
- Loeschen: Muelltonne hinter jedem Tag mit Rueckfrage
- Garbage Collection: gibt belegten Speicher frei, laeuft per registry-Binary
  (Multi-Stage-Build) direkt auf dem gemounteten Speicher, kein Docker-Socket

gunicorn: 1 Worker (gemeinsames Service-Passwort) + 8 Threads, Timeout 1800s
fuer lange Kopiervorgaenge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 10:34:42 +02:00
parent 244138b6bd
commit 41daacd56c
9 changed files with 576 additions and 2 deletions
+2
View File
@@ -1,3 +1,5 @@
.env .env
!.env.example !.env.example
data/ data/
__pycache__/
*.pyc
+41
View File
@@ -88,6 +88,47 @@ docker build -t registry.meinserver.de/mein-app:1.0 .
docker push registry.meinserver.de/mein-app:1.0 docker push registry.meinserver.de/mein-app:1.0
``` ```
## Image aus anderer Registry holen (Web-Oberflaeche)
Im Tab **Images** der Web-Oberflaeche kann ein Image direkt aus einer anderen
Registry (z. B. Docker Hub) in diese Registry kopiert werden &ndash; ganz ohne
lokalen Docker-Client:
1. Quell-Image eintragen, z. B. `nginx:latest`, `bitnami/redis:7` oder
`ghcr.io/owner/app:v1`.
2. **Public Images** (Normalfall) brauchen nichts weiter. Bei **privaten**
Quell-Images passend ankreuzen:
- **Benutzername / Kennwort** &ndash; fuer Docker Hub (Access Token als Kennwort)
und die meisten Registries.
- **Access Token** &ndash; fuer Registries mit direktem Bearer-Token.
3. Auf **Holen** klicken. Das Image wird per [skopeo](https://github.com/containers/skopeo)
inklusive aller Architekturen kopiert und erscheint anschliessend in der Liste
der gespeicherten Images.
Ist ein Image mit dem **gleichen Digest** bereits vorhanden, wird es automatisch
uebersprungen &ndash; so wird ein Doppel-Upload vermieden, auch wenn der Tag (z. B.
`latest`) nicht eindeutig ist.
### Mehrere Images aus einer docker-compose holen
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
`image:`-Eintraege der Services in die Registry kopiert. Eintraege mit nicht
aufgeloesten Variablen (z. B. `${TAG}`) oder die bereits auf diese Registry zeigen,
werden uebersprungen.
## Images loeschen und Speicher aufraeumen (Web-Oberflaeche)
Im Tab **Images** kann jeder Tag ueber das Muelltonnen-Symbol (mit Rueckfrage)
geloescht werden. Der Tag verschwindet sofort, der belegte Speicherplatz wird
aber erst durch die **Garbage Collection** freigegeben:
- Button **Speicher aufraeumen (Garbage Collection)** entfernt alle Daten, die
von keinem Image mehr referenziert werden (laeuft direkt ueber die
registry-Binary, kein Docker-Socket noetig).
- **Wichtig:** Waehrend der Aufraeumung keine Images hochladen &ndash; frisch
hochgeladene, noch nicht referenzierte Daten koennten sonst entfernt werden.
## Nuetzliche Befehle ## Nuetzliche Befehle
```bash ```bash
+13 -1
View File
@@ -1,7 +1,15 @@
# Die registry-Binary aus dem offiziellen Image uebernehmen (fuer Garbage Collection)
FROM registry:2 AS registry
FROM python:3.12-alpine FROM python:3.12-alpine
WORKDIR /app WORKDIR /app
# skopeo wird benoetigt, um Images aus anderen Registries zu kopieren
RUN apk add --no-cache skopeo
# registry-Binary fuer "registry garbage-collect"
COPY --from=registry /bin/registry /usr/local/bin/registry
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
@@ -9,4 +17,8 @@ COPY . .
EXPOSE 5000 EXPOSE 5000
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "1", "app:app"] # 1 Worker (gemeinsames Service-Passwort) + mehrere Threads fuer parallele
# Anfragen, waehrend ein skopeo-Kopiervorgang laeuft.
# Langes Timeout, da das Kopieren grosser Images mehrere Minuten dauern kann.
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "1", "--threads", "8", \
"--timeout", "1800", "app:app"]
+337
View File
@@ -1,10 +1,13 @@
import os import os
import re
import secrets import secrets
import sqlite3 import sqlite3
import subprocess
from functools import wraps from functools import wraps
import bcrypt as bc import bcrypt as bc
import requests as http_client import requests as http_client
import yaml
from flask import ( from flask import (
Flask, Flask,
flash, flash,
@@ -22,7 +25,10 @@ ADMIN_USER = os.environ.get("ADMIN_USER", "admin")
ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "changeme") 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")
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")
REGISTRY_HOST = REGISTRY_URL.split("://", 1)[-1]
# Internal service account for registry API queries (not stored in SQLite) # Internal service account for registry API queries (not stored in SQLite)
_SERVICE_USER = "_service" _SERVICE_USER = "_service"
@@ -86,6 +92,180 @@ def query_registry(path):
return None return None
# Erlaubte Zeichen in einer Image-Referenz (verhindert Shell-/Argument-Injection)
_IMAGE_REF_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/@-]*$")
def strip_registry_host(ref):
"""Entfernt einen optionalen Registry-Host (z. B. docker.io/, ghcr.io:443/)."""
if "/" in ref:
first, rest = ref.split("/", 1)
if "." in first or ":" in first or first == "localhost":
return rest
return ref
def derive_dest(source):
"""Leitet den Ziel-Namen in unserer Registry aus der Quell-Referenz ab.
Beispiele:
nginx -> nginx:latest
bitnami/redis:7 -> bitnami/redis:7
ghcr.io/owner/app:v1 -> owner/app:v1
"""
dest = strip_registry_host(source)
last_segment = dest.rsplit("/", 1)[-1]
if ":" not in last_segment and "@" not in last_segment:
dest += ":latest"
return dest
def split_dest(dest):
"""Teilt einen Ziel-Namen "repo:tag" in (repo, tag). dest hat immer einen Tag."""
repo, _, tag = dest.rpartition(":")
return repo, tag
def extract_compose_images(content):
"""Liest alle 'image:'-Eintraege aus den Services einer docker-compose.
Gibt (images, ignored) zurueck. images ist None bei ungueltigem YAML.
Eintraege mit Variablen (${...}) oder auf die eigene Registry werden
uebersprungen und in ignored gesammelt.
"""
try:
data = yaml.safe_load(content)
except yaml.YAMLError:
return None, []
if not isinstance(data, dict):
return None, []
services = data.get("services")
if not isinstance(services, dict):
return [], []
images, ignored = [], []
for svc in services.values():
if not isinstance(svc, dict):
continue
image = svc.get("image")
if not isinstance(image, str) or not image.strip():
continue
image = image.strip()
host = image.split("/", 1)[0] if "/" in image else ""
if "$" in image or "{" in image:
ignored.append(image) # nicht aufgeloeste Variable
elif host and host in (REGISTRY_HOST, request.host):
ignored.append(image) # zeigt bereits auf unsere Registry
elif not _IMAGE_REF_RE.match(image):
ignored.append(image)
elif image not in images:
images.append(image)
return images, ignored
_MANIFEST_ACCEPT = ", ".join(
[
"application/vnd.docker.distribution.manifest.v2+json",
"application/vnd.docker.distribution.manifest.list.v2+json",
"application/vnd.oci.image.manifest.v1+json",
"application/vnd.oci.image.index.v1+json",
]
)
def get_manifest_digest(name, reference):
"""Liefert den Manifest-Digest eines Tags (fuer das Loeschen benoetigt)."""
try:
resp = http_client.get(
f"{REGISTRY_URL}/v2/{name}/manifests/{reference}",
auth=(_SERVICE_USER, _SERVICE_PASSWORD),
headers={"Accept": _MANIFEST_ACCEPT},
timeout=5,
)
if resp.ok:
return resp.headers.get("Docker-Content-Digest")
except http_client.RequestException:
pass
return None
def delete_manifest(name, digest):
"""Loescht ein Manifest per Digest aus der Registry."""
try:
resp = http_client.delete(
f"{REGISTRY_URL}/v2/{name}/manifests/{digest}",
auth=(_SERVICE_USER, _SERVICE_PASSWORD),
timeout=5,
)
return resp.status_code in (200, 202)
except http_client.RequestException:
return False
def remote_digest(source, src_creds=None, src_token=None):
"""Liefert den Manifest-Digest eines Quell-Images, ohne es herunterzuladen."""
cmd = ["skopeo", "inspect", "--format", "{{.Digest}}", f"docker://{source}"]
if src_token:
cmd += ["--registry-token", src_token]
elif src_creds:
cmd += ["--creds", src_creds]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if result.returncode == 0:
return result.stdout.strip()
except (subprocess.SubprocessError, OSError):
pass
return None
def copy_image(source, src_creds=None, src_token=None):
"""Kopiert ein Image in unsere Registry.
Gibt (status, info) zurueck:
("pulled", dest) - kopiert
("skipped", dest) - bereits mit gleichem Digest vorhanden
("error", grund) - fehlgeschlagen
"""
if not _IMAGE_REF_RE.match(source):
return ("error", f"{source}: ungueltige Image-Referenz")
dest = derive_dest(source)
dest_name, dest_tag = split_dest(dest)
# Doppel-Upload vermeiden: gleicher Digest schon vorhanden? (z. B. bei "latest")
digest = remote_digest(source, src_creds, src_token)
if digest and get_manifest_digest(dest_name, dest_tag) == digest:
return ("skipped", dest)
# --all kopiert alle Architekturen und erhaelt die Manifest-Liste,
# damit der Digest mit der Quelle uebereinstimmt.
cmd = [
"skopeo",
"copy",
"--all",
"--dest-tls-verify=false",
f"docker://{source}",
f"docker://{REGISTRY_HOST}/{dest}",
"--dest-creds",
f"{_SERVICE_USER}:{_SERVICE_PASSWORD}",
]
if src_token:
cmd += ["--src-registry-token", src_token]
elif src_creds:
cmd += ["--src-creds", src_creds]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=1800)
if result.returncode == 0:
return ("pulled", dest)
detail = (result.stderr or result.stdout).strip().splitlines()
return ("error", f"{source}: {detail[-1] if detail else 'Unbekannter Fehler'}")
except subprocess.TimeoutExpired:
return ("error", f"{source}: Zeitueberschreitung")
except FileNotFoundError:
return ("error", "skopeo ist nicht installiert")
def login_required(f): def login_required(f):
@wraps(f) @wraps(f)
def decorated(*args, **kwargs): def decorated(*args, **kwargs):
@@ -138,6 +318,163 @@ def images():
return render_template("images.html", repos=repos) return render_template("images.html", repos=repos)
@app.route("/pull", methods=["POST"])
@login_required
def pull_image():
source = request.form.get("source", "").strip()
use_creds = request.form.get("use_creds") == "on"
use_token = request.form.get("use_token") == "on"
src_user = request.form.get("src_username", "").strip()
src_pass = request.form.get("src_password", "")
src_token = request.form.get("src_token", "").strip()
if not source:
flash("Bitte ein Quell-Image angeben.", "error")
return redirect(url_for("images"))
# Quell-Zugangsdaten nur anwenden, wenn die jeweilige Checkbox aktiviert ist.
creds = f"{src_user}:{src_pass}" if (use_creds and src_user) else None
token = src_token if (use_token and src_token) else None
status, info = copy_image(source, creds, token)
if status == "pulled":
flash(f'Image "{source}" wurde als "{info}" geholt.', "success")
elif status == "skipped":
flash(
f'Image "{info}" ist bereits mit gleichem Digest vorhanden - uebersprungen.',
"success",
)
else:
flash(f"Fehler beim Holen: {info}", "error")
return redirect(url_for("images"))
@app.route("/pull-compose", methods=["POST"])
@login_required
def pull_compose():
content = request.form.get("compose", "")
uploaded = request.files.get("compose_file")
if uploaded and uploaded.filename:
try:
content = uploaded.read().decode("utf-8")
except UnicodeDecodeError:
flash("Die hochgeladene Datei ist keine gueltige Textdatei.", "error")
return redirect(url_for("images"))
if not content.strip():
flash("Bitte eine docker-compose einfuegen oder hochladen.", "error")
return redirect(url_for("images"))
images_list, ignored = extract_compose_images(content)
if images_list is None:
flash("Die docker-compose konnte nicht gelesen werden (ungueltiges YAML).", "error")
return redirect(url_for("images"))
if not images_list:
flash("Keine verwertbaren Images in der docker-compose gefunden.", "error")
return redirect(url_for("images"))
use_creds = request.form.get("bulk_use_creds") == "on"
use_token = request.form.get("bulk_use_token") == "on"
src_user = request.form.get("bulk_src_username", "").strip()
src_pass = request.form.get("bulk_src_password", "")
src_token = request.form.get("bulk_src_token", "").strip()
creds = f"{src_user}:{src_pass}" if (use_creds and src_user) else None
token = src_token if (use_token and src_token) else None
pulled, skipped, errors = [], [], []
for image in images_list:
status, info = copy_image(image, creds, token)
if status == "pulled":
pulled.append(info)
elif status == "skipped":
skipped.append(info)
else:
errors.append(info)
if pulled:
flash(f"{len(pulled)} Image(s) geholt: " + ", ".join(pulled), "success")
if skipped:
flash(
f"{len(skipped)} bereits vorhanden (gleicher Digest), uebersprungen: "
+ ", ".join(skipped),
"success",
)
if ignored:
flash(
f"{len(ignored)} Eintrag/Eintraege ignoriert (Variable oder eigene Registry): "
+ ", ".join(ignored),
"error",
)
for err in errors:
flash(f"Fehler: {err}", "error")
return redirect(url_for("images"))
@app.route("/images/delete", methods=["POST"])
@login_required
def delete_image():
name = request.form.get("name", "").strip()
tag = request.form.get("tag", "").strip()
if not name or not tag:
flash("Image und Tag sind erforderlich.", "error")
return redirect(url_for("images"))
digest = get_manifest_digest(name, tag)
if not digest:
flash(f'Tag "{tag}" von "{name}" wurde nicht gefunden.', "error")
return redirect(url_for("images"))
if delete_manifest(name, digest):
flash(f'Image "{name}:{tag}" wurde geloescht.', "success")
else:
flash(f'Image "{name}:{tag}" konnte nicht geloescht werden.', "error")
return redirect(url_for("images"))
@app.route("/images/gc", methods=["POST"])
@login_required
def garbage_collect():
# Entfernt Blobs/Layer, die von keinem Manifest mehr referenziert werden
# (z. B. nach dem Loeschen von Tags). Nutzt die registry-Binary direkt auf
# dem gemounteten Speicher - kein Docker-Socket noetig.
try:
result = subprocess.run(
["registry", "garbage-collect", GC_CONFIG, "--delete-untagged"],
capture_output=True,
text=True,
timeout=600,
)
if result.returncode == 0:
freed = sum(
1
for line in result.stdout.splitlines()
if line.startswith("blob eligible for deletion")
)
flash(
"Garbage Collection abgeschlossen. "
f"{freed} nicht mehr benoetigte Blob(s) wurden entfernt.",
"success",
)
else:
detail = (result.stderr or result.stdout).strip().splitlines()
reason = detail[-1] if detail else "Unbekannter Fehler"
# Frische, leere Registry: nichts zu tun
if "Path not found" in reason and "repositories" in reason:
flash("Garbage Collection: keine Images vorhanden, nichts zu tun.", "success")
else:
flash(f"Fehler bei der Garbage Collection: {reason}", "error")
except subprocess.TimeoutExpired:
flash("Zeitueberschreitung bei der Garbage Collection.", "error")
except FileNotFoundError:
flash("registry-Binary ist nicht verfuegbar.", "error")
return redirect(url_for("images"))
@app.route("/help") @app.route("/help")
@login_required @login_required
def help_page(): def help_page():
+4
View File
@@ -0,0 +1,4 @@
version: 0.1
storage:
filesystem:
rootdirectory: /var/lib/registry
+1
View File
@@ -2,3 +2,4 @@ flask>=3.0
bcrypt>=4.0 bcrypt>=4.0
gunicorn>=21.0 gunicorn>=21.0
requests>=2.31 requests>=2.31
pyyaml>=6.0
+6
View File
@@ -23,6 +23,8 @@
tr:last-child td { border-bottom: none; } tr:last-child td { border-bottom: none; }
input[type="text"], input[type="password"] { padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; width: 100%; } input[type="text"], input[type="password"] { padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; width: 100%; }
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:focus { outline: none; border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37,99,235,0.1); }
.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; }
@@ -42,6 +44,10 @@
nav a:hover { color: #fff; } nav a:hover { color: #fff; }
nav a.active { color: #fff; border-bottom-color: #2563eb; } nav a.active { color: #fff; border-bottom-color: #2563eb; }
.tag { display: inline-block; background: #e0e7ff; color: #3730a3; padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.8rem; margin: 0.15rem; font-family: monospace; } .tag { display: inline-block; background: #e0e7ff; color: #3730a3; padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.8rem; margin: 0.15rem; font-family: monospace; }
.tag-deletable { display: inline-flex; align-items: center; gap: 0.35rem; }
.tag-delete-form { display: inline-flex; line-height: 0; }
.tag-delete-form button { background: none; border: none; cursor: pointer; padding: 0; display: inline-flex; align-items: center; color: #818cf8; }
.tag-delete-form button:hover { color: #dc2626; }
code { background: #f3f4f6; padding: 0.15rem 0.4rem; border-radius: 4px; font-size: 0.85rem; } code { background: #f3f4f6; padding: 0.15rem 0.4rem; border-radius: 4px; font-size: 0.85rem; }
pre { background: #1e293b; color: #e2e8f0; padding: 1rem; border-radius: 6px; overflow-x: auto; font-size: 0.85rem; line-height: 1.6; margin: 0.75rem 0; } pre { background: #1e293b; color: #e2e8f0; padding: 1rem; border-radius: 6px; overflow-x: auto; font-size: 0.85rem; line-height: 1.6; margin: 0.75rem 0; }
pre code { background: none; padding: 0; color: inherit; } pre code { background: none; padding: 0; color: inherit; }
+169 -1
View File
@@ -1,7 +1,134 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Images - Docker Registry{% endblock %} {% block title %}Images - Docker Registry{% endblock %}
{# Wiederverwendbarer Zugangsdaten-Block (Benutzername/Kennwort ODER Access Token).
prefix unterscheidet die Felder von Einzel- und Bulk-Formular. #}
{% macro cred_fields(prefix) %}
<p style="margin-top: 0.75rem; font-size: 0.8rem; color: #6b7280;">
Nur bei privaten Quell-Images noetig &ndash; passend ankreuzen, was du hast:
</p>
<div class="form-group" style="margin-top: 0.4rem;">
<label style="display: flex; align-items: center; gap: 0.5rem; font-weight: 600;">
<input type="checkbox" id="{{ prefix }}use_creds" name="{{ prefix }}use_creds"
onchange="toggleCredMode('{{ prefix }}', 'creds')" style="width: auto;">
Benutzername / Kennwort
</label>
</div>
<div id="{{ prefix }}creds_box" style="display: none;">
<div class="form-row" style="margin-top: 0.5rem;">
<div class="form-group">
<label for="{{ prefix }}src_username">Benutzername</label>
<input type="text" id="{{ prefix }}src_username" name="{{ prefix }}src_username" autocomplete="off">
</div>
<div class="form-group">
<label for="{{ prefix }}src_password">Kennwort</label>
<input type="password" id="{{ prefix }}src_password" name="{{ prefix }}src_password" autocomplete="new-password">
</div>
</div>
</div>
<div class="form-group" style="margin-top: 0.6rem;">
<label style="display: flex; align-items: center; gap: 0.5rem; font-weight: 600;">
<input type="checkbox" id="{{ prefix }}use_token" name="{{ prefix }}use_token"
onchange="toggleCredMode('{{ prefix }}', 'token')" style="width: auto;">
Access Token
</label>
</div>
<div id="{{ prefix }}token_box" style="display: none;">
<div class="form-group" style="margin-top: 0.5rem;">
<label for="{{ prefix }}src_token">Access Token</label>
<input type="password" id="{{ prefix }}src_token" name="{{ prefix }}src_token" autocomplete="new-password"
placeholder="Bearer-Token der Quell-Registry">
</div>
</div>
{% endmacro %}
{% block content %} {% block content %}
<div class="card">
<h2>Image aus anderer Registry holen</h2>
<form method="post" action="{{ url_for('pull_image') }}">
<div class="form-row">
<div class="form-group">
<label for="source">Quell-Image</label>
<input type="text" id="source" name="source"
placeholder="z. B. nginx:latest oder bitnami/redis:7" required>
</div>
<button type="submit" class="btn btn-primary">Holen</button>
</div>
{{ cred_fields('') }}
</form>
<div style="margin-top: 1.25rem; font-size: 0.9rem; color: #6b7280; line-height: 1.7;">
<p class="help-step"><strong>So funktioniert es</strong></p>
<p class="help-step">
Trage den Namen eines Images aus einer anderen Registry ein (z. B. von Docker Hub)
und klicke auf <strong>Holen</strong>. Das Image wird dann heruntergeladen und in
diese Registry kopiert (alle Architekturen).
</p>
<p class="help-step">Beispiele fuer das Quell-Image:</p>
<ul style="margin: 0.25rem 0 0.5rem 1.25rem;">
<li><code>nginx:latest</code> &ndash; offizielles Image von Docker Hub</li>
<li><code>bitnami/redis:7</code> &ndash; Image eines Docker-Hub-Benutzers</li>
<li><code>ghcr.io/owner/app:v1</code> &ndash; Image aus einer anderen Registry (z. B. GitHub)</li>
</ul>
<p class="help-step">
<strong>Public Images</strong> (der Normalfall, z. B. von Docker Hub) brauchen
keine Zugangsdaten &ndash; einfach nur den Namen eingeben und Holen.
</p>
<p class="help-step">
Nur bei <strong>privaten</strong> Quell-Images Zugangsdaten angeben &ndash; je nachdem,
was du hast:
</p>
<ul style="margin: 0.25rem 0 0.5rem 1.25rem;">
<li><strong>Benutzername / Kennwort</strong> &ndash; fuer Docker Hub und die meisten
Registries. Bei Docker Hub hier das <em>Access Token als Kennwort</em> eintragen.</li>
<li><strong>Access Token</strong> &ndash; fuer Registries, die einen direkten
Bearer-Token akzeptieren.</li>
</ul>
<p class="help-step">
Ist ein Image mit dem gleichen Digest bereits vorhanden, wird es automatisch
uebersprungen (kein Doppel-Upload &ndash; auch bei <code>latest</code>).
</p>
</div>
</div>
<div class="card">
<h2>Mehrere Images aus docker-compose holen</h2>
<form method="post" action="{{ url_for('pull_compose') }}" enctype="multipart/form-data">
<div class="form-group">
<label for="compose">docker-compose einfuegen</label>
<textarea id="compose" name="compose" rows="10"
placeholder="services:&#10; web:&#10; image: nginx:latest&#10; cache:&#10; image: redis:7"></textarea>
</div>
<div class="form-group" style="margin-top: 0.75rem;">
<label for="compose_file">&hellip; oder eine Datei hochladen</label>
<input type="file" id="compose_file" name="compose_file"
accept=".yml,.yaml,.txt,text/yaml,text/plain">
</div>
{{ cred_fields('bulk_') }}
<div style="margin-top: 0.9rem;">
<button type="submit" class="btn btn-primary">Alle Images holen</button>
</div>
</form>
<div style="margin-top: 1.25rem; font-size: 0.9rem; color: #6b7280; line-height: 1.7;">
<p class="help-step"><strong>So funktioniert es</strong></p>
<p class="help-step">
Fuege den Inhalt einer <code>docker-compose.yml</code> ein oder lade die Datei hoch
und klicke auf <strong>Alle Images holen</strong>. Aus jedem Service wird der
<code>image:</code>-Eintrag gelesen und das Image in diese Registry kopiert.
</p>
<p class="help-step">
Eintraege mit nicht aufgeloesten Variablen (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 class="help-step">
Etwaige Zugangsdaten gelten fuer <em>alle</em> Images der Compose-Datei.
</p>
</div>
</div>
<div class="card"> <div class="card">
<h2>Gespeicherte Images</h2> <h2>Gespeicherte Images</h2>
{% if repos %} {% if repos %}
@@ -19,7 +146,23 @@
<td> <td>
{% if repo.tags %} {% if repo.tags %}
{% for tag in repo.tags %} {% for tag in repo.tags %}
<span class="tag">{{ tag }}</span> <span class="tag tag-deletable">
{{ tag }}
<form method="post" action="{{ url_for('delete_image') }}" class="tag-delete-form"
onsubmit="return confirm('Image {{ repo.name }}:{{ tag }} wirklich loeschen?');">
<input type="hidden" name="name" value="{{ repo.name }}">
<input type="hidden" name="tag" value="{{ tag }}">
<button type="submit" title="Image loeschen" aria-label="Image loeschen">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
</button>
</form>
</span>
{% endfor %} {% endfor %}
{% else %} {% else %}
<span style="color: #9ca3af;">keine Tags</span> <span style="color: #9ca3af;">keine Tags</span>
@@ -32,6 +175,31 @@
{% else %} {% else %}
<p class="empty">Noch keine Images in der Registry vorhanden.</p> <p class="empty">Noch keine Images in der Registry vorhanden.</p>
{% endif %} {% endif %}
<div style="margin-top: 1.25rem; padding-top: 1rem; border-top: 1px solid #e5e7eb;">
<form method="post" action="{{ url_for('garbage_collect') }}"
onsubmit="return confirm('Garbage Collection jetzt ausfuehren? Loesche waehrenddessen keine Images und pushe nichts.');">
<button type="submit" class="btn btn-secondary">Speicher aufraeumen (Garbage Collection)</button>
</form>
<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
aber erst hierdurch freigegeben. Entfernt alle Daten, die von keinem Image mehr
referenziert werden. <strong>Wichtig:</strong> waehrend der Aufraeumung keine Images
hochladen, sonst koennen frisch hochgeladene Daten verloren gehen.
</p>
</div>
</div> </div>
<script>
// Benutzername/Kennwort und Access Token schliessen sich gegenseitig aus.
function toggleCredMode(prefix, mode) {
var creds = document.getElementById(prefix + 'use_creds');
var token = document.getElementById(prefix + 'use_token');
if (mode === 'creds' && creds.checked) { token.checked = false; }
if (mode === 'token' && token.checked) { creds.checked = false; }
document.getElementById(prefix + 'creds_box').style.display = creds.checked ? 'block' : 'none';
document.getElementById(prefix + 'token_box').style.display = token.checked ? 'block' : 'none';
}
</script>
{% endblock %} {% endblock %}
+3
View File
@@ -49,9 +49,12 @@ services:
SECRET_KEY: ${SECRET_KEY} SECRET_KEY: ${SECRET_KEY}
DB_PATH: /data/users.db DB_PATH: /data/users.db
HTPASSWD_PATH: /auth/htpasswd HTPASSWD_PATH: /auth/htpasswd
GC_CONFIG: /app/registry-gc-config.yml
volumes: volumes:
- ./data/auth:/data - ./data/auth:/data
- ./data/htpasswd:/auth - ./data/htpasswd:/auth
# Registry-Speicher fuer die Garbage Collection (gleicher Pfad wie im registry-Container)
- ./data/registry:/var/lib/registry
networks: networks:
- registry_net - registry_net