Files
mission-control-v2/backend/routers/homelab.py
T
HitonabiandClaude Opus 5.5 c7c396f56d Welle 2 · Neu entdeckt: neue Geräte mit Vorschlägen, Übernehmen per Klick (Etikett), App-Katalog pflegt sich selbst
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>
2026-09-25 17:25:38 +02:00

382 lines
16 KiB
Python

"""Schnittstellen des Homelab-Teils (Rolle homelab, Phase 3/4, 24.09.2026).
GET /api/homelab/ziele Proxmox-Host und Gäste im gemeinsamen Ziel-Modell
POST /api/homelab/ziele/{id}/update „Jetzt updaten“ (App bzw. Host-Pakete)
POST /api/homelab/ziele/{id}/os-update Pakete im Gast einspielen
POST /api/homelab/ziele/{id}/suchen Paketlisten im Gast erneuern
POST /api/homelab/ziele/{id}/docker Docker über Arcane (zuerst Probelauf)
POST /api/homelab/ziele/{id}/rueckweg-probe Rückweg testen: Snapshot, absichtlich rot, zurück, nachprüfen
POST /api/homelab/ziele/pve/neustart Proxmox-Host neu starten (eigener Knopf)
GET /api/homelab/laeufe Die letzten Läufe (Updates, Snapshot-Aktionen) mit ihren Schritten
GET /api/homelab/snapshots Snapshots und Sicherungen je Gerät (Aktionen: siehe unten)
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
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
GET /api/homelab/ausfuehrer/auftrag ┐
POST /api/homelab/ausfuehrer/bericht ├ nur für den Ausführer (Kopfzeile X-MC2-Ausfuehrer)
POST /api/homelab/ausfuehrer/ergebnis/{id} ┘
GET /api/stream Live-Strom dieser Instanz (Anstöße, kein Messpunkt)
Dünn: die Logik liegt in services/homelab/.
"""
import asyncio
import json
import logging
import time
from fastapi import APIRouter, Depends, Header, HTTPException, Request
from fastapi.responses import StreamingResponse
from kern.einstellungen import einstellungen
from pydantic import BaseModel
from services.homelab import inventar, kanal, updates
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api/homelab", tags=["homelab"])
strom_router = APIRouter(prefix="/api", tags=["homelab"])
def _nur_ausfuehrer(x_mc2_ausfuehrer: str | None = Header(default=None)) -> None:
if not kanal.token_gueltig(x_mc2_ausfuehrer):
raise HTTPException(status_code=403, detail="Nur für den Ausführer auf dem Proxmox-Host.")
kanal.kontakt()
@router.get("/ziele")
def ziele() -> dict:
return inventar.ziele()
def _antwort(ergebnis: dict) -> dict:
if not ergebnis.get("ok"):
raise HTTPException(status_code=409, detail=ergebnis.get("detail") or "Das geht gerade nicht.")
return ergebnis
@router.post("/ziele/{ziel_id}/update")
def update(ziel_id: str) -> dict:
return _antwort(updates.starten(ziel_id, "pakete" if ziel_id == "pve" else "app"))
@router.post("/ziele/{ziel_id}/os-update")
def os_update(ziel_id: str) -> dict:
return _antwort(updates.starten(ziel_id, "os"))
@router.post("/ziele/{ziel_id}/docker")
def docker(ziel_id: str) -> dict:
"""Docker über Arcanes Updater — zuerst nur als Probelauf (MC_ARCANE_ECHT=1 macht es echt)."""
return _antwort(updates.starten(ziel_id, "docker"))
@router.post("/ziele/{ziel_id}/rueckweg-probe")
def rueckweg_probe(ziel_id: str) -> dict:
"""Rückweg testen (seit 25.09.2026): Snapshot anlegen, absichtlich rot werten, zurückgehen, nachprüfen."""
return _antwort(updates.starten(ziel_id, "probe"))
@router.post("/ziele/pve/neustart")
def host_neustart() -> dict:
return _antwort(updates.starten("pve", "neustart"))
@router.post("/ziele/pve/kernel-aufraeumen")
def host_kernel_aufraeumen() -> dict:
"""Alte Kernel vom Proxmox-Host entfernen (seit 25.09.2026, nur per Knopf). Welche, entscheidet der Ausführer."""
return _antwort(updates.starten("pve", "kernel"))
@router.post("/ziele/{ziel_id}/suchen")
def suchen(ziel_id: str) -> dict:
gast = inventar.gast(ziel_id)
if not gast or not gast.get("erlaubt"):
raise HTTPException(status_code=404, detail="Dieses Gerät steht nicht (freigegeben) im Bericht.")
return {"ok": True, "auftrag": kanal.anlegen("suchen", {"vmid": gast["vmid"]})}
@router.get("/hinweise")
def hinweise() -> dict:
"""Was der Wächter dieser Instanz gerade sieht (Ausführer schweigt, Gast antwortet nicht …)."""
from services import waechter
return waechter.lese_stand()
@router.get("/laeufe")
def laeufe() -> dict:
return {"laeufe": updates.laeufe()}
# --- „Alle aktualisieren“, Protokoll, Einstellungen (24.09.2026) ------------------------------------------------
# GET /api/homelab/alle/plan was „Alle aktualisieren“ täte, in dieser Reihenfolge
# POST /api/homelab/alle „Alle aktualisieren“ starten
# GET /api/homelab/sammellauf der laufende oder der letzte Sammellauf
# GET /api/homelab/protokoll?grenze=200&berichte=0 was wann geschah, neueste zuerst
# GET /api/homelab/einstellungen Arcane (Schlüssel, echt), Wartezeit, Ausführer
# POST /api/homelab/einstellungen/arcane-schluessel {"schluessel": …}: erst gegen Arcane prüfen, dann speichern
# DELETE /api/homelab/einstellungen/arcane-schluessel
# POST /api/homelab/einstellungen/arcane-echt {"an": bool}: echte Docker-Updates statt Probelauf
# POST /api/homelab/einstellungen/adguard-zugang {"benutzer", "passwort"}: erst bei AdGuard prüfen, dann speichern
# DELETE /api/homelab/einstellungen/adguard-zugang
# Was nicht geht, beantworten sie mit {"ok": false, "detail": …} und HTTP 200, wie der Telegram-Test (die Oberfläche
# liest ok). Die Dienste laden die Endpunkte selbst, wie hinweise().
@router.get("/alle/plan")
def alle_plan() -> dict:
from services.homelab import sammellauf
return sammellauf.plan()
@router.post("/alle")
def alle_starten() -> dict:
from services.homelab import sammellauf
return sammellauf.starten()
@router.get("/sammellauf")
def sammellauf_stand() -> dict:
from services.homelab import sammellauf
return {"sammellauf": sammellauf.aktueller()}
@router.post("/sammellauf/{sl_id}/quittieren")
def sammellauf_quittieren(sl_id: str) -> dict:
"""Beendetes „Alle aktualisieren“ als gesehen markieren (die Update-Seite blendet es aus)."""
from services.homelab import sammellauf
return {"ok": sammellauf.quittieren(sl_id)}
@router.get("/protokoll")
def protokoll_lesen(grenze: int = 200, berichte: bool = False) -> dict:
from services.homelab import protokoll
return {"eintraege": protokoll.eintraege(grenze, berichte)}
@router.get("/speicher")
def speicher_lage() -> dict:
"""Speicherpool, NAS und Sicherungen samt Empfehlungen, was Platz bringt (Karte im Homelab-Cockpit)."""
from services.homelab import speicher
return speicher.lage()
@router.get("/neu")
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()}
class Wahl(BaseModel):
wahl: str
@router.post("/neu/{ziel_id}")
def neu_uebernehmen(ziel_id: str, w: Wahl) -> dict:
"""Übernehmen: freigeben (Etikett watcher), beobachten (kein Etikett) oder ausschliessen (watcher-aus)."""
from services.homelab import entdecken
return _antwort(entdecken.uebernehmen(ziel_id, w.wahl))
@router.get("/zertifikate")
def zertifikate_lage() -> dict:
"""Die https-Zertifikate mit Ablauf und Stufe (Zertifikats-Wache, seit 25.09.2026)."""
from services.homelab import zertifikate
return zertifikate.lage()
@router.post("/sicherungen/probe")
def sicherungen_probe() -> dict:
"""Knopf „Jetzt prüfen“: die jüngste Sicherung jedes Geräts probeweise öffnen (seit 25.09.2026)."""
from services.homelab import speicher
return _antwort(speicher.probe_starten())
# --- Snapshots und Sicherungen verwalten (Seite Homelab → Snapshots, seit 25.09.2026) ------------------------------
# GET /api/homelab/snapshots je Gerät Snapshots, Sicherungen, ob gerade etwas läuft
# POST /api/homelab/snapshots/{id}/anlegen Snapshot jetzt anlegen (freigegeben, Snapshot möglich)
# POST /api/homelab/snapshots/{id}/{name}/loeschen nur Snapshots des Orchestrators (mc2-…)
# POST /api/homelab/snapshots/{id}/{name}/zuruecksetzen ebenso; danach Prüfung wie nach einem Update
# POST /api/homelab/sicherungen/{id}/{datei}/loeschen nur Sicherungen des Orchestrators (Bericht)
# POST /api/homelab/snapshots/rueckwege-loeschen alle Rückwege von Updates, nacheinander (nur per Klick)
# Jede Aktion ist ein Lauf wie „Jetzt updaten“; was nicht geht, beantworten sie mit 409 wie die Update-Knöpfe.
@router.get("/snapshots")
def snapshots_liste() -> dict:
from services.homelab import snapshots
return snapshots.liste()
@router.post("/snapshots/rueckwege-loeschen")
def rueckwege_loeschen() -> dict:
"""Alle Rückwege von Updates löschen (Snapshots mit Herkunft „update“, Sicherungen des Orchestrators) — nur per
Klick des Users, nacheinander, mit Meldung am Ende."""
from services.homelab import snapshots
return _antwort(snapshots.rueckwege_loeschen())
@router.post("/snapshots/{ziel_id}/anlegen")
def snapshot_anlegen(ziel_id: str) -> dict:
from services.homelab import snapshots
return _antwort(snapshots.anlegen(ziel_id))
@router.post("/snapshots/{ziel_id}/{name}/loeschen")
def snapshot_loeschen(ziel_id: str, name: str) -> dict:
from services.homelab import snapshots
return _antwort(snapshots.loeschen(ziel_id, name))
@router.post("/snapshots/{ziel_id}/{name}/zuruecksetzen")
def snapshot_zuruecksetzen(ziel_id: str, name: str) -> dict:
from services.homelab import snapshots
return _antwort(snapshots.zuruecksetzen(ziel_id, name))
@router.post("/sicherungen/{ziel_id}/{datei}/loeschen")
def sicherung_loeschen(ziel_id: str, datei: str) -> dict:
from services.homelab import snapshots
return _antwort(snapshots.sicherung_loeschen(ziel_id, datei))
@router.get("/einstellungen")
def einstellungen_stand() -> dict:
from services.homelab import einstellungen as homelab_einstellungen
return homelab_einstellungen.stand()
@router.post("/einstellungen/arcane-schluessel")
async def arcane_schluessel_setzen(request: Request) -> dict:
"""Den Body liest der Dienst selbst: Ein Pydantic-Modell würde bei falscher Form mit 422 antworten und die
Eingabe, also den Schlüssel, dabei wiederholen."""
from services.homelab import einstellungen as homelab_einstellungen
wert = homelab_einstellungen.schluessel_aus_body(await request.body())
return await asyncio.to_thread(homelab_einstellungen.arcane_schluessel_setzen, wert)
@router.delete("/einstellungen/arcane-schluessel")
def arcane_schluessel_loeschen() -> dict:
from services.homelab import einstellungen as homelab_einstellungen
return homelab_einstellungen.arcane_schluessel_loeschen()
class ArcaneEcht(BaseModel):
an: bool
@router.post("/einstellungen/arcane-echt")
def arcane_echt(schalter: ArcaneEcht) -> dict:
from services.homelab import einstellungen as homelab_einstellungen
return homelab_einstellungen.arcane_echt_setzen(schalter.an)
@router.post("/einstellungen/adguard-zugang")
async def adguard_zugang_setzen(request: Request) -> dict:
"""Wie beim Arcane-Schlüssel liest der Dienst den Body selbst: Eine 422 würde das Passwort wiederholen."""
from services.homelab import einstellungen as homelab_einstellungen
werte = homelab_einstellungen.zugang_aus_body(await request.body())
return await asyncio.to_thread(homelab_einstellungen.adguard_zugang_setzen, werte)
@router.delete("/einstellungen/adguard-zugang")
def adguard_zugang_loeschen() -> dict:
from services.homelab import einstellungen as homelab_einstellungen
return homelab_einstellungen.adguard_zugang_loeschen()
@router.get("/ausfuehrer")
def ausfuehrer() -> dict:
zuletzt = kanal.zuletzt()
return {"zuletzt": zuletzt, "verbunden": bool(zuletzt and time.time() - zuletzt < inventar.BERICHT_ALT_S),
"auftraege": kanal.liste()[:20]}
@router.get("/ausfuehrer/auftrag", dependencies=[Depends(_nur_ausfuehrer)])
def auftrag_abholen() -> dict:
return kanal.naechster() or {}
@router.post("/ausfuehrer/bericht", dependencies=[Depends(_nur_ausfuehrer)])
def bericht(daten: dict) -> dict:
from services.homelab import entdecken, katalog, speicher
kanal.bericht_speichern(daten)
speicher.verlauf_merken(daten) # Verlauf für „voll in etwa N Tagen“ (seit 25.09.2026)
try: # „Neu entdeckt“ und der Katalog (seit 25.09.2026) halten den Bericht nie auf
entdecken.bericht_gesehen(daten)
katalog.ergaenzen(daten)
except Exception:
log.warning("homelab: Abgleich mit dem Register gescheitert", exc_info=True)
return {"ok": True}
class Ergebnis(BaseModel):
code: int
text: str = ""
@router.post("/ausfuehrer/ergebnis/{auftrag_id}", dependencies=[Depends(_nur_ausfuehrer)])
def ergebnis(auftrag_id: str, e: Ergebnis) -> dict:
return {"ok": kanal.ergebnis(auftrag_id, e.code, e.text)}
# --- Live-Strom der Homelab-Instanz ---------------------------------------------------------------
# Die Oberfläche hört auf /api/stream der Instanz, die sie ausliefert. Im Homelab gibt es keine
# Messpunkte der KI-Box; der Strom stößt nur neu laden an, wenn sich Bericht, Läufe oder Hinweise ändern,
# und meldet sich alle 10 s mit einem leeren Anstoß (sonst hielte die Oberfläche die Leitung für tot).
_DATEIEN = (("homelab", "pve-bericht.json"), ("homelab-laeufe", "homelab-laeufe.json"),
("homelab-hinweise", "mc2-waechter.json"))
def _abdruecke() -> dict[str, float]:
ordner = einstellungen().daten_dir
abdruck = {}
for schluessel, name in _DATEIEN:
try:
abdruck[schluessel] = (ordner / name).stat().st_mtime
except OSError:
abdruck[schluessel] = 0.0
return abdruck
async def _strom(request: Request, takt_s: float = 2.0, lebenszeichen_takte: int = 5):
alt = _abdruecke()
yield ": verbunden\n\n"
takte = 0
while not await request.is_disconnected():
await asyncio.sleep(takt_s)
takte += 1
neu = _abdruecke()
schluessel = [k for k, v in neu.items() if v != alt.get(k)]
alt = neu
if schluessel or takte % lebenszeichen_takte == 0:
yield f"event: invalidate\ndata: {json.dumps({'keys': schluessel})}\n\n"
@strom_router.get("/stream")
async def stream(request: Request) -> StreamingResponse:
return StreamingResponse(_strom(request), media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
# --- Messwerte mit Verlauf (Monitoring, seit 24.09.2026) ------------------------------------------------------
# POST /api/homelab/ausfuehrer/messwerte jede Minute vom Ausführer (Kopfzeile X-MC2-Ausfuehrer)
# GET /api/homelab/messwerte?zeitraum=1h|24h|7d Proxmox-Host und Gäste: Reihen und letzter Punkt
# Die Logik liegt in services/homelab/messwerte.py, die Ablage in kern/messreihen.py.
@router.post("/ausfuehrer/messwerte", dependencies=[Depends(_nur_ausfuehrer)])
def messwerte_annehmen(daten: dict) -> dict:
from services.homelab import messwerte as homelab_messwerte
return {"ok": True, "geraete": homelab_messwerte.annehmen(daten)}
@router.get("/messwerte")
def messwerte_lesen(zeitraum: str = "1h") -> dict:
from services.homelab import messwerte as homelab_messwerte
if zeitraum not in homelab_messwerte.ZEITRAEUME:
raise HTTPException(status_code=400, detail="Den Zeitraum gibt es nicht; möglich sind 1h, 24h und 7d.")
return homelab_messwerte.abfrage(zeitraum)