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:
co-authored by
Claude Opus 5.5
parent
c7c396f56d
commit
6af42bd42c
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
@@ -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“."}
|
||||
|
||||
Reference in New Issue
Block a user