Welle 2 · Docker-Dienste unter Arcane einzeln: Stacks, Zustand, Gesundheit, Update-Stand; neue Stacks unter „Neu entdeckt“

Statt „Arcane (Docker) · 11 Images“ zeigt das Homelab-Cockpit je Stack jeden Container mit Zustand, Healthcheck und
Update-Stand („Update verfügbar“, „lokal gebaut“ für NerdQuiz und Rippy, „aktuell“). Der Wächter meldet gelb, wenn
ein Container ungesund ist oder ständig neu startet; beendete Container sind kein Befund. Neue Stacks erscheinen unter
„Neu entdeckt“ (eigenes Register, nur „Übernehmen“), Lucy meldet sie einmal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-25 17:31:05 +02:00
co-authored by Claude Opus 5.5
parent c7c396f56d
commit 6af42bd42c
13 changed files with 480 additions and 21 deletions
+12 -2
View File
@@ -12,7 +12,8 @@
POST /api/homelab/snapshots/rueckwege-loeschen Alle Rückwege von Updates löschen (nur per Klick)
POST /api/homelab/sicherungen/probe Probe-Wiederherstellung jetzt (nur lesend, sonst einmal die Woche)
GET /api/homelab/zertifikate https-Zertifikate (NPMplus, Proxmox-Host, PBS) mit Stufe
GET /api/homelab/neu „Neu entdeckt“: Geräte, die der Orchestrator noch nicht kennt
GET /api/homelab/neu „Neu entdeckt“: Geräte und Docker-Stacks, die er noch nicht kennt
GET /api/homelab/docker Docker-Dienste unter Arcane je Stack: Zustand, Gesundheit, Update
POST /api/homelab/neu/{id} {"wahl": freigeben | beobachten | ausschliessen} — übernehmen
GET /api/homelab/hinweise Hinweise des Wächters dieser Instanz
GET /api/homelab/ausfuehrer Lebenszeichen des Ausführers
@@ -28,6 +29,7 @@ Dünn: die Logik liegt in services/homelab/.
import asyncio
import json
import logging
import threading
import time
from fastapi import APIRouter, Depends, Header, HTTPException, Request
@@ -168,7 +170,14 @@ def speicher_lage() -> dict:
def neu_entdeckt() -> dict:
"""Geräte, die der Orchestrator noch nicht kennt, mit Vorschlägen (seit 25.09.2026)."""
from services.homelab import entdecken
return {"geraete": entdecken.neue()}
return {"geraete": entdecken.neue(), "docker": entdecken.neue_docker()}
@router.get("/docker")
def docker_dienste() -> dict:
"""Die Docker-Container unter Arcane, je Stack (seit 25.09.2026)."""
from services.homelab import docker
return docker.lage()
class Wahl(BaseModel):
@@ -308,6 +317,7 @@ def bericht(daten: dict) -> dict:
try: # „Neu entdeckt“ und der Katalog (seit 25.09.2026) halten den Bericht nie auf
entdecken.bericht_gesehen(daten)
katalog.ergaenzen(daten)
threading.Thread(target=entdecken.docker_abgleichen, name="docker-abgleich", daemon=True).start()
except Exception:
log.warning("homelab: Abgleich mit dem Register gescheitert", exc_info=True)
return {"ok": True}
+19
View File
@@ -200,6 +200,25 @@ def _images_mit_update(url: str) -> list[dict]:
return ergebnis
def container_liste(url: str) -> list[dict] | None:
"""Alle Container über alle Umgebungen, wie Arcane sie liefert (Docker-Dienste einzeln, seit 25.09.2026). None
ohne Schlüssel oder wenn Arcane nicht antwortet."""
if not schluessel():
return None
return _gemerkt(f"container-liste:{url}", lambda: _container_liste(url))
def _container_liste(url: str) -> list[dict] | None:
ergebnis: list[dict] = []
try:
for umgebung in umgebungen(url):
daten = _get(url, f"/environments/{umgebung['id']}/containers?limit=500")
ergebnis += [c for c in (daten or {}).get("data") or [] if isinstance(c, dict)]
except (httpx.HTTPError, ValueError, TypeError, AttributeError):
return None
return ergebnis
def container_zahlen(url: str) -> dict | None:
"""Laufende und alle Container über alle Umgebungen, für die Kachel (seit 25.09.2026): {"laufend", "gesamt"}.
None ohne Schlüssel oder wenn Arcane nicht antwortet."""
+91
View File
@@ -0,0 +1,91 @@
"""Docker-Dienste unter Arcane einzeln (Ausbauplan Welle 2, Punkt 8, seit 25.09.2026).
Bis zum 25.09. stand im Orchestrator nur „Arcane (Docker) · 11 Images“. Jetzt fragt er Arcane nach jedem Container
(GET /environments/{id}/containers, mit dem Schlüssel aus den Einstellungen, höchstens alle zwei Minuten) und zeigt je
Stack (Compose-Projekt) Zustand, Gesundheit, Image und Update-Stand:
• „Update verfügbar“, wenn Arcane für das Image einen neueren Stand kennt,
• „lokal gebaut“, wenn das Image aus keiner Registry kommt (Arcane: updateType „local“, oder die Registry kennt es
nicht — NerdQuiz und Rippy baut der User selbst) — dort gibt es keinen Abgleich,
• sonst „aktuell“.
Der Wächter meldet gelb, wenn ein Container ungesund ist (Healthcheck) oder immer wieder neu startet; beendete
Container (etwa einmalige Aufgaben) sind kein Befund. Neue Stacks erscheinen unter „Neu entdeckt“ (entdecken.py).
"""
import logging
import re
from services.homelab import apps, arcane, einstellungen, kanal
log = logging.getLogger(__name__)
_GESUND = re.compile(r"\((healthy|unhealthy|health: starting)\)")
_LOKAL = re.compile(r"distribution inspect|manifest unknown|pull access denied|repository does not exist", re.IGNORECASE)
def container_zeile(c: dict) -> dict:
"""Ein Container aus Arcanes Liste in der Form der Oberfläche."""
labels = c.get("labels") if isinstance(c.get("labels"), dict) else {}
info = c.get("updateInfo") if isinstance(c.get("updateInfo"), dict) else {}
gesund = _GESUND.search(str(c.get("status") or ""))
fehler = str(info.get("error") or "")
if info.get("hasUpdate"):
update = "neu"
elif info.get("updateType") == "local" or _LOKAL.search(fehler):
update = "lokal"
elif fehler:
update = "unbekannt"
else:
update = "aktuell"
name = str((c.get("names") or ["?"])[0]).lstrip("/")
return {"id": str(c.get("id") or name)[:12], "name": name, "stack": labels.get("com.docker.compose.project"),
"dienst": labels.get("com.docker.compose.service"), "image": c.get("image"),
"zustand": str(c.get("state") or "unbekannt"), "status": c.get("status"),
"gesund": {"healthy": True, "unhealthy": False}.get(gesund.group(1)) if gesund else None,
"update": update, "aktuell": info.get("currentVersion") or None, "neu": info.get("latestVersion") or None}
def lage() -> dict:
"""Für GET /api/homelab/docker: {"verfuegbar", "grund", "stacks": [{"name", "container": […]}]}."""
url = einstellungen.arcane_url()
if not url:
return {"verfuegbar": False, "grund": "Arcane steht nicht im Bericht des Ausführers.", "stacks": []}
if not arcane.schluessel():
return {"verfuegbar": False, "grund": "Es fehlt der Arcane-API-Schlüssel (Einstellungen → Homelab).", "stacks": []}
roh = arcane.container_liste(url)
if roh is None:
return {"verfuegbar": False, "grund": "Arcane antwortet gerade nicht.", "stacks": []}
stacks: dict[str, list[dict]] = {}
for c in roh:
zeile = container_zeile(c)
stacks.setdefault(zeile["stack"] or zeile["name"], []).append(zeile)
return {"verfuegbar": True, "grund": None,
"stacks": [{"name": n, "container": sorted(cs, key=lambda z: z["name"])} for n, cs in sorted(stacks.items())]}
def arcane_ziel() -> str:
"""Die Ziel-ID der Arcane-VM (für die Hinweise), aus dem Bericht; sonst „arcane“."""
for g in ((kanal.bericht() or {}).get("bericht") or {}).get("gaeste") or []:
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
if app and app.kennung == "arcane":
return f"{'ct' if g.get('art') == 'lxc' else 'vm'}-{g['vmid']}"
return "arcane"
def pruefe_docker() -> list:
"""Wächter: gelb, wenn ein Container ungesund ist oder ständig neu startet. Ohne Arcane oder Schlüssel: nichts."""
from services.waechter import Befund # der Wächter registriert diese Prüfung, also erst hier
befunde = []
quelle = None
for s in lage()["stacks"]:
for c in s["container"]:
if c["gesund"] is False:
titel, text = (f"Docker-Dienst {c['name']} ist ungesund",
f"Sein Healthcheck schlägt fehl ({c['status']}). In Arcane: Container → {c['name']} → Logs.")
elif c["zustand"] == "restarting":
titel, text = (f"Docker-Dienst {c['name']} startet immer wieder neu",
f"Er kommt nicht hoch ({c['status']}). In Arcane: Container → {c['name']} → Logs.")
else:
continue
quelle = quelle or arcane_ziel()
befunde.append(Befund(id=f"docker:{c['name']}", stufe="gelb", titel=titel, text=text, quelle=quelle))
return befunde
+70 -9
View File
@@ -8,6 +8,8 @@ den es noch nicht gibt (oder dessen Nummer jetzt einen anderen Namen trägt), wi
Snapshots anlegen und Updates einspielen. Hat er schon community-script, genügt Übernehmen.
• „Nur beobachten“ kein Etikett; er steht in der Liste, Updates gibt es für ihn nicht.
• „Nicht anfassen“ setzt watcher-aus: auch ein Community-Script bleibt dann unberührt.
Neue Docker-Stacks unter Arcane (Compose-Projekte, docker.py) kommen genauso dazu, mit eigenem Register
(docker-bekannt.json) und nur „Übernehmen“ — Etiketten gibt es dort nicht.
Beim allerersten Bericht (noch kein Register) gilt alles als bekannt — sonst stünden alle Geräte auf einmal da.
Verschwindet ein Gast, fällt er aus dem Register; kommt unter derselben Nummer ein neuer, ist er wieder „neu“.
Der eigene Container (Etikett mc2) erscheint nie.
@@ -33,20 +35,23 @@ ZEITLIMIT_S = 60
_lock = threading.Lock()
def _pfad() -> Path:
return einstellungen().daten_dir / "geraete-bekannt.json"
def _pfad(name: str = "geraete-bekannt.json") -> Path:
return einstellungen().daten_dir / name
def _lesen() -> dict | None:
DOCKER = "docker-bekannt.json"
def _lesen(name: str = "geraete-bekannt.json") -> dict | None:
try:
daten = json.loads(_pfad().read_text(encoding="utf-8"))
daten = json.loads(_pfad(name).read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
return daten if isinstance(daten, dict) else None
def _schreiben(daten: dict) -> None:
pfad = _pfad()
def _schreiben(daten: dict, name: str = "geraete-bekannt.json") -> None:
pfad = _pfad(name)
pfad.parent.mkdir(parents=True, exist_ok=True)
tmp = pfad.with_suffix(".tmp")
tmp.write_text(json.dumps(daten, ensure_ascii=False, indent=1), encoding="utf-8")
@@ -127,18 +132,74 @@ def neue() -> list[dict]:
return liste
def _markieren(zid: str) -> None:
def _markieren(zid: str, name: str = "geraete-bekannt.json") -> None:
with _lock:
register = _lesen() or {}
register = _lesen(name) or {}
if zid in register:
register[zid]["zustand"] = "bekannt"
_schreiben(register)
_schreiben(register, name)
# --- Docker-Stacks (seit 25.09.2026) ----------------------------------------------------------------------------------
def docker_gesehen(stacks: list[dict], jetzt: float | None = None, melden=None) -> list[str]:
"""Die Stacks aus docker.lage() mit dem Register abgleichen. Rückgabe: die IDs („docker:<stack>“), die neu sind."""
jetzt = time.time() if jetzt is None else jetzt
melden = melden or _melden
aktuell = {f"docker:{s['name']}": s for s in stacks if s.get("name")}
if not aktuell:
return []
with _lock:
register = _lesen(DOCKER)
if register is None:
_schreiben({sid: {"erstmals": jetzt, "zustand": "bekannt"} for sid in aktuell}, DOCKER)
return []
neu = [sid for sid in aktuell if sid not in register]
for sid in neu:
register[sid] = {"erstmals": jetzt, "zustand": "neu"}
for sid in [s for s in register if s not in aktuell]:
register.pop(sid)
_schreiben(register, DOCKER)
for sid in neu:
namen = ", ".join(c["name"] for c in aktuell[sid].get("container") or [])
melden(f"Neu unter Arcane: der Docker-Stack „{aktuell[sid]['name']}“ ({namen}). Im Homelab-Cockpit unter „Neu "
"entdeckt“ übernehmen.")
return neu
def docker_abgleichen() -> None:
"""Nach jedem Bericht im eigenen Faden: Arcane fragen (gemerkt) und neue Stacks eintragen."""
from services.homelab import docker
try:
lage = docker.lage()
if lage["verfuegbar"]:
docker_gesehen(lage["stacks"])
except Exception:
log.warning("entdecken: Docker-Abgleich gescheitert", exc_info=True)
def neue_docker() -> list[dict]:
"""Neue Docker-Stacks für GET /api/homelab/neu."""
from services.homelab import docker
register = _lesen(DOCKER) or {}
offen = {sid: e for sid, e in register.items() if e.get("zustand") == "neu"}
if not offen:
return []
lage = docker.lage()
return [{"id": f"docker:{s['name']}", "name": s["name"], "erstmals": offen[f"docker:{s['name']}"].get("erstmals"),
"container": [c["name"] for c in s["container"]]}
for s in lage["stacks"] if f"docker:{s['name']}" in offen]
def uebernehmen(zid: str, wahl: str) -> dict:
"""POST /api/homelab/neu/{zid}: „freigeben“, „beobachten“ oder „ausschliessen“."""
if wahl not in WAHL:
return {"ok": False, "detail": "Unbekannte Wahl."}
if zid.startswith("docker:"):
if wahl != "beobachten" or zid not in {s["id"] for s in neue_docker()}:
return {"ok": False, "detail": "Dieser Docker-Stack steht nicht (mehr) unter „Neu entdeckt“."}
_markieren(zid, DOCKER)
return {"ok": True, "text": f"Der Stack {zid.split(':', 1)[1]} ist übernommen."}
geraet = next((g for g in neue() if g["id"] == zid), None)
if not geraet:
return {"ok": False, "detail": "Dieses Gerät steht nicht (mehr) unter „Neu entdeckt“."}
+2
View File
@@ -624,6 +624,8 @@ PRUEFUNGEN += (__import__("services.homelab.messwerte").homelab.messwerte.pruefe
PRUEFUNGEN += (__import__("services.homelab.speicher").homelab.speicher.pruefe_speicher,) if ROLLE == "homelab" else ()
# Zertifikats-Wache (seit 25.09.2026): gelb, wenn die Erneuerung hängt; rot kurz vor Ablauf.
PRUEFUNGEN += (__import__("services.homelab.zertifikate").homelab.zertifikate.pruefe_zertifikate,) if ROLLE == "homelab" else ()
# Docker-Dienste unter Arcane (seit 25.09.2026): gelb, wenn einer ungesund ist oder ständig neu startet.
PRUEFUNGEN += (__import__("services.homelab.docker").homelab.docker.pruefe_docker,) if ROLLE == "homelab" else ()
BETREFF_PROBLEM, BETREFF_OK = {"box": ("[Box-Problem]", "[Box wieder ok]"),
"homelab": ("[Homelab-Problem]", "[Homelab wieder ok]")}[ROLLE]
+92
View File
@@ -0,0 +1,92 @@
"""Docker-Dienste unter Arcane einzeln (Ausbauplan Welle 2, Punkt 8, 25.09.2026). Die Container stammen aus Arcanes
Antwort vom 25.09. (sieben Container in drei Stacks; NerdQuiz und Rippy baut der User selbst)."""
import json
import pytest
from kern import einstellungen as einstellungen_mod
from services.homelab import arcane, docker, einstellungen, entdecken
JETZT = 1_790_340_000.0
def _c(name: str, stack: str, image: str, status: str = "Up 3 hours", state: str = "running", **info) -> dict:
return {"id": f"{name}0123456789ab", "names": [f"/{name}"], "image": image, "state": state, "status": status,
"labels": {"com.docker.compose.project": stack, "com.docker.compose.service": name.split("-")[1]
if "-" in name else name}, "updateInfo": info}
ARCANE = [
_c("arcane", "arcane", "ghcr.io/getarcaneapp/manager:latest", updateType="digest", currentVersion="latest"),
_c("nerdquiz", "deploy", "nerdquiz:latest", "Up 3 hours (healthy)", currentVersion="1.18.2",
error="distribution inspect of docker.io/library/nerdquiz:1.18.2 failed: Error response from daemon"),
_c("rippy-api-1", "rippy", "rippy-api", "Up 3 hours (healthy)", updateType="local"),
_c("rippy-postgres-1", "rippy", "postgres:16-alpine", "Up 3 hours (healthy)", updateType="digest", hasUpdate=True,
currentVersion="16-alpine"),
]
def test_container_zeile():
zeilen = {z["name"]: z for z in map(docker.container_zeile, ARCANE)}
assert zeilen["arcane"]["update"] == "aktuell" and zeilen["arcane"]["gesund"] is None
assert zeilen["nerdquiz"]["update"] == "lokal" and zeilen["nerdquiz"]["gesund"] is True # kein Fehler, lokal gebaut
assert zeilen["rippy-api-1"]["update"] == "lokal" and zeilen["rippy-api-1"]["stack"] == "rippy"
assert zeilen["rippy-postgres-1"]["update"] == "neu" and zeilen["rippy-postgres-1"]["dienst"] == "postgres"
krank = docker.container_zeile(_c("x", "s", "i", "Up 2 minutes (unhealthy)", error="timeout"))
assert (krank["gesund"], krank["update"]) == (False, "unbekannt")
def test_lage_je_stack(monkeypatch):
monkeypatch.setattr(einstellungen, "arcane_url", lambda: None)
assert docker.lage() == {"verfuegbar": False, "grund": "Arcane steht nicht im Bericht des Ausführers.", "stacks": []}
monkeypatch.setattr(einstellungen, "arcane_url", lambda: "http://192.168.178.28:3552")
monkeypatch.setattr(arcane, "schluessel", lambda: "")
assert docker.lage()["grund"].startswith("Es fehlt der Arcane-API-Schlüssel")
monkeypatch.setattr(arcane, "schluessel", lambda: "schluessel")
monkeypatch.setattr(arcane, "container_liste", lambda url: None)
assert docker.lage()["grund"] == "Arcane antwortet gerade nicht."
monkeypatch.setattr(arcane, "container_liste", lambda url: ARCANE)
lage = docker.lage()
assert [(s["name"], [c["name"] for c in s["container"]]) for s in lage["stacks"]] == [
("arcane", ["arcane"]), ("deploy", ["nerdquiz"]), ("rippy", ["rippy-api-1", "rippy-postgres-1"])]
def test_waechter_meldet_ungesunde_und_neustartende(monkeypatch):
monkeypatch.setattr(einstellungen, "arcane_url", lambda: "http://arcane")
monkeypatch.setattr(arcane, "schluessel", lambda: "schluessel")
monkeypatch.setattr(docker, "arcane_ziel", lambda: "vm-106")
krank = [*ARCANE, _c("rippy-worker-1", "rippy", "rippy-worker", "Up 1 minute (unhealthy)"),
_c("einmal", "jobs", "busybox", "Exited (0) 2 hours ago", state="exited"),
_c("hakt", "jobs", "busybox", "Restarting (1) 5 seconds ago", state="restarting")]
monkeypatch.setattr(arcane, "container_liste", lambda url: krank)
befunde = {b.id: b for b in docker.pruefe_docker()}
assert set(befunde) == {"docker:rippy-worker-1", "docker:hakt"} # beendet ist kein Befund
assert befunde["docker:rippy-worker-1"].titel == "Docker-Dienst rippy-worker-1 ist ungesund"
assert befunde["docker:hakt"].quelle == "vm-106" and befunde["docker:hakt"].stufe == "gelb"
@pytest.fixture
def daten(tmp_path, monkeypatch):
monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path))
einstellungen_mod.einstellungen.cache_clear()
yield tmp_path
einstellungen_mod.einstellungen.cache_clear()
def test_neue_stacks(daten, monkeypatch):
meldungen: list[str] = []
stacks = [{"name": "arcane", "container": [{"name": "arcane"}]}, {"name": "rippy", "container": [{"name": "rippy-api-1"}]}]
assert entdecken.docker_gesehen(stacks, JETZT, meldungen.append) == [] # erster Abgleich: alles bekannt
neu = [*stacks, {"name": "immich", "container": [{"name": "immich-server"}, {"name": "immich-ml"}]}]
assert entdecken.docker_gesehen(neu, JETZT + 600, meldungen.append) == ["docker:immich"]
assert meldungen == [("Neu unter Arcane: der Docker-Stack „immich“ (immich-server, immich-ml). Im Homelab-Cockpit "
"unter „Neu entdeckt“ übernehmen.")]
monkeypatch.setattr(docker, "lage", lambda: {"verfuegbar": True, "grund": None, "stacks": [
{"name": s["name"], "container": [{"name": c["name"]} for c in s["container"]]} for s in neu]})
(stack,) = entdecken.neue_docker()
assert (stack["id"], stack["container"]) == ("docker:immich", ["immich-server", "immich-ml"])
assert entdecken.uebernehmen("docker:immich", "freigeben")["ok"] is False # Etiketten gibt es dort nicht
assert entdecken.uebernehmen("docker:immich", "beobachten") == {"ok": True, "text": "Der Stack immich ist übernommen."}
assert entdecken.neue_docker() == []
register = json.loads((daten / "docker-bekannt.json").read_text(encoding="utf-8"))
assert register["docker:immich"]["zustand"] == "bekannt"
+1 -1
View File
@@ -221,7 +221,7 @@ print(json.dumps({"pruefungen": [p.__name__ for p in waechter.PRUEFUNGEN], "date
""")
assert ergebnis["pruefungen"] == ["pruefe_platte", "pruefe_partner", "pruefe_ausfuehrer", "pruefe_gaeste",
"pruefe_gast_platten", "pruefe_paketlisten", "pruefe_host", "pruefe_speicher",
"pruefe_zertifikate"]
"pruefe_zertifikate", "pruefe_docker"]
def test_homelab_rolle_hat_die_homelab_schnittstellen(tmp_path):