Files
mission-control-v2/backend/services/homelab/entdecken.py
T
HitonabiandClaude Opus 5.5 6af42bd42c 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>
2026-09-25 17:31:05 +02:00

219 lines
10 KiB
Python

"""„Neu entdeckt“: Geräte, die der Orchestrator noch nicht kennt (Ausbauplan Welle 2, Punkt 7, seit 25.09.2026).
Jeder Bericht des Ausführers wird mit dem Register <Datenordner>/geraete-bekannt.json verglichen. Taucht ein Gast auf,
den es noch nicht gibt (oder dessen Nummer jetzt einen anderen Namen trägt), wird er „neu“: Lucy meldet ihn einmal
über Telegram, und das Homelab-Cockpit zeigt ihn als Karte „Neu entdeckt“ mit Vorschlägen — welche App darin steckt
(katalog.py), ob ihn ein Sicherungsauftrag erfasst, ob seine Oberfläche geprüft wird. Ein Klick übernimmt ihn:
• „Für Updates freigeben“ setzt das Etikett watcher (Ausführer: etikett_setzen) — damit darf der Orchestrator
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.
"""
import json
import logging
import threading
import time
from pathlib import Path
from kern.einstellungen import einstellungen
from services import announce
from services.homelab import apps, kanal
from services.homelab.inventar import EIGENES_ETIKETT
log = logging.getLogger(__name__)
BETREFF = "[Homelab]"
WAHL = {"freigeben": ("watcher", True), "ausschliessen": ("watcher-aus", True), "beobachten": (None, None)}
ZEITLIMIT_S = 60
_lock = threading.Lock()
def _pfad(name: str = "geraete-bekannt.json") -> Path:
return einstellungen().daten_dir / name
DOCKER = "docker-bekannt.json"
def _lesen(name: str = "geraete-bekannt.json") -> dict | None:
try:
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, 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")
tmp.replace(pfad)
def _zid(g: dict) -> str:
return f"{'ct' if g.get('art') == 'lxc' else 'vm'}-{g['vmid']}"
def _anzeigename(g: dict) -> str:
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
return app.name if app else str(g.get("name") or _zid(g))
def bericht_gesehen(bericht: dict, jetzt: float | None = None, melden=None) -> list[str]:
"""Den Bericht mit dem Register abgleichen. Rückgabe: die Ziel-IDs, die gerade neu dazukamen."""
jetzt = time.time() if jetzt is None else jetzt
melden = melden or _melden
gaeste = {_zid(g): g for g in bericht.get("gaeste") or []
if isinstance(g, dict) and "vmid" in g and EIGENES_ETIKETT not in (g.get("etiketten") or [])}
if not gaeste:
return [] # ein leerer oder kaputter Bericht löscht nie das Register
with _lock:
register = _lesen()
if register is None:
_schreiben({zid: {"name": g.get("name"), "erstmals": jetzt, "zustand": "bekannt"} for zid, g in gaeste.items()})
return []
neu = []
for zid, g in gaeste.items():
alt = register.get(zid)
if alt and alt.get("name") == g.get("name"):
continue
register[zid] = {"name": g.get("name"), "erstmals": jetzt, "zustand": "neu"}
neu.append(zid)
for zid in [z for z in register if z not in gaeste]:
register.pop(zid)
_schreiben(register)
for zid in neu:
g = gaeste[zid]
art = "Container" if g.get("art") == "lxc" else "VM"
melden(f"Neu im Homelab: {art} {g['vmid']} „{g.get('name') or zid}“ ({_anzeigename(g)}). Ich habe ihn noch "
"nicht übernommen — im Homelab-Cockpit unter „Neu entdeckt“ entscheidest du, ob der Orchestrator ihn "
"aktualisieren darf.")
return neu
def _melden(text: str) -> None:
try:
announce.notify_telegram(BETREFF, text)
except Exception:
log.warning("entdecken: Meldung ging nicht raus", exc_info=True)
def neue() -> list[dict]:
"""Für GET /api/homelab/neu: je neu entdecktem Gerät, was der Orchestrator darüber weiß und vorschlägt."""
register = _lesen() or {}
eingang = (kanal.bericht() or {}).get("bericht") or {}
host = eingang.get("host") or {}
ohne_auftrag = set(host.get("nicht_gesichert") or [])
liste = []
for g in eingang.get("gaeste") or []:
zid = _zid(g) if isinstance(g, dict) and "vmid" in g else None
if not zid or (register.get(zid) or {}).get("zustand") != "neu":
continue
kennung = (g.get("app") or {}).get("kennung")
app = apps.app_fuer(kennung, g.get("name"))
etiketten = list(g.get("etiketten") or [])
liste.append({
"id": zid, "vmid": g["vmid"], "name": app.name if app else str(g.get("name") or zid),
"hostname": g.get("name"), "art": "container" if g.get("art") == "lxc" else "vm",
"status": g.get("status"), "erstmals": register[zid].get("erstmals"), "etiketten": etiketten,
"freigegeben": bool(g.get("erlaubt")), "ausgeschlossen": "watcher-aus" in etiketten,
"app": ({"kennung": kennung, "name": app.name, "quelle": app.quelle, "weg": app.weg,
"adresse": apps.adresse(app, g.get("ip"))} if app else None),
"community_script": "community-script" in etiketten, "ip": g.get("ip"),
"ohne_sicherung": g["vmid"] in ohne_auftrag})
return liste
def _markieren(zid: str, name: str = "geraete-bekannt.json") -> None:
with _lock:
register = _lesen(name) or {}
if zid in register:
register[zid]["zustand"] = "bekannt"
_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“."}
etikett, an = WAHL[wahl]
schon = (wahl == "freigeben" and geraet["freigegeben"]) or (wahl == "ausschliessen" and geraet["ausgeschlossen"])
if etikett and not schon:
auftrag = kanal.anlegen("etikett_setzen", {"vmid": geraet["vmid"], "etikett": etikett, "an": an})
ergebnis = kanal.warten(auftrag, ZEITLIMIT_S)
if not ergebnis or ergebnis.get("status") != "fertig":
grund = (ergebnis or {}).get("text") or "Der Ausführer hat nicht rechtzeitig geantwortet."
return {"ok": False, "detail": f"Das Etikett ließ sich nicht setzen: {str(grund)[-300:]}"}
_markieren(zid)
text = {"freigeben": f"{geraet['name']} ist übernommen und für Updates freigegeben.",
"beobachten": f"{geraet['name']} ist übernommen; der Orchestrator beobachtet ihn nur.",
"ausschliessen": f"{geraet['name']} ist übernommen und bleibt unberührt (Etikett watcher-aus)."}[wahl]
return {"ok": True, "text": text + (" Der nächste Bericht zeigt es in der Geräteliste." if etikett else "")}