- deploy/homelab/ausfuehrer.py: laeuft als root auf dem Proxmox-Host, holt Auftraege beim Homelab-Teil ab (Pull, kein offener Port), feste Aktionsliste, prueft Etiketten selbst; Bericht gegen den echten Host erprobt (nur lesend). Kein Proxmox-Schluessel im Container noetig. - services/homelab: Kanal mit gemeinsamem Geheimnis, App-Katalog, Inventar -> Ziele im gemeinsamen Modell (GitHub-Versionen, Webpruefung, alte Paketlisten = unklar), Jetzt updaten: Snapshot -> Update -> Pruefung -> bei Rot zurueck + dringende Meldung - Waechter in der Rolle homelab: Ausfuehrer schweigt, Gaeste antworten nicht - kern/github.py fuer beide Rollen (auch untagged Releases mit Version im Namen) - Oberflaeche: Seite Homelab zeigt alle Geraete als Karten (KI-Box ueber /api/ziele, Homelab ueber /api/homelab/ziele) mit Stand, Rueckweg und Knopf samt Rueckfrage - Einrichtung als Skripte (container-anlegen, ausrollen, ausfuehrer-einrichten, box-partner) — noch nicht ausgefuehrt, jeder Schritt braucht das User-OK - frontend-bauen-box.sh: Frontend-Pruefung und Build auf der Box, wenn der PC keinen Speicher hat Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
199 lines
8.6 KiB
Python
199 lines
8.6 KiB
Python
"""
|
|
Homelab Orchestrator (vormals Mission Control 2.0) — dünner FastAPI-Einstieg.
|
|
|
|
Hängt die Router ein, liefert (in Prod) das gebaute React-Frontend aus und
|
|
setzt eine no-cache-Middleware. Im Dev läuft das Frontend über den Vite-Dev-
|
|
Server (proxyt /api hierher), daher CORS für localhost offen.
|
|
|
|
Zwei Rollen, derselbe Code (MC_ROLLE, kern/einstellungen.py, seit 24.09.2026):
|
|
box — die KI-Box: Box-Wart (Updater, Wächter, Modell-Radar) plus die Schnittstellen,
|
|
die Lucy-Desktop, OpenChamber und Hermes brauchen (Sprache, /v1, MCP-Werkzeuge)
|
|
homelab — der Proxmox-PC: der Homelab-Teil
|
|
Beide liefern dieselbe Oberfläche aus und reichen den Bereich der anderen Instanz unter
|
|
/api/partner/… durch.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
from collections.abc import AsyncGenerator, Awaitable, Callable
|
|
from contextlib import asynccontextmanager
|
|
from typing import Any
|
|
|
|
import httpx
|
|
from config import FRONTEND_DIST, V1_UPSTREAM, VERSION
|
|
from fastapi import FastAPI, HTTPException
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import FileResponse, JSONResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from kern.einstellungen import einstellungen
|
|
from routers import health, partner
|
|
from services.herkunft import erlaubt
|
|
from starlette.requests import Request
|
|
|
|
ROLLE = einstellungen().rolle
|
|
|
|
# Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration
|
|
# für alle Module (logging.getLogger(__name__)).
|
|
logging.basicConfig(
|
|
level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(),
|
|
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
|
)
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
def _box_hintergrund() -> list[asyncio.Task[Any]]:
|
|
"""Hintergrund-Tasks der KI-Box. Der Wächter läuft NICHT hier, sondern im mc2-steward
|
|
(eigener Prozess, steward.py) — ein totes MC2 könnte sich sonst nicht selbst melden."""
|
|
from services import reminders, warmer
|
|
|
|
tasks: list[asyncio.Task[Any]] = []
|
|
if warmer.ENABLED:
|
|
tasks.append(asyncio.create_task(warmer.rewarm_loop()))
|
|
log.info(
|
|
"Hirn-Re-Warm-Wächter aktiv (Intervall %ss, Hirn dynamisch aus Hermes-Config)",
|
|
warmer.INTERVAL,
|
|
)
|
|
# Probelauf (neue Fassung neben der laufenden, Box-Wart-Umbau 09/2026): Erinnerungen
|
|
# gehören dem echten MC2 — zwei Schreiber würden Erinnerungen doppelt feuern.
|
|
if os.environ.get("MC_PROBELAUF", "") != "1":
|
|
tasks.append(asyncio.create_task(reminders.reminders_loop()))
|
|
else:
|
|
log.info("Probelauf: Erinnerungen bleiben beim echten MC2.")
|
|
return tasks
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|
"""Hintergrund-Tasks an den App-Lebenszyklus binden."""
|
|
# Aufträge (Updates, Downloads) laufen als eigene Einheiten weiter, während MC2 neu startet —
|
|
# hier werden sie wieder beobachtet. Ein Probelauf daneben fasst sie nicht an.
|
|
if os.environ.get("MC_PROBELAUF", "") != "1":
|
|
from services import jobengine
|
|
await asyncio.to_thread(jobengine.wiederaufnehmen)
|
|
tasks = _box_hintergrund() if ROLLE == "box" else []
|
|
if ROLLE == "homelab":
|
|
from services.homelab import updates
|
|
updates.unterbrochene_abschliessen() # Läufe des vorigen Prozesses gelten als unterbrochen
|
|
# Geteilter HTTP-Client zur lokalen Engine: Keep-Alive/Connection-Pooling statt neuer Client
|
|
# pro /v1-Anfrage (spart Sockets/TIME_WAIT unter parallelen Agent-Strömen von Zed/Kilo).
|
|
app.state.gw_client = httpx.AsyncClient(
|
|
timeout=httpx.Timeout(connect=10.0, read=None, write=None, pool=10.0),
|
|
limits=httpx.Limits(max_keepalive_connections=100, max_connections=200),
|
|
)
|
|
try:
|
|
yield
|
|
finally:
|
|
for task in tasks:
|
|
_ = task.cancel()
|
|
await app.state.gw_client.aclose()
|
|
|
|
|
|
app = FastAPI(title="Homelab Orchestrator", version=VERSION, lifespan=lifespan)
|
|
|
|
# Dev: Vite-Dev-Server (5173) ruft das Backend per /api auf.
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
|
|
# Herkunftsprüfung (24.09.2026, User-Entscheid: keine Anmeldung): Knöpfe fremder Webseiten werden
|
|
# abgelehnt, Skripte, Desktop-Lucy und /v1 bleiben unberührt. Regeln in services/herkunft.py.
|
|
@app.middleware("http")
|
|
async def herkunft(request: Request, call_next: Callable[[Request], Awaitable[httpx.Response]]):
|
|
if not erlaubt(request.method, request.url.path, request.headers.get("origin"), request.headers.get("host")):
|
|
return JSONResponse({"detail": "Diese Aktion geht nur von der Orchestrator-Seite selbst aus."}, status_code=403)
|
|
return await call_next(request)
|
|
|
|
|
|
@app.middleware("http")
|
|
async def no_cache(
|
|
request: Request, call_next: Callable[[Request], Awaitable[httpx.Response]]
|
|
) -> httpx.Response:
|
|
resp = await call_next(request)
|
|
if request.url.path.startswith("/api"):
|
|
resp.headers["Cache-Control"] = "no-cache"
|
|
return resp
|
|
|
|
|
|
def _box_router(app: FastAPI) -> None:
|
|
"""Die Schnittstellen der KI-Box (Box-Wart und alles, was Lucy, OpenChamber und Hermes brauchen)."""
|
|
from routers import (
|
|
boxwart,
|
|
events,
|
|
gateway_proxy,
|
|
hermes_ui,
|
|
maintenance,
|
|
models,
|
|
radar,
|
|
routing,
|
|
system,
|
|
voice,
|
|
zeitmaschine,
|
|
)
|
|
from routers import reminders as reminders_router
|
|
|
|
app.include_router(boxwart.router) # Cockpit: Start, Hinweise, Modell-Nutzung, Zeitplan
|
|
app.include_router(radar.router) # Modell-Radar: Kandidaten, Nachttests, Übernehmen/Verwerfen
|
|
app.include_router(models.router)
|
|
app.include_router(routing.router)
|
|
app.include_router(system.router)
|
|
app.include_router(voice.router) # Sprache: STT/TTS-Proxy + Hermes-Agent-Chat
|
|
app.include_router(reminders_router.router) # Erinnerungen/Routinen — feuern in den Briefkasten
|
|
# /v1-Datenpfad: Nach dem Gateway-Auszug (UMBAU v3 P1) läuft der eigentliche Gateway als
|
|
# eigener Prozess (mc2-gateway, Loopback :9010) — MC2 reicht /v1 dann nur roh durch, damit
|
|
# LAN-Clients (IDE-Lane) weiter über :9001 kommen. Ohne MC_V1_UPSTREAM (vor dem ersten
|
|
# Deploy der neuen Unit / nach Rollback) bedient MC2 /v1 wie bisher selbst.
|
|
if V1_UPSTREAM:
|
|
from routers import gateway_forward
|
|
app.include_router(gateway_forward.router) # dünner Roh-Weiterleiter → mc2-gateway
|
|
else:
|
|
app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto)
|
|
app.include_router(maintenance.router)
|
|
app.include_router(events.router) # SSE-Strom /api/stream — Messwerte + Invalidation
|
|
app.include_router(zeitmaschine.router) # Sicherungen ansehen + zurückspielen (detached)
|
|
# Eingebaute Hermes-Web-GUI (hermes dashboard) same-origin unter /hermes-ui/ — VOR dem SPA-Catch-all
|
|
app.include_router(hermes_ui.router)
|
|
|
|
|
|
app.include_router(health.router)
|
|
app.include_router(partner.router) # zweite Instanz: Lebenszeichen + Durchreiche
|
|
if ROLLE == "box":
|
|
_box_router(app)
|
|
else:
|
|
from routers import homelab
|
|
app.include_router(homelab.router) # Proxmox-Host, Container, Arcane (Phase 3/4)
|
|
|
|
|
|
# Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html.
|
|
if FRONTEND_DIST.exists():
|
|
app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets")
|
|
|
|
_DIST_ROOT = FRONTEND_DIST.resolve()
|
|
|
|
@app.get("/{full_path:path}")
|
|
def spa(full_path: str):
|
|
# Unbekannte /api-Pfade sind ein echter 404, keine Startseite: Sonst bekommt die
|
|
# Oberfläche HTML statt JSON und stürzt ab (erster Probelauf 23.09., /api/radar).
|
|
if full_path == "api" or full_path.startswith("api/"):
|
|
raise HTTPException(status_code=404, detail="Diese Schnittstelle gibt es nicht.")
|
|
# Datei direkt aus FRONTEND_DIST ausliefern (manifest.webmanifest, favicon.ico, …) — aber
|
|
# NUR innerhalb des dist-Ordners: Pfad auflösen + Traversal (../, absolute Pfade) hart raus.
|
|
try:
|
|
target = (FRONTEND_DIST / full_path).resolve()
|
|
if target.is_relative_to(_DIST_ROOT) and target.is_file():
|
|
return FileResponse(target)
|
|
except (ValueError, OSError):
|
|
pass
|
|
|
|
index = FRONTEND_DIST / "index.html"
|
|
if index.exists():
|
|
# index.html nie cachen → Browser zieht nach jedem Deploy das aktuelle (gehashte) Bundle.
|
|
return FileResponse(
|
|
index, headers={"Cache-Control": "no-cache, must-revalidate"}
|
|
)
|
|
return {"detail": "frontend not built"}
|