Jeder Bericht wird mit einem Register bekannter Geräte verglichen; ein neuer Container oder eine neue VM erscheint im Homelab-Cockpit unter „Neu entdeckt“, und Lucy meldet ihn einmal über Telegram. Übernehmen: „Für Updates freigeben“ (der Ausführer setzt das Etikett watcher — neue Aktion etikett_setzen, sie darf nur watcher und watcher-aus setzen oder entfernen), „Nur beobachten“ oder „Nicht anfassen“ (watcher-aus). Unbekannte Community-Scripts-Kennungen trägt katalog.py aus ihrem Update-Skript nach (Name, GitHub-Projekt, Port, Weg); ein Port, der nicht antwortet, schaltet die Webprüfung nicht ein. Der Ausführer braucht ausfuehrer-einrichten.sh. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
158 lines
7.3 KiB
Python
158 lines
7.3 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.
|
|
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() -> Path:
|
|
return einstellungen().daten_dir / "geraete-bekannt.json"
|
|
|
|
|
|
def _lesen() -> dict | None:
|
|
try:
|
|
daten = json.loads(_pfad().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()
|
|
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) -> None:
|
|
with _lock:
|
|
register = _lesen() or {}
|
|
if zid in register:
|
|
register[zid]["zustand"] = "bekannt"
|
|
_schreiben(register)
|
|
|
|
|
|
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."}
|
|
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 "")}
|