Files
mission-control-v2/backend/routers/homelab.py
T

158 lines
5.8 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/pve/neustart Proxmox-Host neu starten (eigener Knopf)
GET /api/homelab/laeufe Die letzten Update-Läufe mit ihren Schritten
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 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
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/pve/neustart")
def host_neustart() -> dict:
return _antwort(updates.starten("pve", "neustart"))
@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()}
@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:
kanal.bericht_speichern(daten)
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"})