phase3+4: Homelab-Teil (Ausfuehrer, Ziele, Jetzt updaten mit Rueckweg) und Seite Homelab fuer alle Geraete

- 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>
This commit is contained in:
Hitonabi
2026-09-24 18:39:59 +02:00
co-authored by Claude Opus 5.5
parent a47489fa85
commit 5e9227c4a3
66 changed files with 2472 additions and 170 deletions
+6
View File
@@ -72,6 +72,9 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
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(
@@ -160,6 +163,9 @@ 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.
+44
View File
@@ -0,0 +1,44 @@
"""GitHub-Abfragen mit Zwischenspeicher — für beide Rollen (Box: Motor und llama-swap, Homelab: die Apps).
GitHub erlaubt ohne Anmeldung 60 Anfragen pro Stunde. Jeder Blick auf die Update-Details fragte früher
neu; jetzt gilt eine Antwort 15 Minuten. Fehler (auch das Anfragelimit) werden nicht gemerkt.
"""
import os
import re
import threading
import time
import httpx
GITHUB_CACHE_S = int(os.environ.get("MC_GITHUB_CACHE_S", "900"))
_cache: dict[str, tuple[float, object]] = {}
_lock = threading.Lock()
def github_json(pfad: str, timeout: float = 8) -> object:
"""GET https://api.github.com/<pfad>, 15 Minuten gemerkt. Wirft bei HTTP-Fehlern."""
jetzt = time.time()
with _lock:
if (eintrag := _cache.get(pfad)) and jetzt - eintrag[0] < GITHUB_CACHE_S:
return eintrag[1]
r = httpx.get(f"https://api.github.com/{pfad}", timeout=timeout, headers={"User-Agent": "MissionControl2"})
r.raise_for_status()
daten = r.json()
with _lock:
_cache[pfad] = (jetzt, daten)
return daten
_VERSION = re.compile(r"^v?(\d+(?:\.\d+)+|\d{4}-\d{2}-\d{2}(?:-r\d+)?)", re.IGNORECASE)
def neueste_version(repo: str) -> dict | None:
"""Neueste Veröffentlichung eines Repos: {"tag", "version", "datum"} oder None. Manche Projekte
veröffentlichen „untagged“ und tragen die Version nur im Namen (PVE Scripts Local, 09/2026)."""
daten = github_json(f"repos/{repo}/releases/latest")
if not isinstance(daten, dict) or not daten.get("tag_name"):
return None
tag, name = str(daten["tag_name"]), str(daten.get("name") or "")
roh = tag if _VERSION.match(tag) or not _VERSION.match(name) else name
return {"tag": roh, "version": roh.lstrip("vV"), "datum": str(daten.get("published_at") or "")[:10] or None}
+1
View File
@@ -23,6 +23,7 @@ class Aktion:
methode: str # "POST"
pfad: str # "/api/maintenance/engine-update"
frage: str # Rückfrage vor dem Klick
label: str = "Jetzt updaten"
@dataclass
+96
View File
@@ -0,0 +1,96 @@
"""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/pve/neustart Proxmox-Host neu starten (eigener Knopf)
GET /api/homelab/laeufe Die letzten Update-Läufe mit ihren Schritten
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} ┘
Dünn: die Logik liegt in services/homelab/.
"""
import time
from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel
from services.homelab import inventar, kanal, updates
router = APIRouter(prefix="/api/homelab", 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/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("/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)}
+2 -2
View File
@@ -20,13 +20,13 @@ import time
from pathlib import Path
import httpx
from config import MODELS_DIR
from kern.einstellungen import einstellungen
log = logging.getLogger(__name__)
NOTIFY_SH = str(Path(__file__).resolve().parent.parent.parent / "deploy" / "notify.sh")
STORE_PATH = Path(os.environ.get("MC_ANNOUNCE_STORE", str(MODELS_DIR / "mc2-announce.json")))
STORE_PATH = Path(os.environ.get("MC_ANNOUNCE_STORE", str(einstellungen().daten_dir / "mc2-announce.json")))
MAX_ITEMS = int(os.environ.get("MC_ANNOUNCE_MAX", "200"))
# Steward-Auszug (UMBAU v3 P2): Läuft der Absender AUSSERHALB des MC2-Prozesses
+7
View File
@@ -0,0 +1,7 @@
"""Der Homelab-Teil (Rolle homelab, Phase 3/4): Proxmox-Host, Container und Arcane.
apps.py was in welchem Container steckt, wo seine Oberfläche liegt, woher die neueste Version kommt
kanal.py Aufträge und Berichte des Ausführers auf dem Proxmox-Host (gemeinsames Geheimnis)
inventar.py Bericht + neueste Versionen + Erreichbarkeit → Ziele im gemeinsamen Modell (kern/ziele.py)
updates.py „Jetzt updaten“: Snapshot → Update → Prüfung → bei Rot zurück, mit Meldung
"""
+47
View File
@@ -0,0 +1,47 @@
"""Was in welchem Container steckt (Community-Scripts-Kennung aus /usr/bin/update im Gast).
Stand der Bestandsaufnahme vom 24.09.2026: sechs Container, alle mit dem Etikett community-script.
Neue Container mit dem Etikett erscheinen von selbst; fehlt ihre Kennung hier, zeigt die Übersicht
sie ohne Versionsvergleich und ohne Webprüfung — nachtragen genügt.
"""
from dataclasses import dataclass
@dataclass(frozen=True)
class App:
kennung: str
name: str
quelle: str | None = None # GitHub owner/repo für die neueste Version; None = über die Pakete des Gasts
vergleich: str = "version" # „version“ oder „datum“ (Docker-Images ohne Versionsnummer)
port: int | None = None # Weboberfläche; None = keine (z. B. NetBird-Client)
https: bool = False
pfad: str = "/"
KATALOG: dict[str, App] = {a.kennung: a for a in (
App("adguard", "AdGuard Home", "AdguardTeam/AdGuardHome", port=8080),
App("npmplus", "NPMplus", "ZoeyVid/NPMplus", vergleich="datum", port=81, https=True),
App("netbird", "NetBird", "netbirdio/netbird"),
App("pve-scripts-local", "PVE Scripts Local", "community-scripts/ProxmoxVE-Local", port=3000),
App("gitea", "Gitea", "go-gitea/gitea", port=3000),
App("proxmox-backup-server", "Proxmox Backup Server", port=8007, https=True),
)}
# Gäste, die keine Community-Scripts sind, erkennt man am Namen.
NACH_NAME: dict[str, App] = {
"arcane": App("arcane", "Arcane (Docker)", port=3552),
}
def app_fuer(kennung: str | None, name: str | None) -> App | None:
if kennung and kennung in KATALOG:
return KATALOG[kennung]
return NACH_NAME.get(str(name or "").lower())
def adresse(app: App | None, ip: str | None) -> str | None:
"""Adresse der Weboberfläche, falls es eine gibt und die IP bekannt ist."""
if not app or not app.port or not ip:
return None
return f"{'https' if app.https else 'http'}://{ip}:{app.port}{app.pfad}"
+209
View File
@@ -0,0 +1,209 @@
"""Vom Bericht des Ausführers zu Zielen im gemeinsamen Modell (kern/ziele.py) — Phase 3, 24.09.2026.
Je Gerät ein Ziel: der Proxmox-Host (Pakete, Neustart) und jeder freigegebene Gast (Etikett
community-script oder watcher, nicht watcher-aus) mit seinen Bausteinen „App“ (Community-Script,
Vergleich mit der neuesten Veröffentlichung auf GitHub) und „Pakete“ (Betriebssystem im Gast).
Dazu prüft dieser Teil selbst, ob die Weboberfläche jedes Gasts antwortet.
Wie ehrlich der Stand ist, steht dabei: Sind die Paketlisten im Gast älter als zwei Wochen, heißt es
„unbekannt“ (mit Knopf zum Suchen) statt „aktuell“; ist der Bericht älter als eine halbe Stunde, sagt
die Übersicht, dass der Ausführer schweigt.
"""
import re
import threading
import time
from datetime import datetime
import httpx
from kern.github import neueste_version
from kern.zeit import LOCAL_TZ
from kern.ziele import Aktion, BausteinStand, ZielStand
from services.homelab import apps, kanal
BERICHT_ALT_S = 30 * 60
LISTEN_ALT_S = 14 * 24 * 3600
ERREICHBAR_CACHE_S = 60
_erreichbar_cache: dict[str, tuple[float, bool]] = {}
_erreichbar_lock = threading.Lock()
def erreichbar(url: str) -> bool:
"""Antwortet die Weboberfläche? Alles unter 500 zählt (eine Anmeldeseite ist eine Antwort)."""
jetzt = time.time()
with _erreichbar_lock:
if (eintrag := _erreichbar_cache.get(url)) and jetzt - eintrag[0] < ERREICHBAR_CACHE_S:
return eintrag[1]
try:
# Selbstsignierte Zertifikate sind im Heimnetz üblich (PBS, NPMplus); geprüft wird nur, ob jemand antwortet.
ok = httpx.get(url, timeout=5.0, verify=False, follow_redirects=False).status_code < 500
except httpx.HTTPError:
ok = False
with _erreichbar_lock:
_erreichbar_cache[url] = (jetzt, ok)
return ok
def erreichbar_frisch(url: str) -> bool:
"""Wie erreichbar(), aber ohne den Zwischenspeicher — für die Prüfung direkt nach einem Update."""
with _erreichbar_lock:
_erreichbar_cache.pop(url, None)
return erreichbar(url)
def _teile(version: str) -> tuple[int, ...]:
return tuple(int(x) for x in re.findall(r"\d+", version.split("-")[0])[:4])
def neuer(verfuegbar: str, installiert: str) -> bool | None:
"""Ist verfuegbar neuer als installiert? None, wenn sich das nicht sagen lässt."""
a, b = _teile(verfuegbar), _teile(installiert)
if not a or not b:
return None
return a > b
def _datum(ts: float | None) -> str:
return datetime.fromtimestamp(ts, LOCAL_TZ).strftime("%d.%m.%Y") if ts else "?"
def _app_baustein(gast: dict, app: apps.App, zid: str) -> BausteinStand:
installiert = (gast.get("app") or {}).get("version")
stand = BausteinStand(id="app", name=app.name, zustand="aktuell", installiert=installiert)
if not app.quelle:
return stand
try:
neu = neueste_version(app.quelle)
except Exception:
neu = None
if not neu:
stand.zustand, stand.grund = "unbekannt", "Die neueste Version ist gerade nicht abrufbar (GitHub)."
return stand
stand.verfuegbar = neu["version"]
if app.vergleich == "datum":
# Docker-Image ohne Versionsnummer: neuer, wenn die Veröffentlichung jünger ist als das Image.
bild = re.search(r"\d{4}-\d{2}-\d{2}", installiert or "")
ist_neuer = (neu["datum"] or "") > bild.group(0) if bild else None
else:
ist_neuer = neuer(neu["version"], installiert) if installiert else None
if ist_neuer is None:
stand.zustand, stand.grund = "unbekannt", "Die installierte Version ließ sich nicht lesen."
elif ist_neuer:
stand.zustand, stand.kurz = "neu", f"{installiert} → {neu['version']}"
rueckweg = ("Vorher wird ein Snapshot angelegt; ist die Prüfung danach rot, geht es von selbst zurück."
if gast.get("snapshot_moeglich") else
"Ein Snapshot ist hier nicht möglich — scheitert das Update, gibt es keinen automatischen Rückweg.")
stand.aktion = Aktion("POST", f"/api/homelab/ziele/{zid}/update", f"{app.name} jetzt aktualisieren? {rueckweg}")
return stand
def _os_baustein(gast: dict, zid: str, jetzt: float) -> BausteinStand:
os_stand = gast.get("os_updates") or {}
stand = BausteinStand(id="os", name="Pakete", zustand="aktuell")
anzahl, listen = os_stand.get("anzahl"), os_stand.get("listen_stand")
suchen = Aktion("POST", f"/api/homelab/ziele/{zid}/suchen", "Die Paketlisten im Gast jetzt erneuern?",
label="Nach Updates suchen")
if anzahl is None:
stand.zustand, stand.grund = "unbekannt", "Die Pakete ließen sich nicht lesen."
elif listen and jetzt - listen > LISTEN_ALT_S:
stand.zustand, stand.aktion = "unbekannt", suchen
stand.grund = f"Die Paketlisten sind vom {_datum(listen)}; was fehlt, weiß erst eine neue Suche."
elif anzahl > 0:
stand.zustand, stand.kurz = "neu", f"{anzahl} Pakete"
stand.aktion = Aktion("POST", f"/api/homelab/ziele/{zid}/os-update",
f"{anzahl} Pakete im Gast jetzt einspielen? Vorher wird, wo möglich, ein Snapshot angelegt.")
return stand
def _gast_ziel(gast: dict, jetzt: float) -> ZielStand:
art = "container" if gast["art"] == "lxc" else "vm"
zid = f"{'ct' if art == 'container' else 'vm'}-{gast['vmid']}"
app = apps.app_fuer((gast.get("app") or {}).get("kennung"), gast.get("name"))
url = apps.adresse(app, gast.get("ip"))
laeuft = gast.get("status") == "running"
ziel = ZielStand(id=zid, name=app.name if app else str(gast.get("name") or zid), art=art, instanz="homelab",
erreichbar=(erreichbar(url) if laeuft else False) if url else (laeuft or None),
rueckweg=("Snapshot vor jedem Update" if gast.get("snapshot_moeglich")
else gast.get("snapshot_grund") or "kein Snapshot möglich"))
if not laeuft:
ziel.bausteine.append(BausteinStand(id="status", name="Status", zustand="unbekannt",
grund=f"Der Gast ist {gast.get('status') or 'unbekannt'}."))
return ziel
if not gast.get("erlaubt"):
ziel.bausteine.append(BausteinStand(
id="freigabe", name="Freigabe", zustand="unbekannt",
grund="Nicht freigegeben: Das Etikett „watcher“ fehlt (oder „watcher-aus“ ist gesetzt)."))
return ziel
if app and app.kennung in apps.KATALOG:
ziel.bausteine.append(_app_baustein(gast, app, zid))
if gast["art"] == "lxc":
ziel.bausteine.append(_os_baustein(gast, zid, jetzt))
return ziel
def _host_ziel(host: dict) -> ZielStand:
ziel = ZielStand(id="pve", name="Proxmox-Host", art="proxmox-host", instanz="homelab", erreichbar=True,
rueckweg="Kein Snapshot möglich; der Host bleibt beim Update in Betrieb, neu gestartet wird getrennt.")
updates = host.get("updates")
pakete = BausteinStand(id="pakete", name="Proxmox VE und Debian", zustand="aktuell",
installiert=host.get("version"))
if updates is None:
pakete.zustand, pakete.grund = "unbekannt", host.get("fehler") or "Die Update-Liste fehlt im Bericht."
elif updates:
pakete.zustand, pakete.kurz = "neu", f"{len(updates)} Pakete"
pakete.aktion = Aktion("POST", "/api/homelab/ziele/pve/update",
f"{len(updates)} Pakete auf dem Proxmox-Host einspielen? Alle Gäste laufen weiter. "
"Einen Neustart löst das nicht aus — der ist ein eigener Knopf.")
ziel.bausteine.append(pakete)
if host.get("neustart_noetig"):
ziel.bausteine.append(BausteinStand(
id="neustart", name="Neustart", zustand="neu", kurz="steht an",
aktion=Aktion("POST", "/api/homelab/ziele/pve/neustart",
"Den Proxmox-Host jetzt neu starten? ALLE Container und die Arcane-VM sind dann ein paar "
"Minuten weg. Die KI-Box prüft danach von außen, ob alles wiederkommt.",
label="Jetzt neu starten")))
return ziel
def ziele() -> dict:
"""Alle Ziele des Homelabs aus dem letzten Bericht, dazu wie frisch er ist."""
eingang = kanal.bericht()
jetzt = time.time()
zuletzt = kanal.zuletzt()
ausfuehrer = {"verbunden": bool(zuletzt and jetzt - zuletzt < BERICHT_ALT_S), "zuletzt": zuletzt}
if not eingang:
return {"ziele": [], "bericht": None, "ausfuehrer": ausfuehrer}
b = eingang["bericht"]
liste = [_host_ziel(b.get("host") or {})]
liste += [_gast_ziel(g, jetzt) for g in b.get("gaeste") or []]
for z in liste:
z.geprueft = eingang["empfangen"]
return {"ziele": [z.als_dict() for z in liste],
"bericht": {"empfangen": eingang["empfangen"], "veraltet": jetzt - eingang["empfangen"] > BERICHT_ALT_S},
"ausfuehrer": ausfuehrer}
def erreichbarkeit() -> list[dict]:
"""Für den Wächter: jede Weboberfläche laufender, freigegebener Gäste — ohne GitHub-Abfragen."""
eingang = kanal.bericht()
ergebnis = []
for g in (eingang or {}).get("bericht", {}).get("gaeste") or []:
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
url = apps.adresse(app, g.get("ip"))
if url and g.get("erlaubt"):
zid = f"{'ct' if g['art'] == 'lxc' else 'vm'}-{g['vmid']}"
ergebnis.append({"id": zid, "name": app.name if app else g.get("name"), "url": url,
"laeuft": g.get("status") == "running",
"ok": g.get("status") == "running" and erreichbar(url)})
return ergebnis
def gast(zid: str) -> dict | None:
"""Der Gast zu einer Ziel-ID aus dem letzten Bericht (ct-104 → vmid 104)."""
m = re.fullmatch(r"(ct|vm)-(\d+)", zid)
eingang = kanal.bericht()
if not m or not eingang:
return None
return next((g for g in eingang["bericht"].get("gaeste") or [] if g.get("vmid") == int(m.group(2))), None)
+170
View File
@@ -0,0 +1,170 @@
"""Kanal zum Ausführer auf dem Proxmox-Host (Phase 3/4, 24.09.2026).
Der Ausführer (deploy/homelab/ausfuehrer.py) holt sich Aufträge hier ab, liefert die Ergebnisse und
alle zehn Minuten einen Bericht. Er weist sich mit einem gemeinsamen Geheimnis aus (Kopfzeile
X-MC2-Ausfuehrer). Das erzeugt dieser Teil beim ersten Bedarf in <Datenordner>/ausfuehrer.token (nur
für den Dienst lesbar); die Einrichtung auf dem Proxmox-Host kopiert es in /etc/mc2-ausfuehrer.json.
Aufträge liegen in <Datenordner>/ausfuehrer-auftraege.json und überleben so einen Neustart dieses Teils.
"""
import hmac
import json
import os
import secrets
import threading
import time
import uuid
from pathlib import Path
from kern.einstellungen import einstellungen
AKTIONEN = {"bericht", "snapshot", "update", "os_update", "suchen", "zurueck", "snapshot_loeschen",
"host_update", "host_neustart"}
VERLOREN_S = 3 * 3600 # abgeholt, aber nie beantwortet (Ausführer abgestürzt, Host neu gestartet)
BEHALTEN = 60 # so viele erledigte Aufträge bleiben sichtbar
_lock = threading.Lock()
_kontakt: dict = {"zuletzt": None}
def _dir() -> Path:
return einstellungen().daten_dir
def _json_schreiben(pfad: Path, daten: object) -> None:
pfad.parent.mkdir(parents=True, exist_ok=True)
tmp = pfad.with_suffix(pfad.suffix + ".tmp")
tmp.write_text(json.dumps(daten, ensure_ascii=False), encoding="utf-8")
tmp.replace(pfad)
def _json_lesen(pfad: Path, leer: object) -> object:
try:
return json.loads(pfad.read_text(encoding="utf-8"))
except (OSError, ValueError):
return leer
# --- Gemeinsames Geheimnis ---------------------------------------------------------------
def token() -> str:
"""Das Geheimnis des Kanals; beim ersten Aufruf erzeugt (0600). MC_AUSFUEHRER_TOKEN geht vor."""
if wert := os.environ.get("MC_AUSFUEHRER_TOKEN", "").strip():
return wert
pfad = _dir() / "ausfuehrer.token"
try:
return pfad.read_text(encoding="utf-8").strip()
except OSError:
pass
wert = secrets.token_urlsafe(32)
pfad.parent.mkdir(parents=True, exist_ok=True)
try:
fd = os.open(pfad, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
except FileExistsError: # gleichzeitig erzeugt: den anderen nehmen
return pfad.read_text(encoding="utf-8").strip()
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(wert + "\n")
return wert
def token_gueltig(wert: str | None) -> bool:
return bool(wert) and hmac.compare_digest(str(wert), token())
def kontakt() -> None:
_kontakt["zuletzt"] = time.time()
def zuletzt() -> float | None:
"""Letztes Lebenszeichen des Ausführers (nach einem Neustart: Zeit des letzten Berichts)."""
return _kontakt["zuletzt"] or (bericht() or {}).get("empfangen")
# --- Bericht ------------------------------------------------------------------------------
def bericht_speichern(daten: dict) -> None:
_json_schreiben(_dir() / "pve-bericht.json", {"empfangen": time.time(), "bericht": daten})
def bericht() -> dict | None:
"""{"empfangen": Unix-Sekunden, "bericht": {...}} oder None, solange nie einer kam."""
daten = _json_lesen(_dir() / "pve-bericht.json", None)
return daten if isinstance(daten, dict) and isinstance(daten.get("bericht"), dict) else None
# --- Aufträge -------------------------------------------------------------------------------
def _pfad() -> Path:
return _dir() / "ausfuehrer-auftraege.json"
def _alle() -> list[dict]:
daten = _json_lesen(_pfad(), [])
return daten if isinstance(daten, list) else []
def _merken(auftraege: list[dict]) -> None:
offen = [a for a in auftraege if a["status"] in ("wartet", "laeuft")]
fertig = [a for a in auftraege if a["status"] not in ("wartet", "laeuft")][-BEHALTEN:]
_json_schreiben(_pfad(), sorted(offen + fertig, key=lambda a: a["erstellt"]))
def anlegen(aktion: str, parameter: dict | None = None) -> str:
if aktion not in AKTIONEN:
raise ValueError(f"Unbekannte Aktion: {aktion}")
auftrag = {"id": uuid.uuid4().hex[:12], "aktion": aktion, "parameter": parameter or {},
"status": "wartet", "erstellt": time.time(), "abgeholt": None, "fertig": None,
"code": None, "text": None}
with _lock:
auftraege = _alle()
auftraege.append(auftrag)
_merken(auftraege)
return auftrag["id"]
def naechster() -> dict | None:
"""Für den Ausführer: der älteste wartende Auftrag, ab jetzt „läuft“. Hängengebliebene werden
vorher als verloren abgeschlossen."""
jetzt = time.time()
with _lock:
auftraege = _alle()
for a in auftraege:
if a["status"] == "laeuft" and jetzt - (a["abgeholt"] or jetzt) > VERLOREN_S:
a.update(status="verloren", fertig=jetzt, text="Der Ausführer hat nie geantwortet.")
auftrag = next((a for a in auftraege if a["status"] == "wartet"), None)
if auftrag:
auftrag.update(status="laeuft", abgeholt=jetzt)
_merken(auftraege)
return {k: auftrag[k] for k in ("id", "aktion", "parameter")} if auftrag else None
def ergebnis(auftrag_id: str, code: int, text: str) -> bool:
with _lock:
auftraege = _alle()
auftrag = next((a for a in auftraege if a["id"] == auftrag_id), None)
if auftrag is None or auftrag["status"] != "laeuft":
return False
auftrag.update(status="fertig" if code == 0 else "fehler", fertig=time.time(), code=code,
text=str(text)[-20000:])
_merken(auftraege)
return True
def auftrag(auftrag_id: str) -> dict | None:
return next((a for a in _alle() if a["id"] == auftrag_id), None)
def warten(auftrag_id: str, zeitlimit_s: float, takt_s: float = 2.0) -> dict | None:
"""Bis der Auftrag fertig ist (oder das Zeitlimit abläuft). Rückgabe: der Auftrag, None bei Zeitablauf."""
ende = time.time() + zeitlimit_s
while time.time() < ende:
a = auftrag(auftrag_id)
if a and a["status"] not in ("wartet", "laeuft"):
return a
time.sleep(takt_s)
return None
def liste() -> list[dict]:
return list(reversed(_alle()))
+212
View File
@@ -0,0 +1,212 @@
"""„Jetzt updaten“ im Homelab (Phase 4, User-Entscheide 24.09.2026).
Nur per Knopf, mit Erfolgsmeldung und Erreichbarkeits-Check; ist er rot, geht es automatisch zurück
und es wird gemeldet. Host-Updates gibt es mit Warnung, der Neustart ist ein eigener Knopf.
Ablauf für einen Gast:
1. Snapshot (wo möglich; beim PBS verhindert der Bind-Mount ihn — dann ohne Rückweg, die Rückfrage sagt es)
2. Update: die App per Community-Script (`update`, still) oder die Pakete des Gasts
3. 20 s warten, frischen Bericht holen
4. Prüfen: läuft der Gast, antwortet die Weboberfläche, ist die App-Version jetzt neuer?
5. Rot und Snapshot da → zurück auf den Snapshot → dringende Meldung.
Grün → ältere Snapshots des Orchestrators für diesen Gast weg → Meldung „eingespielt“.
Jeder Lauf steht in <Datenordner>/homelab-laeufe.json und im strukturierten Update-Verlauf. Pro Ziel
läuft höchstens ein Lauf; startet dieser Teil neu, gilt ein offener Lauf als unterbrochen.
"""
import json
import logging
import re
import threading
import time
import uuid
from datetime import datetime
from pathlib import Path
from kern.einstellungen import einstellungen
from kern.zeit import LOCAL_TZ
from services import announce, update_verlauf
from services.homelab import apps, inventar, kanal
log = logging.getLogger(__name__)
WARTEN_NACH_UPDATE_S = 20
ZEITLIMIT_UPDATE_S = 45 * 60
ZEITLIMIT_KURZ_S = 15 * 60
BETREFF = "[Homelab-Update]"
_lock = threading.Lock()
def _pfad() -> Path:
return einstellungen().daten_dir / "homelab-laeufe.json"
def _laeufe() -> list[dict]:
try:
daten = json.loads(_pfad().read_text(encoding="utf-8"))
except (OSError, ValueError):
return []
return daten if isinstance(daten, list) else []
def _merken(lauf: dict) -> None:
with _lock:
laeufe = [x for x in _laeufe() if x["id"] != lauf["id"]] + [lauf]
_pfad().parent.mkdir(parents=True, exist_ok=True)
tmp = _pfad().with_suffix(".tmp")
tmp.write_text(json.dumps(laeufe[-100:], ensure_ascii=False), encoding="utf-8")
tmp.replace(_pfad())
def laeufe(anzahl: int = 20) -> list[dict]:
return list(reversed(_laeufe()))[:anzahl]
def unterbrochene_abschliessen() -> None:
"""Beim Start: Läufe, die noch „läuft“ heißen, gehörten zum vorigen Prozess."""
for lauf in _laeufe():
if lauf.get("status") == "laeuft":
lauf.update(status="unterbrochen", ende=time.time())
lauf["schritte"].append("Der Homelab-Teil wurde während des Laufs neu gestartet.")
_merken(lauf)
def _melden(text: str, dringend: bool = False) -> None:
try:
announce.notify_telegram("[Alarm] Homelab-Update" if dringend else BETREFF, text)
except Exception:
log.warning("homelab: Meldung ging nicht raus", exc_info=True)
def _auftrag(lauf: dict, aktion: str, parameter: dict, zeitlimit_s: float) -> dict:
"""Auftrag an den Ausführer und auf das Ergebnis warten. Wirft RuntimeError bei Fehler/Zeitablauf."""
aid = kanal.anlegen(aktion, parameter)
lauf["schritte"].append(f"{aktion} …")
_merken(lauf)
a = kanal.warten(aid, zeitlimit_s)
if a is None:
raise RuntimeError(f"{aktion}: keine Antwort des Ausführers in {int(zeitlimit_s // 60)} Minuten")
if a["status"] != "fertig":
raise RuntimeError(f"{aktion} gescheitert: {str(a.get('text') or '')[-300:]}")
lauf["schritte"][-1] = f"{aktion}: ok"
return a
def _frischer_gast(lauf: dict, vmid: int) -> dict | None:
try:
a = _auftrag(lauf, "bericht", {}, 5 * 60)
bericht = json.loads(a.get("text") or "{}")
kanal.bericht_speichern(bericht)
except (RuntimeError, ValueError):
return None
return next((g for g in bericht.get("gaeste") or [] if g.get("vmid") == vmid), None)
def pruefen(vorher: dict, nachher: dict | None, baustein: str) -> list[str]:
"""Was nach dem Update nicht stimmt (leer = grün)."""
if nachher is None:
return ["Der Gast fehlt im neuen Bericht."]
fehler = []
if nachher.get("status") != "running":
fehler.append(f"Der Gast läuft nicht ({nachher.get('status')}).")
app = apps.app_fuer((nachher.get("app") or {}).get("kennung"), nachher.get("name"))
url = apps.adresse(app, nachher.get("ip"))
if url and not inventar.erreichbar_frisch(url):
fehler.append(f"Die Weboberfläche ({url}) antwortet nicht.")
if baustein == "app":
alt, neu = (vorher.get("app") or {}).get("version"), (nachher.get("app") or {}).get("version")
if alt and neu and alt == neu:
fehler.append(f"Die App-Version ist unverändert ({alt}).")
return fehler
def _gast_lauf(lauf: dict, gast: dict) -> None:
vmid, baustein = gast["vmid"], lauf["baustein"]
snapshot = None
try:
if gast.get("snapshot_moeglich"):
a = _auftrag(lauf, "snapshot", {"vmid": vmid}, ZEITLIMIT_KURZ_S)
m = re.search(r"snapshot=(mc2-\d{8}-\d{6})", a.get("text") or "")
snapshot = m.group(1) if m else None
_auftrag(lauf, "update" if baustein == "app" else "os_update", {"vmid": vmid}, ZEITLIMIT_UPDATE_S)
time.sleep(WARTEN_NACH_UPDATE_S)
fehler = pruefen(gast, _frischer_gast(lauf, vmid), baustein)
except RuntimeError as exc:
fehler = [str(exc)]
if not fehler:
_aufraeumen(lauf, vmid, behalten=snapshot)
_ende(lauf, "eingespielt", f"{lauf['name']}: eingespielt, Prüfung grün.")
return
if snapshot:
try:
_auftrag(lauf, "zurueck", {"vmid": vmid, "snapshot": snapshot}, ZEITLIMIT_KURZ_S)
_ende(lauf, "zurueckgerollt",
f"{lauf['name']}: Update gescheitert ({' '.join(fehler)}) — automatisch zurück auf den Snapshot "
f"{snapshot}. Läuft wieder wie vorher.", dringend=True)
return
except RuntimeError as exc:
fehler.append(f"Auch der Rückweg scheiterte: {exc}")
_ende(lauf, "fehler", f"{lauf['name']}: Update gescheitert — {' '.join(fehler)}"
+ ("" if snapshot else " Es gab keinen Snapshot, also keinen automatischen Rückweg."),
dringend=True)
def _aufraeumen(lauf: dict, vmid: int, behalten: str | None) -> None:
"""Ältere Snapshots des Orchestrators für diesen Gast löschen; den neuesten behalten."""
gast = inventar.gast(lauf["ziel"]) or {}
for name in gast.get("snapshots") or []:
if name.startswith("mc2-") and name != behalten:
try:
_auftrag(lauf, "snapshot_loeschen", {"vmid": vmid, "snapshot": name}, ZEITLIMIT_KURZ_S)
except RuntimeError:
lauf["schritte"].append(f"Alter Snapshot {name} blieb stehen.")
def _host_lauf(lauf: dict) -> None:
try:
if lauf["baustein"] == "neustart":
_auftrag(lauf, "host_neustart", {}, 60)
_ende(lauf, "neustart", "Der Proxmox-Host startet neu. Die KI-Box meldet, ob alles wiederkommt.")
return
_auftrag(lauf, "host_update", {}, 60 * 60)
_auftrag(lauf, "bericht", {}, 5 * 60)
_ende(lauf, "eingespielt", "Proxmox-Host: Pakete eingespielt. Ein Neustart ist ein eigener Knopf.")
except RuntimeError as exc:
_ende(lauf, "fehler", f"Proxmox-Host: Update gescheitert — {exc}", dringend=True)
def _ende(lauf: dict, ergebnis: str, text: str, dringend: bool = False) -> None:
lauf.update(status="fertig", ergebnis=ergebnis, text=text, ende=time.time())
_merken(lauf)
try:
start = datetime.fromtimestamp(lauf["start"], LOCAL_TZ).isoformat(timespec="seconds")
update_verlauf.eintragen(start, "Update von Hand", lauf["ziel"], ergebnis, text)
except Exception:
log.warning("homelab: Verlauf nicht geschrieben", exc_info=True)
_melden(text, dringend=dringend)
def starten(ziel_id: str, baustein: str) -> dict:
"""Knopf „Jetzt updaten“. Rückgabe {"ok": True, "lauf": id} oder {"ok": False, "detail": …}."""
if baustein not in ("app", "os", "pakete", "neustart"):
return {"ok": False, "detail": "Unbekannter Baustein."}
if any(x.get("ziel") == ziel_id and x.get("status") == "laeuft" for x in _laeufe()):
return {"ok": False, "detail": "Für dieses Gerät läuft schon ein Update."}
if ziel_id == "pve":
name, gast = "Proxmox-Host", None
else:
gast = inventar.gast(ziel_id)
if not gast:
return {"ok": False, "detail": "Dieses Gerät steht nicht im Bericht des Ausführers."}
if not gast.get("erlaubt"):
return {"ok": False, "detail": "Dieses Gerät ist nicht freigegeben (Etikett watcher fehlt)."}
app = apps.app_fuer((gast.get("app") or {}).get("kennung"), gast.get("name"))
name = app.name if app else str(gast.get("name"))
lauf = {"id": uuid.uuid4().hex[:12], "ziel": ziel_id, "baustein": baustein, "name": name, "status": "laeuft",
"start": time.time(), "ende": None, "ergebnis": None, "text": None, "schritte": []}
_merken(lauf)
ziel = _host_lauf if gast is None else (lambda lf: _gast_lauf(lf, gast))
threading.Thread(target=ziel, args=(lauf,), name=f"homelab-{ziel_id}", daemon=True).start()
return {"ok": True, "lauf": lauf["id"]}
+3 -15
View File
@@ -14,6 +14,7 @@ from datetime import datetime
from pathlib import Path
import httpx
from kern.github import github_json
from kern.zeit import LOCAL_TZ
from services import jobengine, system, update_verlauf
@@ -51,21 +52,8 @@ _engine_cache = {"ts": 0.0, "avail": False}
_ENGINE_ASSET_RX = re.compile(r"ubuntu-vulkan-x64\.tar\.gz$", re.IGNORECASE)
# GitHub erlaubt ohne Anmeldung 60 Anfragen pro Stunde. Jeder Blick auf die Update-Details fragte
# bisher neu; jetzt gilt eine Antwort 15 Minuten. Fehler (auch das Anfragelimit) werden nicht gemerkt.
GITHUB_CACHE_S = int(os.environ.get("MC_GITHUB_CACHE_S", "900"))
_github_cache: dict[str, tuple[float, object]] = {}
def _github_json(pfad: str, timeout: float = 8) -> object:
jetzt = time.time()
if (eintrag := _github_cache.get(pfad)) and jetzt - eintrag[0] < GITHUB_CACHE_S:
return eintrag[1]
r = httpx.get(f"https://api.github.com/{pfad}", timeout=timeout, headers={"User-Agent": "MissionControl2"})
r.raise_for_status()
daten = r.json()
_github_cache[pfad] = (jetzt, daten)
return daten
# GitHub-Abfragen mit 15 Minuten Zwischenspeicher (kern/github.py, seit 24.09.2026 für beide Rollen).
_github_json = github_json
def _latest_engine_asset_release() -> dict | None:
+25 -2
View File
@@ -457,12 +457,35 @@ def pruefe_partner() -> list[Befund]:
quelle="partner", nach_takten=PARTNER_TAKTE)]
def pruefe_ausfuehrer() -> list[Befund]:
"""Homelab: Der Ausführer auf dem Proxmox-Host meldet sich alle zehn Minuten. Schweigt er, zeigt die
Übersicht nichts Neues mehr, und „Jetzt updaten“ bliebe liegen. Vor seinem ersten Bericht: kein Alarm."""
from services.homelab import inventar, kanal
if kanal.bericht() is None:
return []
zuletzt = kanal.zuletzt()
if zuletzt and time.time() - zuletzt < inventar.BERICHT_ALT_S:
return []
return [Befund(id="ausfuehrer", stufe="rot", titel="Der Ausführer auf dem Proxmox-Host schweigt",
text="Seit über 30 Minuten kein Bericht. Läuft mc2-ausfuehrer.service auf dem Proxmox-Host?",
quelle="ausfuehrer")]
def pruefe_gaeste() -> list[Befund]:
"""Homelab: Läuft jeder freigegebene Gast, und antwortet seine Weboberfläche?"""
from services.homelab import inventar
return [Befund(id=f"gast:{e['id']}", stufe="rot", titel=f"{e['name']} antwortet nicht",
text=(f"Die Weboberfläche ({e['url']}) antwortet nicht." if e["laeuft"]
else "Der Gast läuft nicht."), quelle=e["id"])
for e in inventar.erreichbarkeit() if not e["ok"]]
# Was jede Rolle prüft. Die KI-Box kennt Dienste, Timer, Hermes und ihren Kern; der Homelab-Teil
# bekommt seine Prüfungen mit den Homelab-Bausteinen.
# den Ausführer auf dem Proxmox-Host und seine Gäste.
PRUEFUNGEN = {
"box": (pruefe_dienste, pruefe_timer_dienste, pruefe_hermes_jobs, pruefe_kern, pruefe_platte,
pruefe_festgehalten, pruefe_partner),
"homelab": (pruefe_platte, pruefe_partner),
"homelab": (pruefe_platte, pruefe_partner, pruefe_ausfuehrer, pruefe_gaeste),
}[ROLLE]
BETREFF_PROBLEM, BETREFF_OK = {"box": ("[Box-Problem]", "[Box wieder ok]"),
"homelab": ("[Homelab-Problem]", "[Homelab wieder ok]")}[ROLLE]
+215
View File
@@ -0,0 +1,215 @@
{
"zeit": "2026-09-24T16:26:04+00:00",
"host": {
"node": "pve",
"neustart_noetig": false,
"version": "9.2.11",
"updates": [
{
"paket": "libvirt-common",
"alt": "11.3.0-3+deb13u2",
"neu": "11.3.0-3+deb13u3",
"herkunft": "Debian"
},
{
"paket": "librados2",
"alt": "19.2.3-pve1",
"neu": "19.2.6-pve4",
"herkunft": "Proxmox"
},
{
"paket": "libperl5.40",
"alt": "5.40.1-6",
"neu": "5.40.1-6+deb13u1",
"herkunft": "Debian"
},
{
"paket": "pve-docs",
"alt": "9.2.4",
"neu": "9.2.12",
"herkunft": "Proxmox"
},
{
"paket": "pve-edk2-firmware-ovmf",
"alt": "4.2025.05-3",
"neu": "4.2026.08-1",
"herkunft": "Proxmox"
}
],
"kernel": "7.0.14-12-pve"
},
"gaeste": [
{
"vmid": 100,
"art": "lxc",
"name": "adguard",
"status": "running",
"etiketten": [
"adblock",
"community-script"
],
"erlaubt": true,
"uptime": 3366322,
"snapshots": [],
"onboot": true,
"snapshot_moeglich": true,
"snapshot_grund": null,
"ostype": "debian",
"ip": "192.168.178.100",
"app": {
"kennung": "adguard",
"version": "0.107.79"
},
"os_updates": {
"anzahl": 0,
"listen_stand": 1761157469
}
},
{
"vmid": 101,
"art": "lxc",
"name": "npmplus",
"status": "running",
"etiketten": [
"community-script",
"nginx",
"proxy"
],
"erlaubt": true,
"uptime": 2868368,
"snapshots": [],
"onboot": true,
"snapshot_moeglich": true,
"snapshot_grund": null,
"ostype": "alpine",
"ip": "192.168.178.110",
"app": {
"kennung": "npmplus",
"version": "Image vom 2026-08-27"
},
"os_updates": {
"anzahl": 0,
"listen_stand": 1787398997
}
},
{
"vmid": 102,
"art": "lxc",
"name": "netbird",
"status": "running",
"etiketten": [
"community-script",
"network",
"vpn"
],
"erlaubt": true,
"uptime": 3366322,
"snapshots": [],
"onboot": true,
"snapshot_moeglich": true,
"snapshot_grund": null,
"ostype": "debian",
"ip": "192.168.178.148",
"app": {
"kennung": "netbird",
"version": "0.77.1"
},
"os_updates": {
"anzahl": 0,
"listen_stand": 1787399037
}
},
{
"vmid": 103,
"art": "lxc",
"name": "pve-scripts-local",
"status": "running",
"etiketten": [
"community-script",
"pve-scripts-local"
],
"erlaubt": true,
"uptime": 1713361,
"snapshots": [],
"onboot": true,
"snapshot_moeglich": true,
"snapshot_grund": null,
"ostype": "debian",
"ip": "192.168.178.149",
"app": {
"kennung": "pve-scripts-local",
"version": "0.5.8"
},
"os_updates": {
"anzahl": 0,
"listen_stand": 1777589263
}
},
{
"vmid": 104,
"art": "lxc",
"name": "gitea",
"status": "running",
"etiketten": [
"community-script",
"git"
],
"erlaubt": true,
"uptime": 3366321,
"snapshots": [],
"onboot": true,
"snapshot_moeglich": true,
"snapshot_grund": null,
"ostype": "debian",
"ip": "192.168.178.153",
"app": {
"kennung": "gitea",
"version": "1.27.2"
},
"os_updates": {
"anzahl": 0,
"listen_stand": 1781978337
}
},
{
"vmid": 105,
"art": "lxc",
"name": "proxmox-backup-server",
"status": "running",
"etiketten": [
"backup",
"community-script"
],
"erlaubt": true,
"uptime": 3366321,
"snapshots": [],
"onboot": true,
"snapshot_moeglich": false,
"snapshot_grund": "Bind-Mount mp0 (/mnt/qnap-backup/pbs-datastore) — nur Backup möglich",
"ostype": "debian",
"ip": "192.168.178.156",
"app": {
"kennung": "proxmox-backup-server",
"version": "4.2.5-1"
},
"os_updates": {
"anzahl": 58,
"listen_stand": 1790207866
}
},
{
"vmid": 106,
"art": "qemu",
"name": "arcane",
"status": "running",
"etiketten": [],
"erlaubt": false,
"uptime": 1713735,
"snapshots": [],
"onboot": true,
"snapshot_moeglich": true,
"snapshot_grund": null,
"ip": "192.168.178.28"
}
]
}
+254
View File
@@ -0,0 +1,254 @@
"""Homelab-Teil (Phase 3/4, 24.09.2026): Kanal zum Ausführer, Ziele aus dem echten Bericht des
Proxmox-Hosts (tests/fixtures/pve-bericht.json, gelesen am 24.09.), „Jetzt updaten“ mit Rückweg."""
import importlib.util
import json
import os
import threading
import time
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from kern import einstellungen as einstellungen_mod
from services.homelab import inventar, kanal, updates
BERICHT = json.loads((Path(__file__).parent / "fixtures" / "pve-bericht.json").read_text(encoding="utf-8"))
AUSFUEHRER = Path(__file__).resolve().parents[2] / "deploy" / "homelab" / "ausfuehrer.py"
@pytest.fixture
def daten(tmp_path, monkeypatch):
monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path))
monkeypatch.delenv("MC_AUSFUEHRER_TOKEN", raising=False)
einstellungen_mod.einstellungen.cache_clear()
monkeypatch.setattr(kanal, "_kontakt", {"zuletzt": None})
# Keine Netzabfragen: neueste Versionen und Weboberflächen sind fest.
neueste = {"AdguardTeam/AdGuardHome": "0.107.79", "go-gitea/gitea": "1.27.3", "netbirdio/netbird": "0.79.0",
"community-scripts/ProxmoxVE-Local": "1.2.1"}
monkeypatch.setattr(inventar, "neueste_version", lambda repo: {
"tag": neueste.get(repo, "2026-07-24-r1"), "version": neueste.get(repo, "2026-07-24-r1"),
"datum": "2026-07-24"})
monkeypatch.setattr(inventar, "erreichbar", lambda url: True)
yield tmp_path
einstellungen_mod.einstellungen.cache_clear()
# --- Kanal ---------------------------------------------------------------------------------
def test_token_wird_einmal_erzeugt_und_geprueft(daten):
wert = kanal.token()
assert len(wert) > 30 and kanal.token() == wert
assert kanal.token_gueltig(wert) and not kanal.token_gueltig("falsch") and not kanal.token_gueltig(None)
if os.name == "posix":
assert (daten / "ausfuehrer.token").stat().st_mode & 0o077 == 0
def test_auftraege_der_reihe_nach_und_verloren(daten, monkeypatch):
erster = kanal.anlegen("bericht")
zweiter = kanal.anlegen("suchen", {"vmid": 104})
with pytest.raises(ValueError):
kanal.anlegen("rm -rf")
assert kanal.naechster()["id"] == erster
assert kanal.ergebnis(erster, 0, "ok") and not kanal.ergebnis(erster, 0, "doppelt")
assert kanal.naechster() == {"id": zweiter, "aktion": "suchen", "parameter": {"vmid": 104}}
monkeypatch.setattr(kanal, "VERLOREN_S", -1)
assert kanal.naechster() is None
assert kanal.auftrag(zweiter)["status"] == "verloren"
def _client() -> TestClient:
from routers import homelab
app = FastAPI()
app.include_router(homelab.router)
return TestClient(app)
def test_nur_der_ausfuehrer_darf_in_den_kanal(daten):
c = _client()
assert c.get("/api/homelab/ausfuehrer/auftrag").status_code == 403
assert c.post("/api/homelab/ausfuehrer/bericht", json=BERICHT,
headers={"X-MC2-Ausfuehrer": "falsch"}).status_code == 403
kopf = {"X-MC2-Ausfuehrer": kanal.token()}
assert c.post("/api/homelab/ausfuehrer/bericht", json=BERICHT, headers=kopf).json() == {"ok": True}
assert c.get("/api/homelab/ausfuehrer/auftrag", headers=kopf).json() == {}
assert c.get("/api/homelab/ausfuehrer").json()["verbunden"] is True
# --- Ziele aus dem echten Bericht ---------------------------------------------------------------
def _ziele(daten) -> dict[str, dict]:
kanal.bericht_speichern(BERICHT)
return {z["id"]: z for z in inventar.ziele()["ziele"]}
def test_ziele_aus_dem_echten_bericht(daten):
z = _ziele(daten)
assert list(z) == ["pve", "ct-100", "ct-101", "ct-102", "ct-103", "ct-104", "ct-105", "vm-106"]
host = {b["id"]: b for b in z["pve"]["bausteine"]}
assert host["pakete"]["zustand"] == "neu" and host["pakete"]["aktion"]["pfad"] == "/api/homelab/ziele/pve/update"
gitea = {b["id"]: b for b in z["ct-104"]["bausteine"]}
assert (gitea["app"]["zustand"], gitea["app"]["kurz"]) == ("neu", "1.27.2 → 1.27.3")
assert z["ct-104"]["name"] == "Gitea" and z["ct-104"]["rueckweg"] == "Snapshot vor jedem Update"
adguard = {b["id"]: b for b in z["ct-100"]["bausteine"]}
assert adguard["app"]["zustand"] == "aktuell"
# Paketlisten vom Oktober 2025: ehrlich „unbekannt“ mit Knopf zum Suchen statt „aktuell“.
assert adguard["os"]["zustand"] == "unbekannt" and adguard["os"]["aktion"]["label"] == "Nach Updates suchen"
pbs = {b["id"]: b for b in z["ct-105"]["bausteine"]}
assert (pbs["os"]["zustand"], pbs["os"]["kurz"]) == ("neu", "58 Pakete")
assert "Bind-Mount" in z["ct-105"]["rueckweg"]
assert {b["id"]: b for b in z["ct-103"]["bausteine"]}["app"]["kurz"] == "0.5.8 → 1.2.1"
assert {b["id"]: b for b in z["ct-101"]["bausteine"]}["app"]["zustand"] == "aktuell" # Image jünger
# NetBird hat keine Weboberfläche: erreichbar heißt hier „läuft“.
assert z["ct-102"]["erreichbar"] is True
arcane = z["vm-106"]
assert arcane["name"] == "Arcane (Docker)" and arcane["bausteine"][0]["id"] == "freigabe"
def test_ohne_bericht_leer_und_ehrlich(daten):
stand = inventar.ziele()
assert stand["ziele"] == [] and stand["bericht"] is None and stand["ausfuehrer"]["verbunden"] is False
def test_wachter_sieht_stumme_gaeste(daten, monkeypatch):
from services import waechter
kanal.bericht_speichern(BERICHT)
monkeypatch.setattr(inventar, "erreichbar", lambda url: "153" not in url)
befunde = waechter.pruefe_gaeste()
assert [b.id for b in befunde] == ["gast:ct-104"] and befunde[0].titel == "Gitea antwortet nicht"
assert waechter.pruefe_ausfuehrer() == [] # Bericht ist frisch
# --- „Jetzt updaten“ mit einem nachgespielten Ausführer ----------------------------------------
def _ausfuehrer_spielen(stopp: threading.Event, neue_version: str, protokoll: list[str]) -> None:
while not stopp.is_set():
auftrag = kanal.naechster()
if not auftrag:
time.sleep(0.02)
continue
protokoll.append(auftrag["aktion"])
text = ""
if auftrag["aktion"] == "snapshot":
text = "snapshot=mc2-20260924-190000"
elif auftrag["aktion"] == "bericht":
neu = json.loads(json.dumps(BERICHT))
next(g for g in neu["gaeste"] if g["vmid"] == 104)["app"]["version"] = neue_version
text = json.dumps(neu)
kanal.ergebnis(auftrag["id"], 0, text)
@pytest.fixture
def ausfuehrer(daten, monkeypatch):
monkeypatch.setattr(updates, "WARTEN_NACH_UPDATE_S", 0)
monkeypatch.setattr(kanal, "warten", _schnell_warten)
meldungen: list[tuple[str, bool]] = []
monkeypatch.setattr(updates, "_melden", lambda text, dringend=False: meldungen.append((text, dringend)))
verlauf: list[tuple] = []
monkeypatch.setattr(updates.update_verlauf, "eintragen", lambda *a: verlauf.append(a))
kanal.bericht_speichern(BERICHT)
stopp, protokoll = threading.Event(), []
def starten(neue_version: str) -> None:
threading.Thread(target=_ausfuehrer_spielen, args=(stopp, neue_version, protokoll), daemon=True).start()
yield starten, protokoll, meldungen, verlauf
stopp.set()
_echtes_warten = kanal.warten
def _schnell_warten(auftrag_id, zeitlimit_s, takt_s=2.0):
return _echtes_warten(auftrag_id, min(zeitlimit_s, 10), 0.02)
def _auf_ende(lauf_id: str) -> dict:
ende = time.time() + 20
while time.time() < ende:
lauf = next((x for x in updates.laeufe() if x["id"] == lauf_id), {})
if lauf.get("status") == "fertig":
return lauf
time.sleep(0.05)
raise AssertionError(f"Lauf {lauf_id} wurde nicht fertig")
def test_update_gruen(ausfuehrer, monkeypatch):
starten, protokoll, meldungen, verlauf = ausfuehrer
starten("1.27.3")
antwort = updates.starten("ct-104", "app")
assert antwort["ok"]
assert updates.starten("ct-104", "app")["detail"] == "Für dieses Gerät läuft schon ein Update."
lauf = _auf_ende(antwort["lauf"])
assert lauf["ergebnis"] == "eingespielt" and protokoll == ["snapshot", "update", "bericht"]
assert meldungen == [("Gitea: eingespielt, Prüfung grün.", False)]
assert verlauf[0][2:4] == ("ct-104", "eingespielt")
def test_update_rot_geht_zurueck_und_meldet_dringend(ausfuehrer, monkeypatch):
starten, protokoll, meldungen, _ = ausfuehrer
monkeypatch.setattr(inventar, "erreichbar", lambda url: False)
starten("1.27.3")
lauf = _auf_ende(updates.starten("ct-104", "app")["lauf"])
assert lauf["ergebnis"] == "zurueckgerollt"
assert protokoll == ["snapshot", "update", "bericht", "zurueck"]
text, dringend = meldungen[0]
assert dringend and "zurück auf den Snapshot mc2-20260924-190000" in text and "antwortet nicht" in text
def test_update_ohne_neue_version_ist_rot(ausfuehrer):
starten, _, _, _ = ausfuehrer
starten("1.27.2")
lauf = _auf_ende(updates.starten("ct-104", "app")["lauf"])
assert lauf["ergebnis"] == "zurueckgerollt" and "unverändert" in lauf["text"]
def test_nicht_freigegeben_und_unbekannt(ausfuehrer):
assert updates.starten("vm-106", "app")["detail"].startswith("Dieses Gerät ist nicht freigegeben")
assert updates.starten("ct-999", "app")["ok"] is False
assert updates.starten("ct-104", "boese")["ok"] is False
# --- Der Ausführer selbst (läuft auf dem Proxmox-Host) -------------------------------------------
def _ausfuehrer_modul():
spec = importlib.util.spec_from_file_location("ausfuehrer", AUSFUEHRER)
modul = importlib.util.module_from_spec(spec)
spec.loader.exec_module(modul)
return modul
def test_ausfuehrer_entscheidet_selbst_ueber_etiketten():
a = _ausfuehrer_modul()
assert a.erlaubt(["community-script", "git"]) and a.erlaubt(["watcher"])
assert not a.erlaubt(["community-script", "watcher-aus"]) and not a.erlaubt([])
def test_ausfuehrer_snapshot_faehigkeit():
a = _ausfuehrer_modul()
arten = {"local-lvm": "lvmthin", "local": "dir"}
assert a.snapshot_moeglich({"rootfs": "local-lvm:vm-104-disk-0,size=8G"}, arten) == (True, None)
ok, grund = a.snapshot_moeglich({"rootfs": "local-lvm:vm-105-disk-0", "mp0": "/mnt/qnap,mp=/mnt/ds"}, arten)
assert not ok and "Bind-Mount" in grund
assert a.snapshot_moeglich({"rootfs": "local:105/vm-105-disk-0.raw"}, arten)[0] is False
def test_ausfuehrer_lehnt_fremdes_ab(monkeypatch):
a = _ausfuehrer_modul()
monkeypatch.setattr(a, "_pvesh", lambda pfad, *p: [
{"vmid": 104, "type": "lxc", "tags": "community-script;git"},
{"vmid": 106, "type": "qemu", "tags": ""}])
ausgefuehrt: list[list[str]] = []
monkeypatch.setattr(a, "_laufen", lambda befehl, zeitlimit=60: ausgefuehrt.append(befehl) or (0, "ok"))
assert a.ausfuehren({"aktion": "rm", "parameter": {}})["code"] == 2
assert "kein erlaubtes Etikett" in a.ausfuehren({"aktion": "update", "parameter": {"vmid": 106}})["text"]
assert a.ausfuehren({"aktion": "zurueck", "parameter": {"vmid": 104, "snapshot": "vorher"}})["code"] == 2
assert a.ausfuehren({"aktion": "update", "parameter": {"vmid": "104; reboot"}})["code"] == 2
assert ausgefuehrt == []
assert a.ausfuehren({"aktion": "update", "parameter": {"vmid": 104}})["code"] == 0
assert ausgefuehrt[-1][:4] == ["pct", "exec", "104", "--"] and "PHS_SILENT=1" in ausgefuehrt[-1][-1]
+15
View File
@@ -208,3 +208,18 @@ def test_unbekannte_rolle_bricht_ab(monkeypatch):
finally:
monkeypatch.delenv("MC_ROLLE")
einstellungen_mod.einstellungen.cache_clear()
def test_homelab_waechter_prueft_ausfuehrer_und_gaeste(tmp_path):
ergebnis = _starte("homelab", tmp_path, """
import json
import steward
from services import waechter
print(json.dumps({"pruefungen": [p.__name__ for p in waechter.PRUEFUNGEN], "daten": str(waechter.DATEN_DIR)}))
""")
assert ergebnis["pruefungen"] == ["pruefe_platte", "pruefe_partner", "pruefe_ausfuehrer", "pruefe_gaeste"]
def test_homelab_rolle_hat_die_homelab_schnittstellen(tmp_path):
ergebnis = _starte("homelab", tmp_path, _PROBE)
assert "/api/homelab/ziele" in ergebnis["pfade"] and "/api/ziele" not in ergebnis["pfade"]
+10 -6
View File
@@ -37,15 +37,19 @@ class _Antwort:
def test_github_antworten_werden_gemerkt_fehler_nicht(monkeypatch):
monkeypatch.setattr(maintenance, "_github_cache", {})
antworten = [_Antwort(403, {"message": "API rate limit exceeded"}), _Antwort(200, {"tag_name": "v258"})]
from kern import github
monkeypatch.setattr(github, "_cache", {})
antworten = [_Antwort(403, {"message": "API rate limit exceeded"}),
_Antwort(200, {"tag_name": "v258", "published_at": "2026-09-20T10:00:00Z"})]
aufrufe = []
monkeypatch.setattr(maintenance.httpx, "get", lambda url, **kw: aufrufe.append(url) or antworten.pop(0))
monkeypatch.setattr(github.httpx, "get", lambda url, **kw: aufrufe.append(url) or antworten.pop(0))
with pytest.raises(httpx.HTTPStatusError):
maintenance._github_json("repos/a/b/releases/latest")
assert maintenance._github_json("repos/a/b/releases/latest") == {"tag_name": "v258"}
assert maintenance._github_json("repos/a/b/releases/latest") == {"tag_name": "v258"}
github.github_json("repos/a/b/releases/latest")
assert github.neueste_version("a/b") == {"tag": "v258", "version": "258", "datum": "2026-09-20"}
assert github.github_json("repos/a/b/releases/latest")["tag_name"] == "v258"
assert len(aufrufe) == 2
assert maintenance._github_json is github.github_json
def test_hermes_version_auch_wenn_der_abruf_haengt(monkeypatch, tmp_path):