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:
@@ -1,3 +1,5 @@
|
|||||||
.env
|
.env
|
||||||
!.env.example
|
!.env.example
|
||||||
data/
|
data/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|||||||
@@ -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 – 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** – fuer Docker Hub (Access Token als Kennwort)
|
||||||
|
und die meisten Registries.
|
||||||
|
- **Access Token** – 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 – 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 – frisch
|
||||||
|
hochgeladene, noch nicht referenzierte Daten koennten sonst entfernt werden.
|
||||||
|
|
||||||
## Nuetzliche Befehle
|
## Nuetzliche Befehle
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+13
-1
@@ -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
@@ -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():
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
version: 0.1
|
||||||
|
storage:
|
||||||
|
filesystem:
|
||||||
|
rootdirectory: /var/lib/registry
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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 – 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> – offizielles Image von Docker Hub</li>
|
||||||
|
<li><code>bitnami/redis:7</code> – Image eines Docker-Hub-Benutzers</li>
|
||||||
|
<li><code>ghcr.io/owner/app:v1</code> – 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 – einfach nur den Namen eingeben und Holen.
|
||||||
|
</p>
|
||||||
|
<p class="help-step">
|
||||||
|
Nur bei <strong>privaten</strong> Quell-Images Zugangsdaten angeben – je nachdem,
|
||||||
|
was du hast:
|
||||||
|
</p>
|
||||||
|
<ul style="margin: 0.25rem 0 0.5rem 1.25rem;">
|
||||||
|
<li><strong>Benutzername / Kennwort</strong> – 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> – 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 – 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: web: image: nginx:latest cache: image: redis:7"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-top: 0.75rem;">
|
||||||
|
<label for="compose_file">… 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 %}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user