diff --git a/backend/routers/homelab.py b/backend/routers/homelab.py index ab5ecb6..a83b52d 100644 --- a/backend/routers/homelab.py +++ b/backend/routers/homelab.py @@ -90,6 +90,73 @@ 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 +# 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.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("/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.get("/ausfuehrer") def ausfuehrer() -> dict: zuletzt = kanal.zuletzt() diff --git a/backend/services/homelab/__init__.py b/backend/services/homelab/__init__.py index 740dec0..5b2fee9 100644 --- a/backend/services/homelab/__init__.py +++ b/backend/services/homelab/__init__.py @@ -5,5 +5,8 @@ inventar.py Bericht + neueste Versionen + Erreichbarkeit → Ziele im gemeinsamen Modell (kern/ziele.py) karenz.py Wartezeit nach einer Änderung am Update-Skript (community-scripts, ungepinnt von GitHub) updates.py „Jetzt updaten“: Snapshot bzw. Sicherung → Update → Prüfung → bei Rot zurück, mit Meldung + sammellauf.py „Alle aktualisieren“: alle Updates mit Knopf nacheinander, bei Rot Schluss + protokoll.py was wann geschah (Aufträge, Läufe, Hinweise, Meldungen, Pflege), ohne Geheimnisse + einstellungen.py Arcane-Schlüssel und Docker-echt-Schalter aus der Oberfläche, Stand für „Einstellungen“ pflege.py wöchentliches Suchen (Paketlisten der Gäste), im Wächter-Takt des Stewards """ diff --git a/backend/services/homelab/arcane.py b/backend/services/homelab/arcane.py index 90210ec..39923b3 100644 --- a/backend/services/homelab/arcane.py +++ b/backend/services/homelab/arcane.py @@ -3,22 +3,34 @@ Arcane (getarcaneapp/arcane) verwaltet die Docker-Container der VM (NerdQuiz, Rippy …) und hat eine eigene Schnittstelle (OpenAPI unter /api/openapi.json). Öffentlich ist nur die eigene Version (/api/app-version mit currentVersion, newestVersion, updateAvailable). Alles über Images braucht einen -API-Schlüssel (Kopfzeile X-API-Key), den man in Arcane anlegt — MC_ARCANE_KEY in /etc/mc2/homelab.env. +API-Schlüssel (Kopfzeile X-API-Key), den man in Arcane anlegt. + +Der Schlüssel (seit 24.09.2026 auch über die Oberfläche): MC_ARCANE_KEY in /etc/mc2/homelab.env geht vor, sonst +/arcane.key (0600, schreibt nur die Oberfläche nach einer Prüfung gegen Arcane, einstellungen.py). +Beides wird bei jedem Zugriff gelesen: Ein neuer Schlüssel gilt ohne Neustart. Er steht nie in einer Antwort, +einem Protokoll oder einer Fehlermeldung. Docker-Updates laufen über Arcanes eigenen Updater (POST /environments/{id}/updater/run). User-Entscheid -24.09.: zuerst nur als Probelauf (dryRun); echte Updates erst mit MC_ARCANE_ECHT=1. +24.09.: zuerst nur als Probelauf (dryRun); echt erst, wenn der Schalter in den Einstellungen an ist +(/homelab-einstellungen.json). Setzt die Umgebung MC_ARCANE_ECHT, gewinnt sie („1“ = echt). """ +import json import os import threading import time +from datetime import datetime +from pathlib import Path import httpx +from kern.einstellungen import einstellungen +from kern.zeit import LOCAL_TZ ZEITLIMIT = httpx.Timeout(20.0, connect=5.0) CACHE_S = 120 _cache: dict[str, tuple[float, object]] = {} _lock = threading.Lock() +_datei_lock = threading.Lock() def _gemerkt(schluessel: str, holen): @@ -33,14 +45,114 @@ def _gemerkt(schluessel: str, holen): return wert +def cache_leeren() -> None: + """Nach einem neuen oder gelöschten Schlüssel: Die Übersicht fragt Arcane gleich neu.""" + with _lock: + _cache.clear() + + +# --- Schlüssel ---------------------------------------------------------------------------------------- + +def _schluessel_pfad() -> Path: + return einstellungen().daten_dir / "arcane.key" + + +def _schluessel_datei() -> str: + try: + return _schluessel_pfad().read_text(encoding="utf-8").strip() + except (OSError, UnicodeDecodeError): + return "" + + def schluessel() -> str: - return os.environ.get("MC_ARCANE_KEY", "").strip() + """Der geltende Schlüssel: MC_ARCANE_KEY, sonst die Datei, sonst leer.""" + return os.environ.get("MC_ARCANE_KEY", "").strip() or _schluessel_datei() + + +def schluessel_quelle() -> str: + """„umgebung“ (MC_ARCANE_KEY), „hinterlegt“ (Datei im Datenordner) oder „fehlt“.""" + if os.environ.get("MC_ARCANE_KEY", "").strip(): + return "umgebung" + return "hinterlegt" if _schluessel_datei() else "fehlt" + + +def schluessel_speichern(wert: str) -> None: + """Atomar und von Anfang an nur für den Dienst lesbar (0600): erst eine Nachbardatei, dann ersetzen.""" + pfad = _schluessel_pfad() + tmp = pfad.with_name(pfad.name + ".tmp") + with _datei_lock: + pfad.parent.mkdir(parents=True, exist_ok=True) + tmp.unlink(missing_ok=True) + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(wert.strip() + "\n") + os.replace(tmp, pfad) + cache_leeren() + + +def schluessel_loeschen() -> bool: + """Die Datei weg. True, wenn es eine gab.""" + with _datei_lock: + pfad = _schluessel_pfad() + da = pfad.exists() + pfad.unlink(missing_ok=True) + cache_leeren() + return da + + +# --- Echt statt Probelauf ----------------------------------------------------------------------------- + +def _einstellungen_pfad() -> Path: + return einstellungen().daten_dir / "homelab-einstellungen.json" + + +def _einstellungen_lesen() -> dict: + try: + daten = json.loads(_einstellungen_pfad().read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + return daten if isinstance(daten, dict) else {} + + +def _echt_eingestellt() -> bool | None: + arcane = _einstellungen_lesen().get("arcane") + wert = arcane.get("echt") if isinstance(arcane, dict) else None + return wert if isinstance(wert, bool) else None + + +def _echt_umgebung() -> str: + return os.environ.get("MC_ARCANE_ECHT", "").strip() def echt() -> bool: - return os.environ.get("MC_ARCANE_ECHT", "") == "1" + """Echte Docker-Updates statt Probelauf? Die Umgebung (MC_ARCANE_ECHT) gewinnt, dann der Schalter, sonst nein.""" + if umgebung := _echt_umgebung(): + return umgebung == "1" + return bool(_echt_eingestellt()) +def echt_quelle() -> str: + """„umgebung“, „einstellung“ oder „standard“ (Probelauf).""" + if _echt_umgebung(): + return "umgebung" + return "einstellung" if _echt_eingestellt() is not None else "standard" + + +def echt_speichern(an: bool) -> None: + pfad = _einstellungen_pfad() + with _datei_lock: + daten = _einstellungen_lesen() + arcane = daten.get("arcane") if isinstance(daten.get("arcane"), dict) else {} + daten.update(arcane={**arcane, "echt": bool(an)}, + geaendert=datetime.now(LOCAL_TZ).isoformat(timespec="seconds")) + pfad.parent.mkdir(parents=True, exist_ok=True) + tmp = pfad.with_name(pfad.name + ".tmp") + tmp.write_text(json.dumps(daten, ensure_ascii=False, indent=1), encoding="utf-8") + os.replace(tmp, pfad) + + +# --- Schnittstelle ------------------------------------------------------------------------------------ + def app_version(url: str) -> dict | None: """Arcanes eigene Version (öffentlich): {"installiert", "neu", "update"} oder None.""" return _gemerkt(f"version:{url}", lambda: _app_version(url)) @@ -57,14 +169,14 @@ def _app_version(url: str) -> dict | None: "update": bool(d.get("updateAvailable"))} -def _get(url: str, pfad: str) -> object: - r = httpx.get(f"{url}/api{pfad}", headers={"X-API-Key": schluessel()}, timeout=ZEITLIMIT) +def _get(url: str, pfad: str, mit_schluessel: str | None = None) -> object: + r = httpx.get(f"{url}/api{pfad}", headers={"X-API-Key": mit_schluessel or schluessel()}, timeout=ZEITLIMIT) r.raise_for_status() return r.json() -def umgebungen(url: str) -> list[dict]: - daten = _get(url, "/environments?limit=50") +def umgebungen(url: str, mit_schluessel: str | None = None) -> list[dict]: + daten = _get(url, "/environments?limit=50", mit_schluessel) return [e for e in (daten or {}).get("data") or [] if isinstance(e, dict) and e.get("id") is not None] @@ -88,6 +200,28 @@ def _images_mit_update(url: str) -> list[dict]: return ergebnis +def schluessel_pruefen(url: str, wert: str) -> dict: + """Nimmt Arcane diesen Schlüssel an? Geprüft wird mit denselben Aufrufen, die die Übersicht braucht: die + Umgebungen, dann ihre Images. {"ok": True, "umgebungen", "images", "mit_update"} oder {"ok": False, "status": + HTTP-Code bzw. None, "art": "abgelehnt" | "http" | "netz" | "antwort"}. Nichts davon enthält den Schlüssel.""" + try: + liste = umgebungen(url, wert) + images = mit_update = 0 + for umgebung in liste: + daten = _get(url, f"/environments/{umgebung['id']}/images?limit=500", wert) + eintraege = [i for i in (daten or {}).get("data") or [] if isinstance(i, dict)] + images += len(eintraege) + mit_update += sum(1 for i in eintraege if (i.get("updateInfo") or {}).get("hasUpdate")) + except httpx.HTTPStatusError as exc: + status = exc.response.status_code + return {"ok": False, "status": status, "art": "abgelehnt" if status in (401, 403) else "http"} + except httpx.HTTPError: + return {"ok": False, "status": None, "art": "netz"} + except (ValueError, AttributeError, TypeError): + return {"ok": False, "status": None, "art": "antwort"} + return {"ok": True, "umgebungen": len(liste), "images": images, "mit_update": mit_update} + + def updater(url: str, probelauf: bool) -> dict: """Arcanes Updater für alle Umgebungen. Rückgabe: je Umgebung das Ergebnis (geprüft, aktualisiert, Fehler).""" ergebnisse = {} diff --git a/backend/services/homelab/einstellungen.py b/backend/services/homelab/einstellungen.py new file mode 100644 index 0000000..3bf92f7 --- /dev/null +++ b/backend/services/homelab/einstellungen.py @@ -0,0 +1,129 @@ +"""Einstellungen des Homelab-Teils (24.09.2026): was sich unter „Einstellungen“ für das Homelab einstellen lässt. + + • Arcane-API-Schlüssel eintragen oder löschen. Vor dem Speichern prüft Arcane ihn mit denselben Aufrufen, die die + Übersicht braucht; lehnt es ab oder antwortet nicht, wird nichts gespeichert. Er liegt in + /arcane.key (0600); MC_ARCANE_KEY in /etc/mc2/homelab.env geht vor (arcane.py). + • Docker echt echte Docker-Updates über Arcane statt Probelauf (/homelab-einstellungen.json); + MC_ARCANE_ECHT in der Umgebung gewinnt. +stand() zeigt dazu die Wartezeit nach Skriptänderungen (karenz.py) und den Ausführer (verbunden, Nur-Lesen, +Sicherungsspeicher). Der Schlüssel verlässt diesen Teil nie: Keine Antwort, kein Protokolleintrag und keine +Fehlermeldung enthält ihn, auch kein Stück davon. +""" + +import json +import logging +import re +import time + +from services.homelab import apps, arcane, inventar, kanal, karenz + +log = logging.getLogger(__name__) + +UMGEBUNG_SCHLUESSEL = ("Der Arcane-Schlüssel kommt aus der Umgebung (MC_ARCANE_KEY in /etc/mc2/homelab.env) und " + "gilt vor einem hier eingetragenen. Erst dort entfernen, dann hier eintragen.") +_FORM = re.compile(r"[\x21-\x7e]{8,512}") # druckbares ASCII ohne Leerzeichen, wie ihn Arcane ausgibt + + +def arcane_url() -> str | None: + """Adresse der Arcane-Oberfläche aus dem Bericht des Ausführers (die VM, deren Name „arcane“ ist).""" + eingang = kanal.bericht() + for gast in ((eingang or {}).get("bericht") or {}).get("gaeste") or []: + app = apps.app_fuer((gast.get("app") or {}).get("kennung"), gast.get("name")) + if app and app.kennung == "arcane" and (url := apps.adresse(app, gast.get("ip"))): + return url.rstrip("/") + return None + + +def stand() -> dict: + """GET /api/homelab/einstellungen.""" + zuletzt = kanal.zuletzt() + host = ((kanal.bericht() or {}).get("bericht") or {}).get("host") or {} + sicherung = host.get("sicherung") if isinstance(host.get("sicherung"), dict) else {} + return { + "arcane": {"url": arcane_url() or "", "schluessel": arcane.schluessel_quelle(), "echt": arcane.echt(), + "echt_quelle": arcane.echt_quelle()}, + "karenz_h": round(karenz.karenz_s() / 3600), + "ausfuehrer": {"verbunden": bool(zuletzt and time.time() - zuletzt < inventar.BERICHT_ALT_S), + "zuletzt": zuletzt, "nur_lesen": bool(host.get("nur_lesen")), + "sicherung_speicher": sicherung.get("speicher") or None}, + } + + +def schluessel_aus_body(roh: bytes) -> str | None: + """Der Schlüssel aus dem Body {"schluessel": "…"} — ohne Pydantic, dessen Fehlermeldung (422) die Eingabe + wiederholen würde. None, wenn der Body nicht passt.""" + try: + daten = json.loads(roh.decode("utf-8")) if roh else None + except (UnicodeDecodeError, ValueError): + return None + wert = daten.get("schluessel") if isinstance(daten, dict) else None + return wert if isinstance(wert, str) else None + + +def _abgelehnt(ergebnis: dict, url: str) -> str: + status, art = ergebnis.get("status"), ergebnis.get("art") + if art == "abgelehnt": + text = f"Arcane lehnt den Schlüssel ab (HTTP {status})." + if status == 403: + text += " Ihm fehlt das Recht, Umgebungen und Images zu lesen." + return text + if art == "http": + return f"Arcane antwortet mit HTTP {status}. Der Schlüssel ist nicht gespeichert." + if art == "netz": + return f"Arcane ist nicht erreichbar ({url}). Der Schlüssel ist nicht gespeichert." + return "Arcane antwortet nicht wie erwartet. Der Schlüssel ist nicht gespeichert." + + +def arcane_schluessel_setzen(wert: str | None) -> dict: + """POST /api/homelab/einstellungen/arcane-schluessel: erst gegen Arcane prüfen, dann speichern.""" + if arcane.schluessel_quelle() == "umgebung": + return {"ok": False, "detail": UMGEBUNG_SCHLUESSEL} + wert = (wert or "").strip() + if not _FORM.fullmatch(wert): + return {"ok": False, "detail": "Das sieht nicht wie ein Arcane-API-Schlüssel aus (leer, zu kurz oder mit " + "Leerzeichen). Nichts gespeichert."} + url = arcane_url() + if not url: + return {"ok": False, "detail": "Arcane steht nicht im Bericht des Ausführers, die Adresse ist unbekannt. " + "Nichts gespeichert."} + ergebnis = arcane.schluessel_pruefen(url, wert) + if not ergebnis["ok"]: + log.info("homelab: Arcane-Schlüssel nicht übernommen (%s, HTTP %s)", ergebnis.get("art"), ergebnis.get("status")) + return {"ok": False, "detail": _abgelehnt(ergebnis, url)} + try: + arcane.schluessel_speichern(wert) + except OSError as exc: + return {"ok": False, "detail": f"Arcane nimmt den Schlüssel an, aber er ließ sich nicht speichern " + f"({exc.__class__.__name__})."} + images, neu, anzahl = ergebnis["images"], ergebnis["mit_update"], ergebnis["umgebungen"] + log.info("homelab: Arcane-Schlüssel gespeichert (%d Images geprüft)", images) + return {"ok": True, "text": (f"Arcane nimmt den Schlüssel an: {images} Images in {anzahl} " + f"{'Umgebung' if anzahl == 1 else 'Umgebungen'} geprüft, {neu} davon mit neuerem " + "Stand. Die Übersicht zeigt die Docker-Images ab sofort.")} + + +def arcane_schluessel_loeschen() -> dict: + """DELETE /api/homelab/einstellungen/arcane-schluessel: die Datei weg.""" + try: + da = arcane.schluessel_loeschen() + except OSError as exc: + return {"ok": False, "detail": f"Der Schlüssel ließ sich nicht löschen ({exc.__class__.__name__})."} + text = "Der Arcane-Schlüssel ist gelöscht." if da else "Es war kein Arcane-Schlüssel hinterlegt." + if arcane.schluessel_quelle() == "umgebung": + text += " Die Umgebung setzt weiter einen (MC_ARCANE_KEY); der gilt." + return {"ok": True, "text": text} + + +def arcane_echt_setzen(an: bool) -> dict: + """POST /api/homelab/einstellungen/arcane-echt: echte Docker-Updates statt Probelauf.""" + if arcane.echt_quelle() == "umgebung": + return {"ok": False, "detail": f"Die Umgebung legt das fest (MC_ARCANE_ECHT in /etc/mc2/homelab.env: Docker-" + f"Updates laufen {'echt' if arcane.echt() else 'als Probelauf'}). Sie gewinnt " + "vor diesem Schalter."} + try: + arcane.echt_speichern(an) + except OSError as exc: + return {"ok": False, "detail": f"Der Schalter ließ sich nicht speichern ({exc.__class__.__name__})."} + return {"ok": True, "text": ("Docker-Updates über Arcane laufen ab jetzt echt: Arcane holt die neuen Images und " + "startet die Container neu." if an else + "Docker-Updates über Arcane laufen wieder nur als Probelauf; geändert wird nichts.")} diff --git a/backend/services/homelab/inventar.py b/backend/services/homelab/inventar.py index a3a5b1b..f3faf5f 100644 --- a/backend/services/homelab/inventar.py +++ b/backend/services/homelab/inventar.py @@ -180,8 +180,8 @@ def _arcane_bausteine(basis: str | None, zid: str) -> list[BausteinStand]: selbst.zustand = "aktuell" docker = BausteinStand(id="docker", name="Docker-Images", zustand="unbekannt") if not arcane.schluessel(): - docker.grund = ("Es fehlt ein Arcane-API-Schlüssel (MC_ARCANE_KEY): ohne ihn sieht der Orchestrator " - "die Images nicht.") + docker.grund = ("Es fehlt ein Arcane-API-Schlüssel: Ohne ihn sieht der Orchestrator die Images nicht. " + "Eintragen unter Einstellungen (Arcane-API-Schlüssel).") elif basis: try: images = arcane.images_mit_update(basis) diff --git a/backend/services/homelab/kanal.py b/backend/services/homelab/kanal.py index dd54778..6312437 100644 --- a/backend/services/homelab/kanal.py +++ b/backend/services/homelab/kanal.py @@ -8,6 +8,10 @@ für den Dienst lesbar); die Einrichtung auf dem Proxmox-Host kopiert es in /etc Aufträge liegen in /ausfuehrer-auftraege.json und überleben so einen Neustart dieses Teils. Zwei Prozesse schreiben hinein: die Oberfläche (Knöpfe, Abholen durch den Ausführer) und seit 24.09.2026 der Steward (wöchentliches Suchen, pflege.py). Lesen, ändern, schreiben geschieht deshalb unter einer Dateisperre. + +Erledigte Aufträge bleiben (seit 24.09.2026) bis zu BEHALTEN Stück stehen — das Protokoll der Oberfläche +(protokoll.py) liest sie. Die jüngsten VOLLTEXT behalten ihre ganze Ausgabe, ältere nur deren Ende (TEXT_KURZ +Zeichen, so viel zeigt das Protokoll): Die Datei wird bei jedem Auftrag gelesen und geschrieben. """ import hmac @@ -31,7 +35,10 @@ except ImportError: # Windows (Entwicklung): nur die Thread-Sperre AKTIONEN = {"bericht", "snapshot", "update", "os_update", "suchen", "zurueck", "snapshot_loeschen", "host_update", "host_neustart", "sichern", "sicherung_zurueck", "sicherung_loeschen"} VERLOREN_S = 3 * 3600 # abgeholt, aber nie beantwortet (Ausführer abgestürzt, Host neu gestartet) -BEHALTEN = 60 # so viele erledigte Aufträge bleiben sichtbar +BEHALTEN = 500 # so viele erledigte Aufträge bleiben sichtbar (Protokoll; bis 24.09.2026: 60) +VOLLTEXT = 60 # die jüngsten erledigten behalten ihre ganze Ausgabe … +TEXT_KURZ = 4000 # … ältere nur deren letzte Zeichen +BERICHTE_BEHALTEN = 150 # Eingänge der Berichte, die der Ausführer von sich aus schickt (alle 10 min: gut ein Tag) _lock = threading.Lock() _kontakt: dict = {"zuletzt": None} @@ -110,9 +117,31 @@ def zuletzt() -> float | None: # --- Bericht ------------------------------------------------------------------------------ -def bericht_speichern(daten: dict) -> None: +def bericht_speichern(daten: dict, aus_auftrag: bool = False) -> None: + """Den neuesten Bericht ablegen. Schickt ihn der Ausführer von sich aus (alle 10 min und nach jedem Auftrag), + wird sein Eingang kurz vermerkt — das Protokoll zeigt ihn auf Wunsch. aus_auftrag: ein Lauf hat ihn als Auftrag + „bericht“ geholt (updates.py); dann steht er dort schon.""" + jetzt = time.time() with _lock: - _json_schreiben(_dir() / "pve-bericht.json", {"empfangen": time.time(), "bericht": daten}) + _json_schreiben(_dir() / "pve-bericht.json", {"empfangen": jetzt, "bericht": daten}) + if aus_auftrag: + return + host = daten.get("host") if isinstance(daten.get("host"), dict) else {} + gaeste = [g for g in daten.get("gaeste") or [] if isinstance(g, dict)] + eingang = {"empfangen": jetzt, "host_version": host.get("version"), "gaeste": len(gaeste), + "laufend": sum(1 for g in gaeste if g.get("status") == "running"), + "host_updates": len(host["updates"]) if isinstance(host.get("updates"), list) else None} + pfad = _dir() / "ausfuehrer-berichte.json" + eingaenge = _json_lesen(pfad, []) + eingaenge = eingaenge if isinstance(eingaenge, list) else [] + _json_schreiben(pfad, (eingaenge + [eingang])[-BERICHTE_BEHALTEN:]) + + +def berichte() -> list[dict]: + """Die vermerkten Eingänge von Berichten (älteste zuerst): Zeit, Host-Version, Zahl der Gäste und Host-Updates.""" + with _lock: + daten = _json_lesen(_dir() / "ausfuehrer-berichte.json", []) + return [e for e in daten if isinstance(e, dict)] if isinstance(daten, list) else [] def bericht() -> dict | None: @@ -136,6 +165,10 @@ def _alle() -> list[dict]: 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:] + for a in fertig[:-VOLLTEXT]: + text = a.get("text") + if isinstance(text, str) and len(text) > TEXT_KURZ: + a["text"] = "…" + text[-(TEXT_KURZ - 1):] _json_schreiben(_pfad(), sorted(offen + fertig, key=lambda a: a["erstellt"])) @@ -154,17 +187,22 @@ def anlegen(aktion: str, parameter: dict | None = None) -> str: def naechster() -> dict | None: """Für den Ausführer: der älteste wartende Auftrag, ab jetzt „läuft“. Hängengebliebene werden - vorher als verloren abgeschlossen.""" + vorher als verloren abgeschlossen. Geschrieben wird nur, wenn sich etwas ändert: Der Ausführer fragt alle + fünf Sekunden.""" jetzt = time.time() with _sperre(): auftraege = _alle() + geaendert = False 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.") + geaendert = True auftrag = next((a for a in auftraege if a["status"] == "wartet"), None) if auftrag: auftrag.update(status="laeuft", abgeholt=jetzt) - _merken(auftraege) + geaendert = True + if geaendert: + _merken(auftraege) return {k: auftrag[k] for k in ("id", "aktion", "parameter")} if auftrag else None diff --git a/backend/services/homelab/pflege.py b/backend/services/homelab/pflege.py index 4dc52ba..114c2a0 100644 --- a/backend/services/homelab/pflege.py +++ b/backend/services/homelab/pflege.py @@ -15,7 +15,9 @@ Die Übersicht ist nur so ehrlich wie die Paketlisten in den Gästen, und die er Der Steward der Rolle homelab ruft pruefe_paketlisten() in jedem Wächter-Takt auf (services/waechter.py, PRUEFUNGEN). Zustand in /homelab-pflege.json; den schreibt nur der Steward. Die Aufträge selbst -landen im Kanal (kanal.py, unter Dateisperre, weil auch die Oberfläche dort schreibt). +landen im Kanal (kanal.py, unter Dateisperre, weil auch die Oberfläche dort schreibt). Seit 24.09.2026 steht dort +auch eine kurze Ereignisliste (angestoßen, gescheitert, wieder ok; die letzten EREIGNISSE_MAX) — das Protokoll der +Oberfläche liest sie (protokoll.py). """ import json @@ -35,6 +37,7 @@ log = logging.getLogger(__name__) LISTEN_ALT_S = 7 * 24 * 3600 NACHT_VON, NACHT_BIS = 2, 5 # volle Stunden, Europe/Berlin FEHLSCHLAEGE_HINWEIS = 2 +EREIGNISSE_MAX = 200 _lock = threading.Lock() @@ -82,10 +85,21 @@ def _fehlerzeile(text: str | None) -> str: return (wichtig or zeilen or ["ohne Angabe"])[-1][:200] +def _ereignis(daten: dict, ts: float, vmid: object, name: object, art: str, text: str) -> None: + """Für das Protokoll der Oberfläche; die Liste bleibt kurz.""" + try: + vmid = int(vmid) + except (TypeError, ValueError): + return + liste = daten["ereignisse"] if isinstance(daten.get("ereignisse"), list) else [] + liste.append({"ts": ts, "vmid": vmid, "name": str(name or vmid), "art": art, "text": text}) + daten["ereignisse"] = liste[-EREIGNISSE_MAX:] + + def _einsammeln(daten: dict) -> bool: """Ergebnisse der eigenen Aufträge übernehmen: fertig → Zähler auf null, gescheitert → eins mehr.""" geaendert = False - for eintrag in daten["gaeste"].values(): + for vmid, eintrag in daten["gaeste"].items(): aid = eintrag.get("auftrag") if not aid: continue @@ -93,10 +107,15 @@ def _einsammeln(daten: dict) -> bool: if a is not None and a.get("status") in ("wartet", "laeuft"): continue if a is not None and a.get("status") == "fertig": + if eintrag.get("fehlschlaege"): + _ereignis(daten, a.get("fertig") or time.time(), vmid, eintrag.get("name"), "wieder_ok", + "Die Paketlisten ließen sich wieder erneuern.") eintrag["fehlschlaege"], eintrag["fehler"] = 0, None elif a is not None: # fehler, verloren eintrag["fehlschlaege"] = int(eintrag.get("fehlschlaege") or 0) + 1 eintrag["fehler"] = _fehlerzeile(a.get("text")) + _ereignis(daten, a.get("fertig") or time.time(), vmid, eintrag.get("name"), "gescheitert", + f"{eintrag['fehlschlaege']}. Fehlschlag in Folge: {eintrag['fehler']}") eintrag["auftrag"] = None # nicht mehr in der Liste: vergessen, nicht zählen geaendert = True return geaendert @@ -173,6 +192,9 @@ def pruefe_paketlisten(jetzt: float | None = None) -> list: log.info("pflege (trocken): würde die Paketlisten von %s erneuern", eintrag["name"]) continue eintrag.update(tag=heute, auftrag=kanal.anlegen("suchen", {"vmid": vmid}), angestossen=jetzt) + listen = (g.get("os_updates") or {}).get("listen_stand") + _ereignis(daten, jetzt, vmid, eintrag["name"], "angestossen", + f"Die Paketlisten sind vom {datetime.fromtimestamp(listen, LOCAL_TZ):%d.%m.%Y}.") geaendert = True log.info("pflege: Paketlisten von %s sind alt → suchen", eintrag["name"]) if geaendert: diff --git a/backend/services/homelab/protokoll.py b/backend/services/homelab/protokoll.py new file mode 100644 index 0000000..a5a328a --- /dev/null +++ b/backend/services/homelab/protokoll.py @@ -0,0 +1,517 @@ +"""Protokoll des Homelab-Teils (24.09.2026): was wann geschah, aus allen Quellen, neueste zuerst. + +Quellen, alle nur gelesen: + auftrag Aufträge an den Ausführer (kanal.py): Aktion, Gerät, Status, Code; die Ausgabe als Einzelheiten. + Berichte nur auf Wunsch (berichte=1): die Aufträge „bericht“ der Update-Läufe und die Berichte, die + der Ausführer von sich aus schickt (alle zehn Minuten; kanal.py vermerkt ihren Eingang). + lauf „Jetzt updaten“-Läufe mit ihren Schritten (updates.py) + sammellauf „Alle aktualisieren“ (sammellauf.py) + hinweis Hinweise des Wächters: erschienen, erledigt, Selbstreparatur (der Verlauf in mc2-waechter.json; + ein Hinweis, der dort schon herausgefallen ist, erscheint mit seinem Beginn) + meldung was notify.sh an Telegram gab oder für die Morgenmeldung zurücklegte (Melde-Log des Containers, + MC_NOTIFY_LOG). Den Betreff schreibt notify.sh nicht mit; er folgt aus dem Absender: Lauf, + Sammellauf, Wächter, Telegram-Test oder Morgenmeldung. + pflege das wöchentliche Suchen (pflege.py): angestoßen, gescheitert, wieder ok + +Kein Eintrag enthält Geheimnisse: schwaerzen() nimmt die bekannten Schlüssel dieser Instanz (Arcane, Ausführer) und +alles, was wie ein Zugangs-Token aussieht, aus Titeln, Texten und Ausgaben; Steuerzeichen der Konsole fallen weg. +""" + +import json +import os +import re +from collections.abc import Callable +from datetime import datetime +from pathlib import Path + +from kern.einstellungen import einstellungen +from kern.zeit import LOCAL_TZ + +from services.einstellungen import melde_log +from services.homelab import apps, arcane, kanal, pflege, sammellauf, updates + +GRENZE_MAX = 1000 +DETAILS_MAX = 4000 +TEXT_MAX = 300 +MELDELOG_MAX = 512 * 1024 # so viel vom Ende des Melde-Logs +STUFE_HINWEIS = {"rot": "fehler", "gelb": "warn"} +# Betreffzeilen der Absender im Homelab-Teil (notify.sh schreibt sie nicht ins Melde-Log). +BETREFF_WAECHTER = ("[Homelab-Problem]", "[Homelab wieder ok]") # waechter.BETREFF_* in der Rolle homelab +BETREFF_TEST = "[Test]" # services/einstellungen.telegram_test +BETREFF_MORGEN = "[Morgenmeldung Homelab]" # deploy/homelab/mc2-homelab-morgenmeldung.service +LUCY_ZUSATZ = " (Diese Meldung kam auch an Lucy.)" # hängt waechter._telegram an + +# Aktion → (erledigt, gescheitert, offen, kurz). Titel sind „Was · Gerät“. +_AKTION = { + "bericht": ("Bericht geholt", "Bericht gescheitert", "Bericht angefordert", "Bericht"), + "snapshot": ("Snapshot angelegt", "Snapshot gescheitert", "Snapshot wird angelegt", "Snapshot"), + "update": ("Update-Skript gelaufen", "Update-Skript gescheitert", "Update-Skript läuft", "Update-Skript"), + "os_update": ("Pakete eingespielt", "Pakete einspielen gescheitert", "Pakete werden eingespielt", + "Pakete einspielen"), + "suchen": ("Paketlisten erneuert", "Paketlisten erneuern gescheitert", "Paketlisten werden erneuert", + "Paketlisten erneuern"), + "zurueck": ("Auf den Snapshot zurückgesetzt", "Zurücksetzen gescheitert", "Wird auf den Snapshot zurückgesetzt", + "Zurücksetzen"), + "snapshot_loeschen": ("Alter Snapshot gelöscht", "Snapshot löschen gescheitert", "Alter Snapshot wird gelöscht", + "Snapshot löschen"), + "sichern": ("Sicherung angelegt", "Sicherung gescheitert", "Sicherung läuft", "Sicherung"), + "sicherung_zurueck": ("Sicherung zurückgespielt", "Zurückspielen gescheitert", "Sicherung wird zurückgespielt", + "Zurückspielen"), + "sicherung_loeschen": ("Alte Sicherung gelöscht", "Sicherung löschen gescheitert", "Alte Sicherung wird gelöscht", + "Sicherung löschen"), + "host_update": ("Host-Pakete eingespielt", "Host-Pakete gescheitert", "Host-Pakete werden eingespielt", + "Host-Pakete"), + "host_neustart": ("Neustart ausgelöst", "Neustart gescheitert", "Neustart angefordert", "Neustart"), +} +_HOST_AKTIONEN = {"bericht", "host_update", "host_neustart"} +_LAUF_WAS = {"app": "Update", "os": "Paket-Update", "pakete": "Paket-Update", "docker": "Docker-Update", + "neustart": "Neustart"} +_SCHRITT_WORT = {"wartet": "wartet", "laeuft": "läuft", "eingespielt": "eingespielt", + "zurueckgerollt": "zurückgerollt", "fehler": "gescheitert", "uebersprungen": "übersprungen"} +_MELDUNG = re.compile(r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) " + r"(OK telegram direkt|OK telegram|QUEUED für Morgen-Digest|FALLBACK \((.*?)\)): ?(.*)$") +_WEG = {"OK telegram direkt": ("Meldung raus", "info", "Direkt an die Telegram-Bot-API gesendet."), + "OK telegram": ("Meldung raus", "info", "Über Hermes an Telegram gesendet."), + "QUEUED für Morgen-Digest": ("Meldung zurückgelegt", "info", + "Nachtruhe: Sie kommt mit der Morgenmeldung um 07:00.")} +_FEHLERZEILE = ("E:", "ERROR", "Error", "error:", "FATAL", "Fehler", "Abgelehnt", "Zeitlimit", "Traceback") + + +# --- Geheimnisse und Konsolen-Zeichen ------------------------------------------------------------------- + +_ANSI = re.compile(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-Z\\-_])") +_STEUER = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]") +_GEHEIM = ( + # Telegram-Bot-Token, frei oder in Adressen (…/bot/sendMessage: dort ohne Wortgrenze davor) + (re.compile(r"(? str: + """Das Geheimnis des Kanals, falls es schon eines gibt — ohne es zu erzeugen (kanal.token() täte das).""" + if wert := os.environ.get("MC_AUSFUEHRER_TOKEN", "").strip(): + return wert + try: + return (einstellungen().daten_dir / "ausfuehrer.token").read_text(encoding="utf-8").strip() + except (OSError, UnicodeDecodeError): + return "" + + +def _schwaerzer() -> Callable[[str | None], str | None]: + """Eine Schwärz-Funktion mit den bekannten Schlüsseln dieser Instanz (einmal je Protokoll gelesen).""" + bekannt = sorted({w for w in (arcane.schluessel(), _ausfuehrer_token()) if len(w) >= 8}, key=len, reverse=True) + + def schwaerzen(text: str | None) -> str | None: + if text is None: + return None + text = _ANSI.sub("", str(text)) + # Fortschrittsbalken überschreiben ihre Zeile mit \r: Stehen bleibt, was die Konsole zuletzt zeigte. + zeilen = [] + for zeile in text.split("\n"): + teile = [t for t in zeile.split("\r") if t.strip()] + zeilen.append(teile[-1] if teile else "") + text = _STEUER.sub("", "\n".join(zeilen)) + for wert in bekannt: + text = text.replace(wert, "***") + for muster, ersatz in _GEHEIM: + text = muster.sub(ersatz, text) + return text + + return schwaerzen + + +def schwaerzen(text: str | None) -> str | None: + """Geheimnisse und Steuerzeichen aus einem Text (für Tests und Einzelfälle; eintraege() liest einmal).""" + return _schwaerzer()(text) + + +def _kuerzen(text: str | None, laenge: int = DETAILS_MAX) -> str | None: + """Das Ende langer Ausgaben (dort stehen Ergebnis und Fehler), wenn möglich ab einem Zeilenanfang.""" + if not text or not text.strip(): + return None + text = text.strip("\n") + if len(text) <= laenge: + return text + rest = text[-(laenge - 2):] + if "\n" in rest[:300]: + rest = rest.split("\n", 1)[1] + return "…\n" + rest + + +def _zeile(text: str | None) -> str | None: + """Ein Text für das Feld „text“: eine Zeile, höchstens TEXT_MAX Zeichen.""" + if not text or not str(text).strip(): + return None + ganz = str(text).strip() + erste = ganz.splitlines()[0].strip() + if len(erste) > TEXT_MAX: + return erste[:TEXT_MAX - 1].rstrip() + "…" + return erste + ("…" if "\n" in ganz else "") + + +# --- Geräte --------------------------------------------------------------------------------------------- + +def _geraete() -> dict[int, tuple[str, str]]: + """VMID → (Ziel-ID, Name) aus dem letzten Bericht des Ausführers.""" + geraete: dict[int, tuple[str, str]] = {} + for g in ((kanal.bericht() or {}).get("bericht") or {}).get("gaeste") or []: + if not isinstance(g, dict) or not isinstance(g.get("vmid"), int): + continue + app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name")) + zid = f"{'ct' if g.get('art') == 'lxc' else 'vm'}-{g['vmid']}" + geraete[g["vmid"]] = (zid, app.name if app else str(g.get("name") or zid)) + return geraete + + +def _eintrag(zeit: object, art: str, titel: str, text: str | None, stufe: str, ziel: str | None, + details: str | None, schw: Callable) -> dict | None: + if not isinstance(zeit, (int, float)): + return None + return {"zeit": float(zeit), "art": art, "titel": schw(titel), "text": schw(text), "stufe": stufe, + "ziel": ziel, "details": schw(details)} + + +# --- Aufträge an den Ausführer -------------------------------------------------------------------------- + +def _dauer(bis: object, von: object) -> str | None: + if not (isinstance(bis, (int, float)) and isinstance(von, (int, float))) or bis < von: + return None + s = round(bis - von) + if s < 60: + return f"{s} s" + if s < 3600: + return f"{s // 60} min" + return f"{s // 3600} h {s % 3600 // 60} min" + + +def _gegenstand(aktion: str, p: dict, ausgabe: str) -> str | None: + """Worum es ging: der Snapshot bzw. die Sicherung.""" + if aktion in ("zurueck", "snapshot_loeschen"): + return str(p.get("snapshot") or "") or None + if aktion in ("sicherung_zurueck", "sicherung_loeschen"): + return str(p.get("sicherung") or "").rsplit("/", 1)[-1] or None + if aktion == "snapshot" and (m := re.search(r"snapshot=(\S+)", ausgabe)): + return m.group(1) + if aktion == "sichern" and (m := re.search(r"^sicherung=(\S+)$", ausgabe, re.MULTILINE)): + return m.group(1).rsplit("/", 1)[-1] + return None + + +def _fehlerzeile(ausgabe: str) -> str | None: + zeilen = [z.strip() for z in ausgabe.splitlines() if z.strip()] + wichtig = [z for z in zeilen if z.startswith(_FEHLERZEILE)] + zeile = (wichtig or zeilen or [None])[-1] + return zeile[:200] if zeile else None + + +def _bericht_satz(version: object, gaeste: object, laufend: object, host_updates: object) -> str: + teile = [f"Proxmox VE {version}" if version else None, f"{gaeste} Gäste, davon {laufend} laufend", + f"{host_updates} Paket-Updates auf dem Host" if isinstance(host_updates, int) else None] + return ", ".join(t for t in teile if t) + "." + + +def _bericht_kurz(ausgabe: str) -> str | None: + """Ein Bericht ist JSON; statt seines Endes eine Zusammenfassung.""" + try: + b = json.loads(ausgabe) + except ValueError: + return None + if not isinstance(b, dict): + return None + host = b.get("host") if isinstance(b.get("host"), dict) else {} + gaeste = [g for g in b.get("gaeste") or [] if isinstance(g, dict)] + return _bericht_satz(host.get("version"), len(gaeste), sum(1 for g in gaeste if g.get("status") == "running"), + len(host["updates"]) if isinstance(host.get("updates"), list) else None) + + +def _bericht_eingang(e: dict, schw: Callable) -> dict | None: + """Ein Bericht, den der Ausführer von sich aus geschickt hat (kein Auftrag).""" + return _eintrag(e.get("empfangen"), "auftrag", "Bericht empfangen · Proxmox-Host", + _bericht_satz(e.get("host_version"), e.get("gaeste"), e.get("laufend"), e.get("host_updates")), + "info", "pve", None, schw) + + +def _auftrag(a: dict, geraete: dict, schw: Callable) -> dict | None: + aktion = str(a.get("aktion") or "?") + erledigt, gescheitert, offen, kurz = _AKTION.get(aktion, (aktion, f"{aktion} gescheitert", f"{aktion} läuft", + aktion)) + p = a.get("parameter") if isinstance(a.get("parameter"), dict) else {} + ziel, name = None, None + if aktion in _HOST_AKTIONEN: + ziel, name = "pve", "Proxmox-Host" + elif isinstance(p.get("vmid"), int): + ziel, name = geraete.get(p["vmid"], (None, f"Gast {p['vmid']}")) + status, code = a.get("status"), a.get("code") + ausgabe = str(a.get("text") or "") + gegenstand = _gegenstand(aktion, p, ausgabe) + dauer = _dauer(a.get("fertig"), a.get("abgeholt")) + kopf = ", ".join(t for t in (gegenstand, f"nach {dauer}" if dauer else None, + f"Code {code}" if code is not None else None) if t) + if status == "fertig": + titel, stufe, text = erledigt, "ok", kopf or None + elif status == "fehler" and code == 2 and ausgabe.startswith("Abgelehnt"): + titel, stufe, text = f"{kurz} abgelehnt", "warn", _fehlerzeile(ausgabe) + elif status == "fehler": + grund = _fehlerzeile(ausgabe) + titel, stufe, text = gescheitert, "fehler", f"{kopf}: {grund}" if grund else kopf or None + elif status == "verloren": + titel, stufe, text = f"{kurz}: keine Antwort", "fehler", "Der Ausführer hat nie geantwortet." + elif status == "laeuft": + titel, stufe, text = offen, "info", "Der Ausführer ist dran." + else: + titel, stufe, text = offen, "info", "Wartet auf den Ausführer." + details = _bericht_kurz(ausgabe) if aktion == "bericht" else None + return _eintrag(a.get("fertig") or a.get("abgeholt") or a.get("erstellt"), "auftrag", + f"{titel} · {name}" if name else titel, text, stufe, ziel, + details or _kuerzen(schw(ausgabe)), schw) + + +# --- Läufe und Sammelläufe ------------------------------------------------------------------------------ + +def _anzahl_pakete(n: object, wort: str) -> str: + return f"{n} {wort}{'' if n == 1 else 'e'} eingespielt" if isinstance(n, int) and n > 0 else f"{wort}e eingespielt" + + +def _lauf(x: dict, schw: Callable) -> dict | None: + name = str(x.get("name") or x.get("ziel") or "?") + baustein, ergebnis, status, text = x.get("baustein"), x.get("ergebnis"), x.get("status"), x.get("text") + was = _LAUF_WAS.get(str(baustein), "Update") + if status == "laeuft": + titel, stufe = f"{was} läuft · {name}", "info" + elif status == "unterbrochen": + titel, stufe = f"{was} unterbrochen · {name}", "fehler" + text = text or "Der Homelab-Teil wurde während des Laufs neu gestartet; ob das Update durchlief, zeigt der " \ + "nächste Bericht." + elif ergebnis == "eingespielt": + stufe = "ok" + if baustein == "app": + titel = f"Update eingespielt · {name}" + (f" {x['version_neu']}" if x.get("version_neu") else "") + elif baustein == "docker": + titel = f"Docker-Images aktualisiert · {name}" + elif baustein == "pakete": + titel = f"{_anzahl_pakete(x.get('pakete'), 'Host-Paket')} · {name}" + else: + titel = f"{_anzahl_pakete(x.get('pakete'), 'Paket')} · {name}" + elif ergebnis == "zurueckgerollt": + titel, stufe = f"{was} zurückgerollt · {name}", "warn" + elif ergebnis == "fehler" and "Update nicht begonnen" in str(text or ""): + titel, stufe = f"{was} nicht begonnen · {name}", "warn" + elif ergebnis == "fehler": + titel, stufe = f"{was} gescheitert · {name}", "fehler" + elif ergebnis == "neustart": + titel, stufe = f"Neustart ausgelöst · {name}", "info" + elif ergebnis == "offen": + titel, stufe = f"Docker-Probelauf · {name}", "info" + else: + titel, stufe = f"{was} beendet · {name}", "info" + zeilen = [str(s) for s in x.get("schritte") or []] + if x.get("sammellauf"): + zeilen.append("Teil von „Alle aktualisieren“.") + if x.get("gemeldet") is False: + zeilen.append("Keine eigene Meldung: Die Sammelmeldung am Ende sagt es.") + return _eintrag(x.get("ende") or x.get("start"), "lauf", titel, text, stufe, x.get("ziel"), + "\n".join(zeilen) or None, schw) + + +def _sammellauf(sl: dict, schw: Callable) -> dict | None: + schritte = [s for s in sl.get("schritte") or [] if isinstance(s, dict)] + anzahl = len(schritte) + eingespielt = sum(1 for s in schritte if s.get("status") == "eingespielt") + status = sl.get("status") + if status == "laeuft": + i = sl.get("aktuell") + titel = "Alle aktualisieren läuft" + (f" · Schritt {i + 1} von {anzahl}" if isinstance(i, int) else "") + stufe = "info" + elif status == "abgebrochen": + bei = next((s.get("name") for s in schritte if s.get("status") in ("fehler", "zurueckgerollt")), None) + titel, stufe = "Alle aktualisieren abgebrochen" + (f" · {bei}" if bei else ""), "fehler" + else: + titel = f"Alle aktualisieren fertig · {eingespielt} von {anzahl} eingespielt" + stufe = "ok" if eingespielt == anzahl else "info" + details = "\n".join(f"{s.get('name')} ({s.get('was')}): {_SCHRITT_WORT.get(str(s.get('status')), s.get('status'))}" + + (f" – {s['text']}" if s.get("text") else "") for s in schritte) + return _eintrag(sl.get("ende") or sl.get("start"), "sammellauf", titel, sl.get("text"), stufe, None, + details or None, schw) + + +# --- Hinweise des Wächters ------------------------------------------------------------------------------ + +def _waechter_pfad() -> Path: + """Wie waechter.STORE_PATH — hier gelesen, ohne den Wächter zu laden (der zieht Module der KI-Box nach).""" + return Path(os.environ.get("MC_WAECHTER_STORE", str(einstellungen().daten_dir / "mc2-waechter.json"))) + + +def _ziel_aus(hinweis_id: str) -> str | None: + if m := re.search(r"\b(ct|vm)-(\d+)\b", hinweis_id): + return f"{m.group(1)}-{m.group(2)}" + if m := re.fullmatch(r"gast-platte:(\d+)", hinweis_id): + return f"ct-{m.group(1)}" + return "pve" if hinweis_id == "ausfuehrer" else None + + +def _hinweise(schw: Callable) -> list[dict]: + try: + daten = json.loads(_waechter_pfad().read_text(encoding="utf-8")) + except (OSError, ValueError): + return [] + daten = daten if isinstance(daten, dict) else {} + aktuell = daten.get("hinweise") if isinstance(daten.get("hinweise"), dict) else {} + eintraege, erschienen = [], set() + for v in daten.get("verlauf") or []: + if not isinstance(v, dict): + continue + art, hid = v.get("art"), str(v.get("id") or "") + titel, h = str(v.get("text") or hid), aktuell.get(hid) if isinstance(aktuell.get(hid), dict) else {} + if art == "neu": + erschienen.add(hid) + e = _eintrag(v.get("ts"), "hinweis", f"Hinweis: {titel}", h.get("text"), + STUFE_HINWEIS.get(str(v.get("stufe") or h.get("stufe")), "warn"), _ziel_aus(hid), None, schw) + elif art == "erledigt": + e = _eintrag(v.get("ts"), "hinweis", f"Erledigt: {titel}", None, "ok", _ziel_aus(hid), None, schw) + elif art == "auto": + e = _eintrag(v.get("ts"), "hinweis", titel, "Selbstreparatur des Wächters.", "info", _ziel_aus(hid), + None, schw) + else: + continue + eintraege.append(e) + for hid, h in aktuell.items(): + if hid not in erschienen and isinstance(h, dict): + eintraege.append(_eintrag(h.get("seit"), "hinweis", f"Hinweis: {h.get('titel') or hid}", h.get("text"), + STUFE_HINWEIS.get(str(h.get("stufe")), "warn"), _ziel_aus(hid), None, schw)) + return eintraege + + +# --- Meldungen ------------------------------------------------------------------------------------------ + +def _meldelog() -> list[list]: + """[Zeit, Weg, Grund, Text] je Eintrag des Melde-Logs; Zeilen ohne Zeitstempel gehören zur Meldung davor.""" + try: + with melde_log().open("rb") as f: + f.seek(0, os.SEEK_END) + groesse = f.tell() + f.seek(max(0, groesse - MELDELOG_MAX)) + roh = f.read().decode("utf-8", "replace") + except OSError: + return [] + if groesse > MELDELOG_MAX: + roh = roh.split("\n", 1)[-1] # angeschnittene erste Zeile + eintraege: list[list] = [] + for zeile in roh.splitlines(): + if m := _MELDUNG.match(zeile): + try: + zeit = datetime.strptime(m.group(1), "%Y-%m-%d %H:%M:%S").replace(tzinfo=LOCAL_TZ).timestamp() + except ValueError: + continue + weg = "FALLBACK" if m.group(2).startswith("FALLBACK") else m.group(2) + eintraege.append([zeit, weg, m.group(3), m.group(4)]) + elif eintraege: + eintraege[-1][3] += "\n" + zeile + return eintraege + + +def _betreffe(laeufe: list[dict], sammellaeufe: list[dict]) -> dict[str, str]: + """Meldungstext → Betreff für das, was Läufe und Sammelläufe gemeldet haben.""" + bekannt = {} + for x in laeufe: + if x.get("text") and x.get("gemeldet") is not False: + dringend = x.get("dringend") + if dringend is None: # Läufe von vor dem 24.09.2026 abends: wie updates._ende es entschied + dringend = x.get("ergebnis") in ("zurueckgerollt", "fehler") and "nicht begonnen" not in x["text"] + bekannt[x["text"]] = updates.BETREFF_ALARM if dringend else updates.BETREFF + for sl in sammellaeufe: + if sl.get("meldung"): + bekannt[sl["meldung"]] = updates.BETREFF_ALARM if sl.get("dringend") else updates.BETREFF + return bekannt + + +def _betreff(text: str, bekannt: dict[str, str]) -> str | None: + if text in bekannt: + return bekannt[text] + if text.endswith(LUCY_ZUSATZ): + return BETREFF_WAECHTER[1] if text.startswith("Erledigt:") else BETREFF_WAECHTER[0] + if text.startswith("Testmeldung aus den Einstellungen"): + return BETREFF_TEST + if text.startswith("Guten Morgen, Commander."): + return BETREFF_MORGEN + return None + + +def _meldungen(bekannt: dict[str, str], schw: Callable) -> list[dict]: + eintraege = [] + for zeit, weg, grund, text in _meldelog(): + betreff = _betreff(text, bekannt) + if weg == "FALLBACK": + titel, stufe, wie = "Meldung ging nicht raus", "fehler", f"Telegram ging nicht: {grund or 'ohne Angabe'}" + else: + titel, stufe, wie = _WEG[weg] + kurz = _zeile(text) + details = wie if kurz == text.strip() else f"{wie}\n\n{text.strip()}" + eintraege.append(_eintrag(zeit, "meldung", f"{titel} · {betreff}" if betreff else titel, kurz, stufe, None, + details, schw)) + return eintraege + + +# --- Pflege --------------------------------------------------------------------------------------------- + +_PFLEGE = {"angestossen": ("Wöchentliche Suche angestoßen", "info"), + "gescheitert": ("Wöchentliche Suche gescheitert", "warn"), + "wieder_ok": ("Wöchentliche Suche klappt wieder", "ok")} + + +def _pflege(geraete: dict, schw: Callable) -> list[dict]: + daten = pflege.zustand() + ereignisse = [e for e in daten.get("ereignisse") or [] if isinstance(e, dict)] + eintraege = [] + for e in ereignisse: + if e.get("art") not in _PFLEGE or not isinstance(e.get("vmid"), int): + continue + titel, stufe = _PFLEGE[e["art"]] + name = e.get("name") or geraete.get(e["vmid"], (None, f"Gast {e['vmid']}"))[1] + eintraege.append(_eintrag(e.get("ts"), "pflege", f"{titel} · {name}", e.get("text"), stufe, + f"ct-{e['vmid']}", None, schw)) + # Stände von vor dem 24.09.2026 abends haben keine Ereignisliste: dann wenigstens der letzte Anstoß je Gast. + schon = {(e.get("vmid"), e.get("ts")) for e in ereignisse if e.get("art") == "angestossen"} + for vmid, g in (daten.get("gaeste") or {}).items(): + if not (isinstance(g, dict) and isinstance(g.get("angestossen"), (int, float)) and str(vmid).isdigit()): + continue + if (int(vmid), g["angestossen"]) not in schon: + name = g.get("name") or geraete.get(int(vmid), (None, f"Gast {vmid}"))[1] + eintraege.append(_eintrag(g["angestossen"], "pflege", f"{_PFLEGE['angestossen'][0]} · {name}", + "Die Paketlisten waren älter als 7 Tage.", "info", f"ct-{vmid}", None, schw)) + return eintraege + + +# --- Zusammen ------------------------------------------------------------------------------------------- + +def eintraege(grenze: int = 200, berichte: bool = False) -> list[dict]: + """Alle Einträge, neueste zuerst, höchstens `grenze` (1 bis GRENZE_MAX).""" + grenze = max(1, min(int(grenze), GRENZE_MAX)) + schw = _schwaerzer() + geraete = _geraete() + laeufe = updates.laeufe(100) + sammellaeufe = sammellauf.liste() + alle: list[dict | None] = [] + alle += [_auftrag(a, geraete, schw) for a in kanal.liste() if berichte or a.get("aktion") != "bericht"] + if berichte: + alle += [_bericht_eingang(e, schw) for e in kanal.berichte()] + alle += [_lauf(x, schw) for x in laeufe if isinstance(x, dict)] + alle += [_sammellauf(sl, schw) for sl in sammellaeufe] + alle += _hinweise(schw) + alle += _meldungen(_betreffe(laeufe, sammellaeufe), schw) + alle += _pflege(geraete, schw) + liste = [e for e in alle if e is not None] + liste.sort(key=lambda e: e["zeit"], reverse=True) + return liste[:grenze] diff --git a/backend/services/homelab/sammellauf.py b/backend/services/homelab/sammellauf.py new file mode 100644 index 0000000..24f7c9e --- /dev/null +++ b/backend/services/homelab/sammellauf.py @@ -0,0 +1,329 @@ +"""„Alle aktualisieren“ im Homelab (24.09.2026): alle Updates mit Knopf nacheinander. + +Jeder Schritt ist ein gewöhnlicher Lauf von „Jetzt updaten“ (updates.starten) und wartet, bis dieser fertig ist — +mit Rückweg, Prüfung und Zurückrollen wie beim einzelnen Knopf. Reihenfolge (User-Entscheid 24.09.2026): + 1. die Gäste nach VMID, außer NPMplus und AdGuard Home + 2. NPMplus, dann AdGuard Home: Nadelöhre (Proxy, DNS) — fällt einer aus, hängt anderes mit + 3. ganz zuletzt die Pakete des Proxmox-Hosts; neu gestartet wird der Host dabei nie +Dabei ist nur, was „neu“ ist und einen Knopf hat. Was in der Wartezeit nach einer Skriptänderung steht (karenz.py) +oder sich über die eigene Oberfläche aktualisiert, hat keinen Knopf und fehlt deshalb. Docker über Arcane ist nur +dabei, wenn die Updates echt laufen: Ein Probelauf ändert nichts. + +Endet ein Schritt mit „zurueckgerollt“ oder „fehler“, hört der Sammellauf auf: Die übrigen Schritte sind +„uebersprungen“, er selbst ist „abgebrochen“, und es geht eine dringende Meldung raus. Läuft alles durch, kommt eine +Sammelmeldung; die Erfolgsmeldungen der einzelnen Schritte hält updates._ende() so lange zurück, Fehlermeldungen +nicht. Lehnt updates.starten einen Schritt ab (am Gerät ändert sich dann nichts, etwa weil die Wartezeit inzwischen +greift), wird er übersprungen, und es geht weiter. + +Es läuft höchstens ein Sammellauf, und solange er läuft, lehnt der einzelne Knopf ab (updates.SAMMELLAUF_SPERRE). +Stand in /homelab-sammellauf.json (die letzten BEHALTEN). Startet der Homelab-Teil mitten im Lauf neu, +gilt er als abgebrochen, „unterbrochen (Neustart)“ (über updates.unterbrochene_abschliessen beim Start). +""" + +import json +import logging +import threading +import time +import uuid +from pathlib import Path + +from kern.einstellungen import einstellungen + +from services import announce +from services.homelab import apps, arcane, inventar, kanal, updates + +log = logging.getLogger(__name__) + +NADELOEHRE = ("npmplus", "adguard") # nach allen anderen Gästen, in dieser Reihenfolge +GAST_BAUSTEINE = ("app", "os", "docker") +TAKT_S = 2.0 +WARTEN_MAX_S = 4 * 3600 # Sicherheitsnetz; ein Lauf hat je Schritt eigene Zeitlimits (höchstens 45 min) +BEHALTEN = 20 +UNTERBROCHEN = "unterbrochen (Neustart)" +HOST_HINWEIS = "Der Proxmox-Host kommt zuletzt und hat keinen Rückweg; neu gestartet wird er nicht." +# Was ein Lauf als Ergebnis hat → Stand des Schritts. „offen“ ist ein Arcane-Probelauf: geändert wurde nichts. +STATUS = {"eingespielt": "eingespielt", "zurueckgerollt": "zurueckgerollt", "fehler": "fehler", + "offen": "uebersprungen"} +# Die Felder, die die Oberfläche bekommt (Vertrag GET /api/homelab/sammellauf); der Rest ist für das Protokoll. +FELDER = ("id", "status", "start", "ende", "aktuell", "schritte", "text") +SCHRITT_FELDER = ("ziel", "name", "baustein", "was", "status", "lauf", "text") + +_lock = threading.RLock() # _merken liest und schreibt unter derselben Sperre + + +# --- Stand ------------------------------------------------------------------------------------------ + +def _pfad() -> Path: + return einstellungen().daten_dir / "homelab-sammellauf.json" + + +def _alle() -> list[dict]: + with _lock: + try: + daten = json.loads(_pfad().read_text(encoding="utf-8")) + except (OSError, ValueError): + return [] + return [x for x in daten if isinstance(x, dict) and x.get("id")] if isinstance(daten, list) else [] + + +def _merken(sl: dict) -> None: + with _lock: + alle = [x for x in _alle() if x["id"] != sl["id"]] + [sl] + _pfad().parent.mkdir(parents=True, exist_ok=True) + tmp = _pfad().with_suffix(".tmp") + tmp.write_text(json.dumps(alle[-BEHALTEN:], ensure_ascii=False), encoding="utf-8") + tmp.replace(_pfad()) + + +def oeffentlich(sl: dict | None) -> dict | None: + """Ein Sammellauf, wie ihn die Oberfläche bekommt (nur die Felder des Vertrags, als Kopie).""" + if sl is None: + return None + return {**{k: sl.get(k) for k in FELDER}, + "schritte": [{k: s.get(k) for k in SCHRITT_FELDER} for s in sl.get("schritte") or []]} + + +def liste() -> list[dict]: + """Alle aufbewahrten Sammelläufe, neueste zuerst, mit allen Feldern (für das Protokoll).""" + return list(reversed(_alle())) + + +def aktueller() -> dict | None: + """Der laufende oder, wenn keiner läuft, der letzte Sammellauf.""" + alle = _alle() + return oeffentlich(next((x for x in reversed(alle) if x.get("status") == "laeuft"), alle[-1] if alle else None)) + + +def laeuft() -> bool: + return any(x.get("status") == "laeuft" for x in _alle()) + + +def _aufzaehlung(namen: list[str]) -> str: + """„A“, „A und B“, „A, B und C“.""" + namen = list(dict.fromkeys(namen)) + return namen[0] if len(namen) == 1 else ", ".join(namen[:-1]) + " und " + namen[-1] + + +def _melden(text: str, dringend: bool = False) -> None: + """Derselbe Meldeweg und Betreff wie beim einzelnen Knopf (updates.py).""" + try: + announce.notify_telegram(updates.BETREFF_ALARM if dringend else updates.BETREFF, text) + except Exception: + log.warning("homelab: Meldung zu „Alle aktualisieren“ ging nicht raus", exc_info=True) + + +# --- Plan ------------------------------------------------------------------------------------------- + +def _kennung(ziel_id: str) -> str | None: + gast = inventar.gast(ziel_id) or {} + app = apps.app_fuer((gast.get("app") or {}).get("kennung"), gast.get("name")) + return app.kennung if app else None + + +def _rang(schritt: dict) -> tuple[int, int]: + """Erst alle anderen Gäste nach VMID, dann die Nadelöhre in ihrer Reihenfolge.""" + kennung = _kennung(schritt["ziel"]) + nadel = NADELOEHRE.index(kennung) + 1 if kennung in NADELOEHRE else 0 + return nadel, int(schritt["ziel"].split("-", 1)[1]) + + +def plan() -> dict: + """Was „Alle aktualisieren“ jetzt täte, in dieser Reihenfolge, samt Hinweis auf das, was keinen Rückweg hat. + {"schritte": [{"ziel", "name", "baustein", "was", "rueckweg_art"}], "hinweis": str | None}""" + gaeste: list[dict] = [] + host: list[dict] = [] + docker_probe = False + for z in inventar.ziele()["ziele"]: + for b in z.get("bausteine") or []: + if b.get("zustand") != "neu" or not b.get("aktion"): + continue + schritt = {"ziel": z["id"], "name": z["name"], "baustein": b["id"], "was": b.get("kurz") or b["name"], + "rueckweg_art": z.get("rueckweg_art") or "keiner"} + if z["id"] == "pve": + if b["id"] == "pakete": # der Neustart ist nie dabei + host.append({**schritt, "rueckweg_art": "keiner"}) + elif b["id"] == "docker": + if not arcane.echt(): + docker_probe = True # ein Probelauf ist kein Update + else: + gaeste.append({**schritt, "rueckweg_art": "keiner"}) # Arcane legt keinen Snapshot an + elif b["id"] in GAST_BAUSTEINE: + gaeste.append(schritt) + gaeste.sort(key=_rang) # sort ist stabil: je Gast bleibt die Reihenfolge seiner Bausteine + schritte = gaeste + host + teile = [] + ohne = [s["name"] for s in gaeste if s["rueckweg_art"] == "keiner"] + if ohne: + teile.append(f"Ohne Rückweg: {_aufzaehlung(ohne)} – scheitert dort das Update, geht es nicht von selbst " + "zurück.") + if host: + teile.append(HOST_HINWEIS) + if docker_probe: + teile.append("Die Docker-Images (Arcane) sind nicht dabei, solange Docker-Updates nur als Probelauf laufen.") + return {"schritte": schritte, "hinweis": " ".join(teile) or None} + + +# --- Starten ---------------------------------------------------------------------------------------- + +def _ausfuehrer_grund() -> str | None: + """Warum der Ausführer gerade keine Updates annimmt — oder None.""" + zuletzt = kanal.zuletzt() + if not (zuletzt and time.time() - zuletzt < inventar.BERICHT_ALT_S): + return "Der Ausführer auf dem Proxmox-Host ist nicht verbunden. Ohne ihn geht kein Update." + host = ((kanal.bericht() or {}).get("bericht") or {}).get("host") or {} + if host.get("nur_lesen"): + return "Der Ausführer auf dem Proxmox-Host läuft im Nur-Lesen-Modus und lehnt Updates ab." + return None + + +def starten() -> dict: + """Knopf „Alle aktualisieren“. {"ok": True, "sammellauf": {…}} oder {"ok": False, "detail": …}.""" + if laeuft(): + return {"ok": False, "detail": updates.SAMMELLAUF_SPERRE} + schritte = plan()["schritte"] + if not schritte: + return {"ok": False, "detail": "Gerade gibt es kein Update mit Knopf, also nichts zu tun."} + if any(s["baustein"] != "docker" for s in schritte) and (grund := _ausfuehrer_grund()): + return {"ok": False, "detail": grund} + with updates.START_SPERRE: + if laeuft(): + return {"ok": False, "detail": updates.SAMMELLAUF_SPERRE} + if laufende := [x.get("name") or x.get("ziel") for x in updates.laeufe(100) if x.get("status") == "laeuft"]: + return {"ok": False, "detail": f"Gerade läuft ein einzelnes Update ({_aufzaehlung(laufende)}). " + "„Alle aktualisieren“ geht, sobald es fertig ist."} + sl = {"id": uuid.uuid4().hex[:12], "status": "laeuft", "start": time.time(), "ende": None, "aktuell": None, + "schritte": [{"ziel": s["ziel"], "name": s["name"], "baustein": s["baustein"], "was": s["was"], + "status": "wartet", "lauf": None, "text": None} for s in schritte], + "text": None, "meldung": None, "dringend": False} + _merken(sl) + antwort = oeffentlich(sl) # eine Kopie: Der Faden ändert sl gleich weiter + threading.Thread(target=_ablauf, args=(sl,), name="homelab-alle", daemon=True).start() + log.info("homelab: „Alle aktualisieren“ %s mit %d Schritten gestartet", sl["id"], len(schritte)) + return {"ok": True, "sammellauf": antwort} + + +# --- Ablauf ----------------------------------------------------------------------------------------- + +def _warten(lauf_id: str) -> dict | None: + """Bis der Lauf fertig ist; None, wenn er sich in WARTEN_MAX_S nicht zurückmeldet.""" + ende = time.time() + WARTEN_MAX_S + while time.time() < ende: + lauf = updates.lauf_lesen(lauf_id) + if lauf is not None and lauf.get("status") != "laeuft": + return lauf + time.sleep(TAKT_S) + return None + + +def _schritt(sl: dict, schritt: dict) -> None: + """Einen Schritt gehen: den Lauf anstoßen, auf ihn warten, sein Ergebnis übernehmen.""" + if schritt["baustein"] == "docker" and not arcane.echt(): + schritt.update(status="uebersprungen", text="Docker-Updates über Arcane stehen inzwischen auf Probelauf.") + return + antwort = updates.starten(schritt["ziel"], schritt["baustein"], sammellauf={"id": sl["id"], "start": sl["start"]}) + if not antwort.get("ok"): + schritt.update(status="uebersprungen", text=str(antwort.get("detail") or "abgelehnt")) + return + schritt["lauf"] = antwort["lauf"] + _merken(sl) + lauf = _warten(antwort["lauf"]) + if lauf is None: + schritt.update(status="fehler", text=f"Der Lauf hat sich in {WARTEN_MAX_S // 3600} Stunden nicht " + "zurückgemeldet.") + return + schritt.update(status=STATUS.get(str(lauf.get("ergebnis")), "fehler"), text=lauf.get("text")) + + +def _ablauf(sl: dict) -> None: + try: + for i, schritt in enumerate(sl["schritte"]): + sl["aktuell"] = i + schritt["status"] = "laeuft" + _merken(sl) + _schritt(sl, schritt) + _merken(sl) + if schritt["status"] in ("zurueckgerollt", "fehler"): + _abbrechen(sl, i) + return + _abschliessen(sl) + except Exception as exc: + # Sonst hieße der Sammellauf bis zum nächsten Neustart „läuft“, und jeder einzelne Knopf wäre gesperrt. + log.exception("homelab: „Alle aktualisieren“ %s ist abgestürzt", sl.get("id")) + _beenden(sl, f"Interner Fehler: {exc.__class__.__name__}.", + f"„Alle aktualisieren“ ist mit einem internen Fehler stehen geblieben ({exc.__class__.__name__}).") + + +def _abschliessen(sl: dict) -> None: + eingespielt = [s for s in sl["schritte"] if s["status"] == "eingespielt"] + uebersprungen = [s for s in sl["schritte"] if s["status"] == "uebersprungen"] + anzahl = len(eingespielt) + if anzahl: + text = f"{anzahl} {'Update' if anzahl == 1 else 'Updates'} eingespielt, alle Prüfungen grün." + else: + text = "Kein Update eingespielt." + if uebersprungen: + text += " Nicht gelaufen: " + "; ".join(f"{s['name']} ({str(s.get('text') or 'abgelehnt').rstrip('.')})" + for s in uebersprungen) + "." + sl.update(status="fertig", ende=time.time(), aktuell=None, text=text, meldung=f"Homelab: {text}", dringend=False) + _merken(sl) + log.info("homelab: „Alle aktualisieren“ %s fertig: %s", sl["id"], text) + _melden(sl["meldung"]) + + +def _beenden(sl: dict, text: str, anlass: str) -> None: + """Abbrechen: offene Schritte „uebersprungen“, Status „abgebrochen“, dringende Meldung.""" + rest = [s for s in sl["schritte"] if s["status"] in ("wartet", "laeuft")] + for s in rest: + s.update(status="uebersprungen", text="Nicht mehr gelaufen: „Alle aktualisieren“ wurde abgebrochen.") + eingespielt = [s["name"] for s in sl["schritte"] if s["status"] == "eingespielt"] + meldung = f"Homelab: {anlass}" + if eingespielt: + meldung += f" Vorher eingespielt: {_aufzaehlung(eingespielt)}." + if rest: + meldung += f" Nicht mehr gelaufen: {_aufzaehlung([s['name'] for s in rest])}." + sl.update(status="abgebrochen", ende=time.time(), aktuell=None, text=text, meldung=meldung, dringend=True) + _merken(sl) + log.warning("homelab: „Alle aktualisieren“ %s abgebrochen: %s", sl["id"], text) + _melden(meldung, dringend=True) + + +def _abbrechen(sl: dict, i: int) -> None: + schritt = sl["schritte"][i] + wie = "zurückgerollt" if schritt["status"] == "zurueckgerollt" else "gescheitert" + _beenden(sl, f"Abgebrochen bei {schritt['name']}: Update {wie}.", + f"„Alle aktualisieren“ abgebrochen bei {schritt['name']} (Update {wie}).") + + +def unterbrochene_abschliessen() -> None: + """Beim Start (über updates.unterbrochene_abschliessen): Ein Sammellauf, der noch „läuft“, gehörte zum vorigen + Prozess. Er gilt als abgebrochen; die Meldung geht nebenher raus, damit der Start nicht auf Telegram wartet.""" + for sl in _alle(): + if sl.get("status") != "laeuft": + continue + bei = None + for s in sl["schritte"]: + if s.get("status") != "laeuft": + continue + bei = s["name"] + # War sein Lauf noch fertig geworden, zählt dessen Ergebnis; sonst ist offen, was am Gerät geschah. + lauf = updates.lauf_lesen(s["lauf"]) if s.get("lauf") else None + if lauf is not None and lauf.get("status") == "fertig" and lauf.get("ergebnis") in STATUS: + s.update(status=STATUS[lauf["ergebnis"]], text=lauf.get("text")) + else: + s.update(status="fehler", text=UNTERBROCHEN) + rest = [s for s in sl["schritte"] if s.get("status") == "wartet"] + for s in rest: + s.update(status="uebersprungen", text="Nicht mehr gelaufen: „Alle aktualisieren“ wurde abgebrochen.") + eingespielt = [s["name"] for s in sl["schritte"] if s.get("status") == "eingespielt"] + meldung = ("Homelab: „Alle aktualisieren“ wurde unterbrochen: Der Homelab-Teil ist mitten im Lauf neu " + "gestartet" + (f" (bei {bei})" if bei else "") + ".") + if eingespielt: + meldung += f" Vorher eingespielt: {_aufzaehlung(eingespielt)}." + if rest: + meldung += f" Nicht mehr gelaufen: {_aufzaehlung([s['name'] for s in rest])}." + meldung += " Bitte den Stand in der Übersicht prüfen." + sl.update(status="abgebrochen", ende=time.time(), aktuell=None, text=UNTERBROCHEN, meldung=meldung, + dringend=True) + _merken(sl) + log.warning("homelab: „Alle aktualisieren“ %s war beim Start noch offen → abgebrochen", sl["id"]) + threading.Thread(target=_melden, args=(meldung, True), name="homelab-alle-meldung", daemon=True).start() diff --git a/backend/services/homelab/updates.py b/backend/services/homelab/updates.py index 89c3d64..a465e52 100644 --- a/backend/services/homelab/updates.py +++ b/backend/services/homelab/updates.py @@ -15,6 +15,10 @@ Ablauf für einen Gast: Grün → ältere Snapshots bzw. Sicherungen des Orchestrators für diesen Gast weg → Meldung „eingespielt“. Jeder Lauf steht in /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. + +„Alle aktualisieren“ (sammellauf.py, seit 24.09.2026) stößt dieselben Läufe nacheinander an. Solange es läuft, +lehnt der einzelne Knopf ab (SAMMELLAUF_SPERRE), und die Erfolgsmeldung eines Schritts entfällt — die Sammelmeldung +am Ende sagt es; Fehler werden weiter sofort gemeldet. """ import json @@ -39,8 +43,13 @@ ZEITLIMIT_UPDATE_S = 45 * 60 ZEITLIMIT_KURZ_S = 15 * 60 ZEITLIMIT_SICHERUNG_S = 40 * 60 # Sicherung und Zurückspielen (der Ausführer gibt nach 30 min auf) BETREFF = "[Homelab-Update]" +BETREFF_ALARM = "[Alarm] Homelab-Update" +SAMMELLAUF_SPERRE = "Es läuft gerade ‚Alle aktualisieren‘." _lock = threading.RLock() # _merken liest und schreibt unter derselben Sperre +# Prüfen und Anlegen eines Laufs am Stück — auch „Alle aktualisieren“ legt sich unter ihr an. Sonst könnte ein +# einzelner Knopf genau zwischen Prüfung und Anlegen des Sammellaufs durchrutschen. +START_SPERRE = threading.Lock() def _pfad() -> Path: @@ -69,6 +78,10 @@ def laeufe(anzahl: int = 20) -> list[dict]: return list(reversed(_laeufe()))[:anzahl] +def lauf_lesen(lauf_id: str) -> dict | None: + return next((x for x in _laeufe() if x.get("id") == lauf_id), None) + + def laufende_ziele() -> set[str]: """Ziele mit einem laufenden Update. Liest nur die Datei — geht also auch im Steward (Wächter, Pflege).""" return {str(x.get("ziel")) for x in _laeufe() if x.get("status") == "laeuft"} @@ -79,17 +92,25 @@ def laeuft(ziel_id: str) -> bool: def unterbrochene_abschliessen() -> None: - """Beim Start: Läufe, die noch „läuft“ heißen, gehörten zum vorigen Prozess.""" + """Beim Start: Läufe, die noch „läuft“ heißen, gehörten zum vorigen Prozess — ebenso ein laufendes + „Alle aktualisieren“ (danach, damit es den unterbrochenen Lauf schon als solchen sieht).""" 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) + from services.homelab import sammellauf # sammellauf importiert dieses Modul + sammellauf.unterbrochene_abschliessen() + + +def _sammellauf_laeuft() -> bool: + from services.homelab import sammellauf + return sammellauf.laeuft() def _melden(text: str, dringend: bool = False) -> None: try: - announce.notify_telegram("[Alarm] Homelab-Update" if dringend else BETREFF, text) + announce.notify_telegram(BETREFF_ALARM if dringend else BETREFF, text) except Exception: log.warning("homelab: Meldung ging nicht raus", exc_info=True) @@ -112,7 +133,7 @@ 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) + kanal.bericht_speichern(bericht, aus_auftrag=True) except (RuntimeError, ValueError): return None return next((g for g in bericht.get("gaeste") or [] if g.get("vmid") == vmid), None) @@ -174,7 +195,10 @@ def _gast_lauf(lauf: dict, gast: dict) -> None: try: _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) + nachher = _frischer_gast(lauf, vmid) + if baustein == "app" and nachher: + lauf["version_neu"] = (nachher.get("app") or {}).get("version") # fürs Protokoll + fehler = pruefen(gast, nachher, baustein) except RuntimeError as exc: fehler = [str(exc)] if not fehler: @@ -257,53 +281,73 @@ def _host_lauf(lauf: dict) -> None: def _ende(lauf: dict, ergebnis: str, text: str, dringend: bool = False) -> None: - lauf.update(status="fertig", ergebnis=ergebnis, text=text, ende=time.time()) + # Schritt von „Alle aktualisieren“: Den Erfolg sagt die Sammelmeldung am Ende; Fehler gehen sofort raus. + still = bool(lauf.get("sammellauf")) and ergebnis == "eingespielt" and not dringend + lauf.update(status="fertig", ergebnis=ergebnis, text=text, ende=time.time(), dringend=dringend, + gemeldet=not still) _merken(lauf) try: - start = datetime.fromtimestamp(lauf["start"], LOCAL_TZ).isoformat(timespec="seconds") - update_verlauf.eintragen(start, "Update von Hand", lauf["ziel"], ergebnis, text) + # Die Schritte eines Sammellaufs stehen im Update-Verlauf als EIN Lauf (gleicher Beginn, eigener Anlass). + start = datetime.fromtimestamp(lauf.get("sammellauf_start") or lauf["start"], LOCAL_TZ) + anlass = "Alle aktualisieren" if lauf.get("sammellauf") else "Update von Hand" + update_verlauf.eintragen(start.isoformat(timespec="seconds"), anlass, lauf["ziel"], ergebnis, text) except Exception: log.warning("homelab: Verlauf nicht geschrieben", exc_info=True) - _melden(text, dringend=dringend) + if not still: + _melden(text, dringend=dringend) -def _neuer_lauf(ziel_id: str, baustein: str, name: str) -> dict: +def _neuer_lauf(ziel_id: str, baustein: str, name: str, sammellauf: dict | None = None, **mehr: object) -> dict: 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": []} + "start": time.time(), "ende": None, "ergebnis": None, "text": None, "schritte": [], **mehr} + if sammellauf: + lauf.update(sammellauf=sammellauf["id"], sammellauf_start=sammellauf["start"]) _merken(lauf) return lauf -def starten(ziel_id: str, baustein: str) -> dict: - """Knopf „Jetzt updaten“. Rückgabe {"ok": True, "lauf": id} oder {"ok": False, "detail": …}.""" +def starten(ziel_id: str, baustein: str, sammellauf: dict | None = None) -> dict: + """Knopf „Jetzt updaten“. Rückgabe {"ok": True, "lauf": id} oder {"ok": False, "detail": …}. + + sammellauf ({"id", "start"}) setzt nur „Alle aktualisieren“ (sammellauf.py) für seine Schritte; ohne ihn lehnt + der Knopf ab, solange ein Sammellauf läuft.""" if baustein not in ("app", "os", "pakete", "neustart", "docker"): return {"ok": False, "detail": "Unbekannter Baustein."} - if laeuft(ziel_id): - 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 baustein == "docker": - app = apps.app_fuer(None, gast.get("name")) - basis = (apps.adresse(app, gast.get("ip")) or "").rstrip("/") - if not (app and app.kennung == "arcane" and basis and arcane.schluessel()): - return {"ok": False, "detail": "Docker-Updates gehen nur über Arcane mit API-Schlüssel."} - lauf = _neuer_lauf(ziel_id, baustein, app.name) - threading.Thread(target=_docker_lauf, args=(lauf, basis), name=f"homelab-{ziel_id}", daemon=True).start() - return {"ok": True, "lauf": lauf["id"]} - 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")) - if baustein == "app" and (not app or app.weg != "skript"): - return {"ok": False, "detail": f"{name} aktualisiert sich nicht über das Community-Script " - "(eigene Oberfläche oder Pakete)."} - if baustein == "app" and (sperre := karenz.pruefen(app.kennung)["gesperrt"]): - return {"ok": False, "detail": sperre} - lauf = _neuer_lauf(ziel_id, baustein, name) + with START_SPERRE: + if sammellauf is None and _sammellauf_laeuft(): + return {"ok": False, "detail": SAMMELLAUF_SPERRE} + if laeuft(ziel_id): + return {"ok": False, "detail": "Für dieses Gerät läuft schon ein Update."} + if ziel_id == "pve": + name, gast = "Proxmox-Host", None + host = ((kanal.bericht() or {}).get("bericht") or {}).get("host") or {} + mehr = {"pakete": len(host.get("updates") or [])} if baustein == "pakete" else {} + else: + gast = inventar.gast(ziel_id) + if not gast: + return {"ok": False, "detail": "Dieses Gerät steht nicht im Bericht des Ausführers."} + if baustein == "docker": + app = apps.app_fuer(None, gast.get("name")) + basis = (apps.adresse(app, gast.get("ip")) or "").rstrip("/") + if not (app and app.kennung == "arcane" and basis and arcane.schluessel()): + return {"ok": False, "detail": "Docker-Updates gehen nur über Arcane mit API-Schlüssel."} + lauf = _neuer_lauf(ziel_id, baustein, app.name, sammellauf) + threading.Thread(target=_docker_lauf, args=(lauf, basis), name=f"homelab-{ziel_id}", + daemon=True).start() + return {"ok": True, "lauf": lauf["id"]} + 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")) + if baustein == "app" and (not app or app.weg != "skript"): + return {"ok": False, "detail": f"{name} aktualisiert sich nicht über das Community-Script " + "(eigene Oberfläche oder Pakete)."} + if baustein == "app" and (sperre := karenz.pruefen(app.kennung)["gesperrt"]): + return {"ok": False, "detail": sperre} + # Was vorher war, fürs Protokoll: die App-Version bzw. die Zahl der Pakete. + mehr = ({"version_alt": (gast.get("app") or {}).get("version")} if baustein == "app" + else {"pakete": (gast.get("os_updates") or {}).get("anzahl")}) + lauf = _neuer_lauf(ziel_id, baustein, name, sammellauf, **mehr) 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"]} diff --git a/backend/services/waechter.py b/backend/services/waechter.py index b85264c..3581b47 100644 --- a/backend/services/waechter.py +++ b/backend/services/waechter.py @@ -628,8 +628,11 @@ def _speichere() -> None: log.warning("waechter: Stand %s nicht schreibbar", STORE_PATH, exc_info=True) -def _verlauf(art: str, hinweis_id: str, text: str) -> None: - _stand["verlauf"].append({"ts": time.time(), "art": art, "id": hinweis_id, "text": text}) +def _verlauf(art: str, hinweis_id: str, text: str, stufe: str | None = None) -> None: + eintrag = {"ts": time.time(), "art": art, "id": hinweis_id, "text": text} + if stufe: # seit 24.09.2026: das Protokoll des Homelab-Teils zeigt, wie ernst ein Hinweis war + eintrag["stufe"] = stufe + _stand["verlauf"].append(eintrag) del _stand["verlauf"][:-VERLAUF_MAX] @@ -700,7 +703,7 @@ def takt() -> None: aktionen=b.aktionen, zuletzt=jetzt, takte=k["takte"]) hinweise[b.id] = h if neu: - _verlauf("neu", b.id, b.titel) + _verlauf("neu", b.id, b.titel, b.stufe) if b.stufe == "rot" and (neu or jetzt - h.get("gemeldet", 0.0) >= REMIND_S): vorsatz = "" if neu else "Immer noch: " _telegram(BETREFF_PROBLEM, f"{vorsatz}{b.titel}. {b.text}") @@ -714,7 +717,7 @@ def takt() -> None: if update and hid.startswith(("dienst:", "kern:")): continue h = hinweise.pop(hid) - _verlauf("erledigt", hid, h.get("titel", hid)) + _verlauf("erledigt", hid, h.get("titel", hid), h.get("stufe")) if h.get("stufe") == "rot" and h.get("gemeldet"): _telegram(BETREFF_OK, f"Erledigt: {h.get('titel', hid)}.") for kid in list(kandidaten): diff --git a/backend/tests/test_homelab.py b/backend/tests/test_homelab.py index 3b67e73..4ae360f 100644 --- a/backend/tests/test_homelab.py +++ b/backend/tests/test_homelab.py @@ -119,7 +119,7 @@ def test_ziele_aus_dem_echten_bericht(daten): assert z["vm-106"]["name"] == "Arcane (Docker)" # Arcane läuft über seine eigene Schnittstelle: ohne Schlüssel sagt die Übersicht das ehrlich. assert (arcane["arcane"]["zustand"], arcane["arcane"]["kurz"]) == ("neu", "2.12.0 → 2.13.1") - assert arcane["docker"]["zustand"] == "unbekannt" and "MC_ARCANE_KEY" in arcane["docker"]["grund"] + assert arcane["docker"]["zustand"] == "unbekannt" and "Einstellungen" in arcane["docker"]["grund"] def test_ohne_bericht_leer_und_ehrlich(daten): diff --git a/backend/tests/test_homelab_einstellungen.py b/backend/tests/test_homelab_einstellungen.py new file mode 100644 index 0000000..66e67fb --- /dev/null +++ b/backend/tests/test_homelab_einstellungen.py @@ -0,0 +1,260 @@ +"""Einstellungen des Homelab-Teils (services/homelab/einstellungen.py, 24.09.2026): den Arcane-Schlüssel über die +Oberfläche eintragen (erst von Arcane geprüft, nie zurückgegeben) und echte Docker-Updates statt Probelauf. Arcane ist +ein kleiner Nachbau mit derselben Antwortform wie v2.12/2.13 (401 bei falschem Schlüssel).""" + +import json +import logging +import os +import subprocess +import sys +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +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 arcane, einstellungen, inventar, kanal + +BERICHT = json.loads((Path(__file__).parent / "fixtures" / "pve-bericht.json").read_text(encoding="utf-8")) +BACKEND = Path(__file__).resolve().parents[1] +RICHTIG = "arc_RichtigerSchluessel_0123456789abcdef" +FALSCH = "arc_FalscherSchluessel_9876543210fedcba" +ARCANE_URL_AUS_DEM_BERICHT = einstellungen.arcane_url # das Original; die Tests lenken auf den Nachbau um + + +@pytest.fixture +def arcane_nachbau(): + """Arcane mit einer Umgebung und zwei Images, eins davon mit neuerem Stand.""" + stand: dict = {"schluessel": RICHTIG, "status": None, "aufrufe": []} + + class Handler(BaseHTTPRequestHandler): + def _antwort(self, code: int, daten: dict) -> None: + roh = json.dumps(daten).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(roh))) + self.end_headers() + self.wfile.write(roh) + + def do_GET(self): + stand["aufrufe"].append(self.path) + if stand["status"]: + return self._antwort(stand["status"], {"title": "Fehler", "status": stand["status"]}) + if self.headers.get("X-API-Key") != stand["schluessel"]: + return self._antwort(401, {"title": "Unauthorized", "status": 401, + "detail": "Unauthorized: invalid API key"}) + if self.path.startswith("/api/environments?"): + return self._antwort(200, {"success": True, "data": [{"id": "0", "name": "local"}], + "pagination": {"totalItems": 1}}) + if self.path.startswith("/api/environments/0/images"): + return self._antwort(200, {"success": True, "pagination": {"totalItems": 2}, "data": [ + {"id": "a", "repo": "ghcr.io/tobi/nerdquiz", "tag": "latest", "usedBy": [{"name": "nerdquiz"}], + "updateInfo": {"hasUpdate": True, "currentVersion": "1.0", "latestVersion": "1.1"}}, + {"id": "b", "repo": "nginx", "tag": "1.27", "usedBy": [], + "updateInfo": {"hasUpdate": False}}]}) + return self._antwort(404, {"title": "Not Found", "status": 404}) + + def log_message(self, *args): + pass + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + yield f"http://127.0.0.1:{server.server_port}", stand + server.shutdown() + + +@pytest.fixture +def daten(tmp_path, monkeypatch, arcane_nachbau): + monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path)) + for name in ("MC_AUSFUEHRER_TOKEN", "MC_ARCANE_KEY", "MC_ARCANE_ECHT", "MC_HOMELAB_KARENZ_H"): + monkeypatch.delenv(name, raising=False) + einstellungen_mod.einstellungen.cache_clear() + monkeypatch.setattr(kanal, "_kontakt", {"zuletzt": None}) + arcane.cache_leeren() + kanal.bericht_speichern(BERICHT) + url, _ = arcane_nachbau + monkeypatch.setattr(einstellungen, "arcane_url", lambda: url) # die VM 106 zeigt hier auf den Nachbau + yield tmp_path + arcane.cache_leeren() + einstellungen_mod.einstellungen.cache_clear() + + +def _client() -> TestClient: + from routers import homelab as homelab_router + app = FastAPI() + app.include_router(homelab_router.router) + return TestClient(app) + + +def test_stand_von_anfang_an(daten, monkeypatch): + monkeypatch.setattr(einstellungen, "arcane_url", lambda: None) + assert einstellungen.stand()["arcane"]["url"] == "" + monkeypatch.setattr(einstellungen, "arcane_url", ARCANE_URL_AUS_DEM_BERICHT) # die VM 106 aus dem Bericht + stand = _client().get("/api/homelab/einstellungen").json() + assert stand == { + "arcane": {"url": "http://192.168.178.28:3552", "schluessel": "fehlt", "echt": False, + "echt_quelle": "standard"}, + "karenz_h": 48, + "ausfuehrer": {"verbunden": True, "zuletzt": stand["ausfuehrer"]["zuletzt"], "nur_lesen": False, + "sicherung_speicher": None}} + bericht = json.loads(json.dumps(BERICHT)) + bericht["host"].update(nur_lesen=True, sicherung={"speicher": "local", "art": "dir", "frei": 1}) + kanal.bericht_speichern(bericht) + monkeypatch.setenv("MC_HOMELAB_KARENZ_H", "72") + stand = einstellungen.stand() + assert stand["ausfuehrer"]["nur_lesen"] is True and stand["ausfuehrer"]["sicherung_speicher"] == "local" + assert stand["karenz_h"] == 72 + + +def test_richtiger_schluessel_wird_gespeichert_und_gilt_sofort(daten, arcane_nachbau, caplog): + url, stand = arcane_nachbau + caplog.set_level(logging.DEBUG) + antwort = _client().post("/api/homelab/einstellungen/arcane-schluessel", json={"schluessel": f" {RICHTIG}\n"}) + assert antwort.status_code == 200 + assert antwort.json() == {"ok": True, "text": "Arcane nimmt den Schlüssel an: 2 Images in 1 Umgebung geprüft, 1 " + "davon mit neuerem Stand. Die Übersicht zeigt die Docker-Images ab " + "sofort."} + assert stand["aufrufe"] == ["/api/environments?limit=50", "/api/environments/0/images?limit=500"] + datei = daten / "arcane.key" + assert datei.read_text(encoding="utf-8") == RICHTIG + "\n" and arcane.schluessel() == RICHTIG + if os.name == "posix": + assert datei.stat().st_mode & 0o077 == 0 + assert _client().get("/api/homelab/einstellungen").json()["arcane"]["schluessel"] == "hinterlegt" + # Ohne Neustart: Die Übersicht fragt Arcane mit dem neuen Schlüssel und sieht die Images. + _, docker = inventar._arcane_bausteine(url, "vm-106") + assert (docker.zustand, docker.kurz, docker.aktion.label) == ("neu", "1 Images", "Probelauf") + assert RICHTIG not in caplog.text + + +def test_falscher_schluessel_wird_nicht_gespeichert(daten, caplog): + caplog.set_level(logging.DEBUG) + antwort = _client().post("/api/homelab/einstellungen/arcane-schluessel", json={"schluessel": FALSCH}) + assert antwort.status_code == 200 + assert antwort.json() == {"ok": False, "detail": "Arcane lehnt den Schlüssel ab (HTTP 401)."} + assert not (daten / "arcane.key").exists() and arcane.schluessel() == "" + assert FALSCH not in antwort.text and FALSCH not in caplog.text + + +def test_ohne_rechte_und_bei_anderen_fehlern_nichts_gespeichert(daten, arcane_nachbau): + _, stand = arcane_nachbau + stand["status"] = 403 + assert einstellungen.arcane_schluessel_setzen(RICHTIG)["detail"] == ( + "Arcane lehnt den Schlüssel ab (HTTP 403). Ihm fehlt das Recht, Umgebungen und Images zu lesen.") + stand["status"] = 500 + assert einstellungen.arcane_schluessel_setzen(RICHTIG)["detail"] == ( + "Arcane antwortet mit HTTP 500. Der Schlüssel ist nicht gespeichert.") + assert not (daten / "arcane.key").exists() + + +def test_arcane_nicht_erreichbar(daten, monkeypatch): + frei = ThreadingHTTPServer(("127.0.0.1", 0), BaseHTTPRequestHandler) + tot = f"http://127.0.0.1:{frei.server_port}" + frei.server_close() # der Port ist jetzt zu + monkeypatch.setattr(einstellungen, "arcane_url", lambda: tot) + ergebnis = einstellungen.arcane_schluessel_setzen(RICHTIG) + assert ergebnis == {"ok": False, "detail": f"Arcane ist nicht erreichbar ({tot}). Der Schlüssel ist nicht " + "gespeichert."} + monkeypatch.setattr(einstellungen, "arcane_url", lambda: None) + assert einstellungen.arcane_schluessel_setzen(RICHTIG)["detail"].startswith("Arcane steht nicht im Bericht") + assert not (daten / "arcane.key").exists() + + +@pytest.mark.parametrize("body", [b'"arc_nur_ein_text_0123456789"', b'{"schluessel": 12345678901}', + b'{"schluessel": ["arc_in_einer_liste_0123456789"]}', b"kein json arc_roh_0123456789", + b'{"schluessel": "arc mit leerzeichen 0123456789"}', b'{"schluessel": "kurz"}', b""]) +def test_falsche_form_wiederholt_nichts(daten, arcane_nachbau, body): + _, stand = arcane_nachbau + antwort = _client().post("/api/homelab/einstellungen/arcane-schluessel", content=body, + headers={"Content-Type": "application/json"}) + assert antwort.status_code == 200 and antwort.json()["ok"] is False + assert "arc_" not in antwort.text and "12345678901" not in antwort.text and "leerzeichen" not in antwort.text + assert stand["aufrufe"] == [] and not (daten / "arcane.key").exists() + + +def test_loeschen(daten): + assert einstellungen.arcane_schluessel_setzen(RICHTIG)["ok"] + antwort = _client().delete("/api/homelab/einstellungen/arcane-schluessel") + assert antwort.json() == {"ok": True, "text": "Der Arcane-Schlüssel ist gelöscht."} + assert not (daten / "arcane.key").exists() and arcane.schluessel_quelle() == "fehlt" + assert _client().delete("/api/homelab/einstellungen/arcane-schluessel").json()["text"] == ( + "Es war kein Arcane-Schlüssel hinterlegt.") + docker = inventar._arcane_bausteine("http://127.0.0.1:9", "vm-106")[1] + assert docker.zustand == "unbekannt" and docker.grund.startswith("Es fehlt ein Arcane-API-Schlüssel") + + +def test_die_umgebung_geht_vor(daten, monkeypatch): + assert einstellungen.arcane_schluessel_setzen(RICHTIG)["ok"] + monkeypatch.setenv("MC_ARCANE_KEY", "arc_aus_der_umgebung_0123456789") + assert arcane.schluessel() == "arc_aus_der_umgebung_0123456789" and arcane.schluessel_quelle() == "umgebung" + assert einstellungen.stand()["arcane"]["schluessel"] == "umgebung" + assert einstellungen.arcane_schluessel_setzen(FALSCH) == {"ok": False, "detail": einstellungen.UMGEBUNG_SCHLUESSEL} + geloescht = einstellungen.arcane_schluessel_loeschen() + assert geloescht["ok"] and "Die Umgebung setzt weiter einen (MC_ARCANE_KEY); der gilt." in geloescht["text"] + assert not (daten / "arcane.key").exists() + + +def test_echt_schalter(daten, monkeypatch): + c = _client() + antwort = c.post("/api/homelab/einstellungen/arcane-echt", json={"an": True}) + assert antwort.status_code == 200 and antwort.json()["ok"] is True + assert arcane.echt() is True + assert c.get("/api/homelab/einstellungen").json()["arcane"] | {"url": ""} == { + "url": "", "schluessel": "fehlt", "echt": True, "echt_quelle": "einstellung"} + assert json.loads((daten / "homelab-einstellungen.json").read_text(encoding="utf-8"))["arcane"] == {"echt": True} + assert c.post("/api/homelab/einstellungen/arcane-echt", json={"an": False}).json()["ok"] is True + assert arcane.echt() is False and arcane.echt_quelle() == "einstellung" + # Setzt die Umgebung MC_ARCANE_ECHT, gewinnt sie; der Schalter sagt es und ändert nichts. + monkeypatch.setenv("MC_ARCANE_ECHT", "1") + abgelehnt = c.post("/api/homelab/einstellungen/arcane-echt", json={"an": False}).json() + assert abgelehnt["ok"] is False and "MC_ARCANE_ECHT" in abgelehnt["detail"] and "echt" in abgelehnt["detail"] + assert arcane.echt() is True and arcane.echt_quelle() == "umgebung" + monkeypatch.setenv("MC_ARCANE_ECHT", "0") + assert arcane.echt() is False and einstellungen.stand()["arcane"]["echt_quelle"] == "umgebung" + assert c.post("/api/homelab/einstellungen/arcane-echt", json={"an": "vielleicht"}).status_code == 422 + + +def test_echt_schalter_macht_aus_dem_probelauf_ein_update(daten, arcane_nachbau): + url, _ = arcane_nachbau + assert einstellungen.arcane_schluessel_setzen(RICHTIG)["ok"] + assert inventar._arcane_bausteine(url, "vm-106")[1].aktion.label == "Probelauf" + assert einstellungen.arcane_echt_setzen(True)["ok"] + assert inventar._arcane_bausteine(url, "vm-106")[1].aktion.label == "Jetzt updaten" + + +# --- Herkunftsprüfung (Middleware der App, Rolle homelab) --------------------------------------------------------- + +_HERKUNFT = """ +import json +from fastapi.testclient import TestClient +import app +c = TestClient(app.app) +fremd = {"Origin": "https://boese.example"} +eigen = {"Origin": "http://testserver"} +ergebnis = { + "schluessel_fremd": c.post("/api/homelab/einstellungen/arcane-schluessel", json={"schluessel": "x"}, + headers=fremd).status_code, + "loeschen_fremd": c.delete("/api/homelab/einstellungen/arcane-schluessel", headers=fremd).status_code, + "echt_fremd": c.post("/api/homelab/einstellungen/arcane-echt", json={"an": True}, headers=fremd).status_code, + "alle_fremd": c.post("/api/homelab/alle", headers=fremd).status_code, + "schluessel_eigen": c.post("/api/homelab/einstellungen/arcane-schluessel", json={"schluessel": "x"}, + headers=eigen).json(), + "lesen_fremd": c.get("/api/homelab/einstellungen", headers=fremd).status_code, +} +print(json.dumps(ergebnis)) +""" + + +def test_herkunftspruefung_gilt_auch_hier(tmp_path): + env = {**os.environ, "MC_ROLLE": "homelab", "MC_MODELS_DIR": str(tmp_path), "MC_DATEN_DIR": str(tmp_path), + "MC_PARTNER_URL": ""} + env.pop("MC_ARCANE_KEY", None) + lauf = subprocess.run([sys.executable, "-c", _HERKUNFT], cwd=BACKEND, env=env, capture_output=True, text=True, + timeout=120) + assert lauf.returncode == 0, lauf.stderr[-2000:] + ergebnis = json.loads(lauf.stdout.strip().splitlines()[-1]) + assert [ergebnis[k] for k in ("schluessel_fremd", "loeschen_fremd", "echt_fremd", "alle_fremd")] == [403] * 4 + assert ergebnis["schluessel_eigen"]["ok"] is False # durchgelassen; nur zu kurz + assert ergebnis["lesen_fremd"] == 200 + assert not (tmp_path / "arcane.key").exists() and not (tmp_path / "homelab-einstellungen.json").exists() diff --git a/backend/tests/test_homelab_protokoll.py b/backend/tests/test_homelab_protokoll.py new file mode 100644 index 0000000..feabedc --- /dev/null +++ b/backend/tests/test_homelab_protokoll.py @@ -0,0 +1,335 @@ +"""Protokoll des Homelab-Teils (services/homelab/protokoll.py, 24.09.2026): alle Quellen, sprechende deutsche Titel, +neueste zuerst — und keine Geheimnisse. Dazu die längere Aufbewahrung im Kanal und die Ereignisse der Pflege.""" + +import json +from collections import Counter +from datetime import datetime +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from kern import einstellungen as einstellungen_mod +from kern.zeit import LOCAL_TZ +from services import waechter +from services.homelab import kanal, pflege, protokoll, sammellauf, updates + +BERICHT = json.loads((Path(__file__).parent / "fixtures" / "pve-bericht.json").read_text(encoding="utf-8")) +T = datetime(2026, 9, 24, 20, 0, tzinfo=LOCAL_TZ).timestamp() +ARCANE_KEY = "arc_Zz9TestSchluesselNurFuerDenTest" +AUSFUEHRER_TOKEN = "kanal-token-nur-fuer-den-test-0123456789" +GEHEIM = [ARCANE_KEY, AUSFUEHRER_TOKEN, "abcdefghijklmnop123", "geheim123", "AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw", + "hunter2hunter", "supergeheim123456", "ghp_0123456789abcdefghijABCDEFGHIJ"] +GEHEIME_AUSGABE = ( + "\x1b[H\x1b[J\x1b[3J _______ __\nDownloading 10%\rDownloading 100%\n" + "Authorization: Bearer abcdefghijklmnop123\ncurl https://tobi:geheim123@example.org/x\n" + "TELEGRAM_BOT_TOKEN=123456789:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw\n" + "POST https://api.telegram.org/bot123456789:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw/sendMessage\n" + f"X-API-Key: {ARCANE_KEY}\nX-MC2-Ausfuehrer: {AUSFUEHRER_TOKEN}\npassword=hunter2hunter\n" + "git clone https://ghp_0123456789abcdefghijABCDEFGHIJ@github.com/x/y\n" + "E: Sub-process /usr/bin/dpkg returned an error code (1)") + + +def _zeit(ts: float) -> str: + return datetime.fromtimestamp(ts, LOCAL_TZ).strftime("%Y-%m-%d %H:%M:%S") + + +def _auftrag(aid: str, aktion: str, vmid: int | None, status: str, code: int | None, text: str | None, + ab: float, dauer: float = 3) -> dict: + return {"id": aid, "aktion": aktion, "parameter": {} if vmid is None else {"vmid": vmid}, "status": status, + "erstellt": ab, "abgeholt": None if status == "wartet" else ab + 1, + "fertig": ab + 1 + dauer if status not in ("wartet", "laeuft") else None, "code": code, "text": text} + + +def _lauf(lid: str, ziel: str, baustein: str, name: str, ende: float, **mehr) -> dict: + return {"id": lid, "ziel": ziel, "baustein": baustein, "name": name, "status": "fertig", "start": ende - 60, + "ende": ende, "ergebnis": None, "text": None, "schritte": ["snapshot: ok", "update: ok"], **mehr} + + +@pytest.fixture +def daten(tmp_path, monkeypatch): + monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path)) + monkeypatch.setenv("MC_NOTIFY_LOG", str(tmp_path / "notify.log")) + for name in ("MC_AUSFUEHRER_TOKEN", "MC_ARCANE_KEY", "MC_ARCANE_ECHT", "MC_WAECHTER_STORE"): + monkeypatch.delenv(name, raising=False) + einstellungen_mod.einstellungen.cache_clear() + monkeypatch.setattr(kanal, "_kontakt", {"zuletzt": None}) + kanal.bericht_speichern(BERICHT) + (tmp_path / "arcane.key").write_text(ARCANE_KEY + "\n", encoding="utf-8") + (tmp_path / "ausfuehrer.token").write_text(AUSFUEHRER_TOKEN + "\n", encoding="utf-8") + yield tmp_path + einstellungen_mod.einstellungen.cache_clear() + + +@pytest.fixture +def geschichte(daten): + """Ein Abend im Homelab, aus allen Quellen.""" + kanal._json_schreiben(kanal._pfad(), [ + _auftrag("a1", "suchen", 104, "fertig", 0, "Hit:1 http://deb.debian.org/debian trixie InRelease\n", T), + _auftrag("a2", "snapshot", 104, "fertig", 0, "snapshot=mc2-20260924-192352\nfreeze guest filesystem", T + 10), + _auftrag("a3", "update", 104, "fehler", 1, GEHEIME_AUSGABE, T + 20, dauer=60), + _auftrag("a4", "bericht", None, "fertig", 0, json.dumps(BERICHT), T + 90), + _auftrag("a5", "os_update", 105, "fehler", 2, "Abgelehnt: Der Ausführer läuft im Nur-Lesen-Modus.", T + 100), + _auftrag("a6", "host_update", None, "verloren", None, "Der Ausführer hat nie geantwortet.", T + 110), + _auftrag("a8", "sichern", 105, "fertig", 0, + "INFO: fertig\nsicherung=local:backup/vzdump-lxc-105-2026_09_24-22_10_00.tar.zst", T + 130), + _auftrag("a9", "os_update", 101, "fertig", 0, "\n".join(f"Zeile {i}: " + "x" * 90 for i in range(200)), + T + 140), + _auftrag("a7", "suchen", 100, "wartet", None, None, T + 150), + ]) + for lauf in ( + _lauf("l1", "ct-104", "app", "Gitea", T + 320, ergebnis="eingespielt", text="Gitea: eingespielt, Prüfung grün.", + version_alt="1.27.2", version_neu="1.27.3", dringend=False, gemeldet=True), + _lauf("l2", "ct-105", "os", "Proxmox Backup Server", T + 400, ergebnis="zurueckgerollt", dringend=True, + text="Proxmox Backup Server: Update gescheitert (Die Weboberfläche antwortet nicht.) — automatisch die " + "Sicherung von vorher zurückgespielt (vzdump-lxc-105.tar.zst). Läuft wieder wie vorher."), + _lauf("l3", "ct-102", "app", "NetBird", T + 500, ergebnis="fehler", dringend=False, + text="NetBird: Update nicht begonnen — snapshot gescheitert: kaputt. Am Gerät wurde nichts geändert."), + _lauf("l4", "pve", "pakete", "Proxmox-Host", T + 600, ergebnis="eingespielt", pakete=5, sammellauf="s1", + sammellauf_start=T + 550, dringend=False, gemeldet=False, + text="Proxmox-Host: Pakete eingespielt. Ein Neustart ist ein eigener Knopf."), + {**_lauf("l5", "ct-103", "os", "PVE Scripts Local", T + 650), "status": "unterbrochen"}, + ): + updates._merken(lauf) + sammellauf._merken({"id": "s1", "status": "fertig", "start": T + 550, "ende": T + 700, "aktuell": None, + "text": "1 Update eingespielt, alle Prüfungen grün.", "dringend": False, + "meldung": "Homelab: 1 Update eingespielt, alle Prüfungen grün.", + "schritte": [{"ziel": "pve", "name": "Proxmox-Host", "baustein": "pakete", "was": "5 Pakete", + "status": "eingespielt", "lauf": "l4", "text": "Proxmox-Host: Pakete eingespielt."}]}) + (daten / "mc2-waechter.json").write_text(json.dumps({ + "hinweise": { + "gast-platte:100": {"id": "gast-platte:100", "stufe": "gelb", "titel": "Platte von AdGuard Home zu 85 % voll", + "text": "1,7 von 2,0 GB belegt (Container 100).", "seit": T + 900}, + "ausfuehrer": {"id": "ausfuehrer", "stufe": "rot", "titel": "Der Ausführer auf dem Proxmox-Host schweigt", + "text": "Seit über 30 Minuten kein Bericht.", "seit": T + 950}}, + "verlauf": [ + {"ts": T + 800, "art": "neu", "id": "gast:ct-104", "text": "Gitea antwortet nicht", "stufe": "rot"}, + {"ts": T + 860, "art": "erledigt", "id": "gast:ct-104", "text": "Gitea antwortet nicht", "stufe": "rot"}, + {"ts": T + 900, "art": "neu", "id": "gast-platte:100", "text": "Platte von AdGuard Home zu 85 % voll"}]}), + encoding="utf-8") + (daten / "notify.log").write_text("\n".join([ + f"{_zeit(T + 320)} OK telegram direkt: Gitea: eingespielt, Prüfung grün.", + (f"{_zeit(T + 400)} OK telegram direkt: Proxmox Backup Server: Update gescheitert (Die Weboberfläche " + "antwortet nicht.) — automatisch die Sicherung von vorher zurückgespielt (vzdump-lxc-105.tar.zst). Läuft " + "wieder wie vorher."), + f"{_zeit(T + 700)} OK telegram direkt: Homelab: 1 Update eingespielt, alle Prüfungen grün.", + (f"{_zeit(T + 800)} OK telegram direkt: Gitea antwortet nicht. Die Weboberfläche antwortet nicht. (Diese " + "Meldung kam auch an Lucy.)"), + f"{_zeit(T + 860)} QUEUED für Morgen-Digest: Erledigt: Gitea antwortet nicht. (Diese Meldung kam auch an Lucy.)", + (f"{_zeit(T + 870)} FALLBACK (telegram fehlgeschlagen: ; Zweitweg: Telegram lehnt ab, HTTP-Fehler – Token " + "oder Chat falsch?): Testmeldung aus den Einstellungen (Homelab, 24.09. 22:10 Uhr, Kennung abc123). Kommt sie " + "an, funktioniert der Meldeweg."), + f"{_zeit(T + 880)} OK telegram direkt: Guten Morgen, Commander. Heute Nacht gab es 2 Meldungen:", + "- [Homelab-Update]: Gitea: eingespielt, Prüfung grün.", + "- [Homelab-Problem]: Gitea antwortet nicht.", + f"{_zeit(T + 890)} OK telegram direkt: Der Homelab-Teil läuft jetzt in Container 107. Nichts zu tun.", + f"{_zeit(T + 895)} OK telegram direkt: Neuer Zugang: api_key=supergeheim123456", + ]) + "\n", encoding="utf-8") + pflege._schreiben({"nacht": "2026-09-24", "gaeste": { + "104": {"name": "Gitea", "tag": "2026-09-24", "auftrag": None, "angestossen": T + 1000, "fehlschlaege": 0, + "fehler": None}, + "100": {"name": "AdGuard Home", "tag": "2026-09-24", "auftrag": None, "angestossen": T + 1010, + "fehlschlaege": 2, "fehler": "E: Failed to fetch"}}, + "ereignisse": [ + {"ts": T + 1010, "vmid": 100, "name": "AdGuard Home", "art": "angestossen", + "text": "Die Paketlisten sind vom 01.09.2026."}, + {"ts": T + 1020, "vmid": 100, "name": "AdGuard Home", "art": "gescheitert", + "text": "2. Fehlschlag in Folge: E: Failed to fetch"}]}) + return daten + + +def _nach_titel(eintraege: list[dict]) -> dict[str, dict]: + return {e["titel"]: e for e in eintraege} + + +def test_alle_quellen_mit_sprechenden_titeln(geschichte): + eintraege = protokoll.eintraege() + assert [e["zeit"] for e in eintraege] == sorted((e["zeit"] for e in eintraege), reverse=True) + assert all(set(e) == {"zeit", "art", "titel", "text", "stufe", "ziel", "details"} for e in eintraege) + assert {e["art"] for e in eintraege} == {"auftrag", "lauf", "sammellauf", "hinweis", "meldung", "pflege"} + assert {e["stufe"] for e in eintraege} <= {"ok", "info", "warn", "fehler"} + e = _nach_titel(eintraege) + + # Aufträge an den Ausführer: Aktion und Gerät im Titel, Status und Code im Text, Ausgabe als Einzelheiten. + suchen = e["Paketlisten erneuert · Gitea"] + assert (suchen["art"], suchen["stufe"], suchen["ziel"], suchen["text"]) == ("auftrag", "ok", "ct-104", + "nach 3 s, Code 0") + assert suchen["details"] == "Hit:1 http://deb.debian.org/debian trixie InRelease" + assert e["Snapshot angelegt · Gitea"]["text"] == "mc2-20260924-192352, nach 3 s, Code 0" + update = e["Update-Skript gescheitert · Gitea"] + assert update["stufe"] == "fehler" + assert update["text"] == "nach 1 min, Code 1: E: Sub-process /usr/bin/dpkg returned an error code (1)" + assert e["Pakete einspielen abgelehnt · Proxmox Backup Server"]["stufe"] == "warn" + host = e["Host-Pakete: keine Antwort · Proxmox-Host"] + assert (host["stufe"], host["ziel"]) == ("fehler", "pve") + assert e["Paketlisten werden erneuert · AdGuard Home"]["stufe"] == "info" + assert e["Sicherung angelegt · Proxmox Backup Server"]["text"].startswith("vzdump-lxc-105-2026_09_24-22_10_00") + lang = e["Pakete eingespielt · NPMplus"]["details"] + assert lang.startswith("…\nZeile ") and len(lang) <= protokoll.DETAILS_MAX and lang.endswith("Zeile 199: " + "x" * 90) + + # Update-Läufe mit ihren Schritten + assert e["Update eingespielt · Gitea 1.27.3"]["stufe"] == "ok" + assert e["Update eingespielt · Gitea 1.27.3"]["details"] == "snapshot: ok\nupdate: ok" + assert e["Paket-Update zurückgerollt · Proxmox Backup Server"]["stufe"] == "warn" + assert e["Update nicht begonnen · NetBird"]["stufe"] == "warn" + host_lauf = e["5 Host-Pakete eingespielt · Proxmox-Host"] + assert "Teil von „Alle aktualisieren“." in host_lauf["details"] and "Sammelmeldung" in host_lauf["details"] + assert e["Paket-Update unterbrochen · PVE Scripts Local"]["stufe"] == "fehler" + + # Sammellauf + sl = e["Alle aktualisieren fertig · 1 von 1 eingespielt"] + assert (sl["art"], sl["stufe"], sl["text"]) == ("sammellauf", "ok", "1 Update eingespielt, alle Prüfungen grün.") + assert sl["details"] == "Proxmox-Host (5 Pakete): eingespielt – Proxmox-Host: Pakete eingespielt." + + # Hinweise des Wächters: Verlauf und, was dort fehlt, aus den aktuellen Hinweisen + assert (e["Hinweis: Gitea antwortet nicht"]["stufe"], e["Hinweis: Gitea antwortet nicht"]["ziel"]) == ("fehler", + "ct-104") + assert e["Erledigt: Gitea antwortet nicht"]["stufe"] == "ok" + platte = e["Hinweis: Platte von AdGuard Home zu 85 % voll"] + assert (platte["stufe"], platte["ziel"], platte["text"]) == ("warn", "ct-100", + "1,7 von 2,0 GB belegt (Container 100).") + schweigt = e["Hinweis: Der Ausführer auf dem Proxmox-Host schweigt"] + assert (schweigt["stufe"], schweigt["ziel"], schweigt["zeit"]) == ("fehler", "pve", T + 950) + + # Meldungen: Zeit, Betreff, ob raus + assert [x["text"] for x in eintraege if x["titel"] == "Meldung raus · [Homelab-Update]"] == [ + "Homelab: 1 Update eingespielt, alle Prüfungen grün.", "Gitea: eingespielt, Prüfung grün."] + alarm = e["Meldung raus · [Alarm] Homelab-Update"] + assert alarm["zeit"] == T + 400 and alarm["details"] == "Direkt an die Telegram-Bot-API gesendet." + assert e["Meldung raus · [Homelab-Problem]"]["text"].startswith("Gitea antwortet nicht.") + assert e["Meldung zurückgelegt · [Homelab wieder ok]"]["stufe"] == "info" + test = e["Meldung ging nicht raus · [Test]"] + assert test["stufe"] == "fehler" and test["details"].startswith("Telegram ging nicht: telegram fehlgeschlagen: ; " + "Zweitweg: Telegram lehnt ab, HTTP-Fehler") + morgen = e["Meldung raus · [Morgenmeldung Homelab]"] + assert morgen["text"] == "Guten Morgen, Commander. Heute Nacht gab es 2 Meldungen:…" + assert morgen["details"].endswith("- [Homelab-Problem]: Gitea antwortet nicht.") + assert e["Meldung raus"]["text"] == "Der Homelab-Teil läuft jetzt in Container 107. Nichts zu tun." + meldungen = [x for x in eintraege if x["art"] == "meldung"] + assert len(meldungen) == 9 # die Folgezeilen der Morgenmeldung sind keine eigenen Meldungen + + # Pflege: Ereignisse, und aus älteren Ständen der letzte Anstoß + assert e["Wöchentliche Suche gescheitert · AdGuard Home"]["stufe"] == "warn" + gitea = e["Wöchentliche Suche angestoßen · Gitea"] + assert (gitea["zeit"], gitea["text"], gitea["ziel"]) == (T + 1000, "Die Paketlisten waren älter als 7 Tage.", + "ct-104") + angestossen = [x for x in eintraege if x["titel"] == "Wöchentliche Suche angestoßen · AdGuard Home"] + assert len(angestossen) == 1 and angestossen[0]["text"] == "Die Paketlisten sind vom 01.09.2026." + + +def test_keine_geheimnisse_und_keine_steuerzeichen(geschichte): + alles = json.dumps(protokoll.eintraege(berichte=True), ensure_ascii=False) + for geheim in GEHEIM: + assert geheim not in alles, geheim + assert "\x1b" not in alles and "\\u001b" not in alles and "\r" not in alles and "\\r" not in alles + details = _nach_titel(protokoll.eintraege())["Update-Skript gescheitert · Gitea"]["details"] + assert "Downloading 100%" in details and "Downloading 10%" not in details + assert "Authorization: ***" in details and "https://***@example.org/x" in details + assert "TELEGRAM_BOT_TOKEN=***" in details and "/bot***/sendMessage" in details + assert "X-API-Key: ***" in details and "X-MC2-Ausfuehrer: ***" in details and "password=***" in details + assert "Neuer Zugang: api_key=***" in alles + + +def test_schwaerzen_einzeln(daten): + assert protokoll.schwaerzen(f"Schlüssel {ARCANE_KEY} benutzt") == "Schlüssel *** benutzt" + assert protokoll.schwaerzen('{"api_key": "abc123def456"}') == '{"api_key": "***"}' + assert protokoll.schwaerzen("Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N") == \ + "Bearer ***" + harmlos = "snapshot=mc2-20260924-192352 sha256:7304d54948626355e11a855dbcde49f58468c71e4cbf43b5315d3a2f5fd01a58" + assert protokoll.schwaerzen(harmlos) == harmlos + assert protokoll.schwaerzen(None) is None + + +def test_berichte_nur_auf_wunsch_und_die_grenze(geschichte): + ohne = protokoll.eintraege() + assert not [x for x in ohne if x["titel"].startswith("Bericht")] + mit = _nach_titel(protokoll.eintraege(berichte=True)) + bericht = mit["Bericht geholt · Proxmox-Host"] + assert bericht["details"] == "Proxmox VE 9.2.11, 7 Gäste, davon 7 laufend, 5 Paket-Updates auf dem Host." + # Die Berichte, die der Ausführer von sich aus schickt (alle 10 min), sind keine Aufträge: kanal.py vermerkt sie. + eingang = mit["Bericht empfangen · Proxmox-Host"] + assert (eingang["art"], eingang["stufe"], eingang["ziel"]) == ("auftrag", "info", "pve") + assert eingang["text"] == "Proxmox VE 9.2.11, 7 Gäste, davon 7 laufend, 5 Paket-Updates auf dem Host." + kanal.bericht_speichern(BERICHT, aus_auftrag=True) # von einem Lauf geholt: steht schon als Auftrag da + assert len(kanal.berichte()) == 1 + drei = protokoll.eintraege(grenze=3) + assert drei == ohne[:3] and drei[0]["titel"] == "Wöchentliche Suche gescheitert · AdGuard Home" + assert len(protokoll.eintraege(grenze=0)) == 1 and len(protokoll.eintraege(grenze=10**6)) == len(ohne) + + +def test_ohne_daten_leer(daten): + (daten / "pve-bericht.json").unlink() + assert protokoll.eintraege() == [] + + +def test_schnittstelle(geschichte): + app = FastAPI() + from routers import homelab as homelab_router + app.include_router(homelab_router.router) + c = TestClient(app) + antwort = c.get("/api/homelab/protokoll?grenze=2&berichte=1") + assert antwort.status_code == 200 and len(antwort.json()["eintraege"]) == 2 + alle = c.get("/api/homelab/protokoll").json()["eintraege"] + assert len(alle) == len(protokoll.eintraege()) and not any(x["titel"].startswith("Bericht") for x in alle) + assert any(x["titel"].startswith("Bericht") for x in c.get("/api/homelab/protokoll?berichte=1").json()["eintraege"]) + + +# --- Kanal: längere Aufbewahrung -------------------------------------------------------------------------------- + +def test_kanal_behaelt_500_und_kuerzt_nur_alte_ausgaben(daten): + alt = [_auftrag(f"x{i:03d}", "suchen", 104, "fertig", 0, f"{i:03d}" + "y" * 4997, T + i) for i in range(520)] + kanal._json_schreiben(kanal._pfad(), alt) + neu = kanal.anlegen("bericht") + gespeichert = json.loads(kanal._pfad().read_text(encoding="utf-8")) + fertig = [a for a in gespeichert if a["status"] == "fertig"] + assert len(fertig) == kanal.BEHALTEN == 500 and fertig[0]["id"] == "x020" + assert [a["id"] for a in gespeichert if a["status"] == "wartet"] == [neu] + assert all(len(a["text"]) == kanal.TEXT_KURZ and a["text"].startswith("…") for a in fertig[:-kanal.VOLLTEXT]) + assert all(len(a["text"]) == 5000 for a in fertig[-kanal.VOLLTEXT:]) + + +def test_kanal_schreibt_beim_abholen_nur_bei_aenderung(daten, monkeypatch): + schreiben = Counter() + echt = kanal._json_schreiben + monkeypatch.setattr(kanal, "_json_schreiben", lambda pfad, d: (schreiben.update([pfad.name]), echt(pfad, d))) + assert kanal.naechster() is None and schreiben["ausfuehrer-auftraege.json"] == 0 + aid = kanal.anlegen("suchen", {"vmid": 104}) + assert kanal.naechster()["id"] == aid and schreiben["ausfuehrer-auftraege.json"] == 2 + assert kanal.naechster() is None and schreiben["ausfuehrer-auftraege.json"] == 2 + + +# --- Pflege: Ereignisse für das Protokoll ------------------------------------------------------------------------ + +NACHT = datetime(2026, 9, 25, 3, 0, tzinfo=LOCAL_TZ).timestamp() +NAECHSTE_NACHT = datetime(2026, 9, 26, 3, 0, tzinfo=LOCAL_TZ).timestamp() + + +def test_pflege_merkt_sich_anstoss_fehlschlag_und_erholung(daten, monkeypatch): + stand = {"zuletzt": NACHT} + monkeypatch.setattr(kanal, "zuletzt", lambda: stand["zuletzt"]) + monkeypatch.setattr(waechter, "TROCKEN", False) + + def antworten(scheitert: set[int]) -> None: + while (a := kanal.naechster()): + if a["parameter"]["vmid"] in scheitert: + kanal.ergebnis(a["id"], 100, "E: Failed to fetch http://security.debian.org") + else: + kanal.ergebnis(a["id"], 0, "Hit:1 http://deb.debian.org/debian bookworm InRelease") + + pflege.pruefe_paketlisten(jetzt=NACHT) + antworten({100}) + pflege.pruefe_paketlisten(jetzt=NACHT + 60) + stand["zuletzt"] = NAECHSTE_NACHT + pflege.pruefe_paketlisten(jetzt=NAECHSTE_NACHT) + antworten(set()) + pflege.pruefe_paketlisten(jetzt=NAECHSTE_NACHT + 60) + adguard = [(e["art"], e["text"]) for e in pflege.zustand()["ereignisse"] if e["vmid"] == 100] + assert adguard == [("angestossen", "Die Paketlisten sind vom 22.10.2025."), + ("gescheitert", "1. Fehlschlag in Folge: E: Failed to fetch http://security.debian.org"), + ("angestossen", "Die Paketlisten sind vom 22.10.2025."), + ("wieder_ok", "Die Paketlisten ließen sich wieder erneuern.")] + # Anstöße tragen die (hier gespielte) Nacht, Ergebnisse die echte Zeit des Auftrags: nur die Menge vergleichen. + titel = Counter(e["titel"] for e in protokoll.eintraege() if e["art"] == "pflege" and e["ziel"] == "ct-100") + assert titel == Counter({"Wöchentliche Suche angestoßen · AdGuard Home": 2, + "Wöchentliche Suche gescheitert · AdGuard Home": 1, + "Wöchentliche Suche klappt wieder · AdGuard Home": 1}) diff --git a/backend/tests/test_homelab_sammellauf.py b/backend/tests/test_homelab_sammellauf.py new file mode 100644 index 0000000..8606825 --- /dev/null +++ b/backend/tests/test_homelab_sammellauf.py @@ -0,0 +1,388 @@ +"""„Alle aktualisieren“ (services/homelab/sammellauf.py, 24.09.2026): Reihenfolge, Stopp bei Rot, Sperre, Neustart +mitten im Lauf und zurückgehaltene Einzelmeldungen. Mit einem nachgespielten Ausführer und dem echten Bericht vom +24.09. (tests/fixtures/pve-bericht.json); NPMplus und AdGuard haben hier zusätzlich Paket-Updates.""" + +import json +import threading +import time +from pathlib import Path +from types import SimpleNamespace + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from kern import einstellungen as einstellungen_mod +from services.homelab import inventar, kanal, karenz, sammellauf, updates + +BERICHT = json.loads((Path(__file__).parent / "fixtures" / "pve-bericht.json").read_text(encoding="utf-8")) +SPERRE = "Es läuft gerade ‚Alle aktualisieren‘." +NEU = {102: "0.79.0", 104: "1.27.3"} # NetBird und Gitea nach ihrem Update +ALLE = [("ct-102", "app"), ("ct-104", "app"), ("ct-105", "os"), ("ct-101", "os"), ("ct-100", "os"), ("pve", "pakete")] + + +def _bericht(versionen: dict[int, str] | None = None) -> dict: + """Der echte Bericht mit frischen Paketlisten: PBS 58, NPMplus 3 und AdGuard 2 Pakete.""" + neu = json.loads(json.dumps(BERICHT)) + jetzt = int(time.time()) + for g in neu["gaeste"]: + if g["vmid"] == 101: + g["os_updates"] = {"anzahl": 3, "listen_stand": jetzt} + elif g["vmid"] == 100: + g["os_updates"] = {"anzahl": 2, "listen_stand": jetzt} + elif g["vmid"] == 105: + g["os_updates"]["listen_stand"] = jetzt + if versionen and g["vmid"] in versionen: + g["app"]["version"] = versionen[g["vmid"]] + return neu + + +class Ausfuehrer: + """Nachgespielt: beantwortet Aufträge der Reihe nach. Nach „update“ meldet der nächste Bericht die neue + App-Version — außer für VMIDs in `unveraendert` (dann ist die Prüfung rot). `weiter` hält ihn an; + `fehlschlag` (Aktion, VMID) → (Code, Text).""" + + def __init__(self) -> None: + self.protokoll: list[tuple[str, int | None]] = [] + self.weiter, self.stopp = threading.Event(), threading.Event() + self.weiter.set() + self.unveraendert: set[int] = set() + self.aktualisiert: set[int] = set() + self.fehlschlag: dict[tuple[str, int | None], tuple[int, str]] = {} + threading.Thread(target=self._laufen, daemon=True).start() + + def _laufen(self) -> None: + while not self.stopp.is_set(): + if not self.weiter.is_set(): + time.sleep(0.01) + continue + a = kanal.naechster() + if not a: + time.sleep(0.01) + continue + vmid = a["parameter"].get("vmid") + self.protokoll.append((a["aktion"], vmid)) + code, text = self.fehlschlag.get((a["aktion"], vmid), (0, "")) + if code == 0 and a["aktion"] == "snapshot": + text = "snapshot=mc2-20260924-220000" + elif code == 0 and a["aktion"] == "update": + self.aktualisiert.add(vmid) + elif code == 0 and a["aktion"] == "bericht": + text = json.dumps(_bericht({v: NEU[v] for v in self.aktualisiert - self.unveraendert if v in NEU})) + kanal.ergebnis(a["id"], code, text) + + +@pytest.fixture +def homelab(tmp_path, monkeypatch): + monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path)) + for name in ("MC_AUSFUEHRER_TOKEN", "MC_ARCANE_KEY", "MC_ARCANE_ECHT"): + monkeypatch.delenv(name, raising=False) + einstellungen_mod.einstellungen.cache_clear() + monkeypatch.setattr(kanal, "_kontakt", {"zuletzt": None}) + neueste = {"netbirdio/netbird": "0.79.0", "go-gitea/gitea": "1.27.3", "AdguardTeam/AdGuardHome": "0.107.79", + "community-scripts/ProxmoxVE-Local": "1.2.1"} + roh = {"community-scripts/ProxmoxVE-Local": "untagged-80028680f0b29a379726"} + monkeypatch.setattr(inventar, "neueste_version", lambda repo: { + "tag": neueste.get(repo, "2026-07-24-r1"), "tag_roh": roh.get(repo, 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) + monkeypatch.setattr(karenz, "skript_geaendert", lambda kennung: 0.0) + monkeypatch.setattr(inventar.arcane, "app_version", + lambda url: {"installiert": "2.12.0", "neu": "2.13.1", "update": True}) + monkeypatch.setattr(updates, "WARTEN_NACH_UPDATE_S", 0) + echt_warten = kanal.warten + monkeypatch.setattr(kanal, "warten", lambda aid, zeitlimit_s, takt_s=2.0: echt_warten(aid, min(zeitlimit_s, 10), 0.02)) + monkeypatch.setattr(sammellauf, "TAKT_S", 0.02) + meldungen: list[tuple[str, bool]] = [] + + def melden(text: str, dringend: bool = False) -> None: + meldungen.append((text, dringend)) + + monkeypatch.setattr(updates, "_melden", melden) + monkeypatch.setattr(sammellauf, "_melden", melden) + verlauf: list[tuple] = [] + monkeypatch.setattr(updates.update_verlauf, "eintragen", lambda *a: verlauf.append(a)) + kanal.bericht_speichern(_bericht()) + ausfuehrer = Ausfuehrer() + yield SimpleNamespace(ausfuehrer=ausfuehrer, meldungen=meldungen, verlauf=verlauf, daten=tmp_path) + ausfuehrer.weiter.set() + ende = time.time() + 20 # keinen Faden in den nächsten Test mitnehmen + while (sammellauf.laeuft() or updates.laufende_ziele()) and time.time() < ende: + time.sleep(0.05) + ausfuehrer.stopp.set() + einstellungen_mod.einstellungen.cache_clear() + + +def _auf_ende(zeitlimit: float = 30) -> dict: + ende = time.time() + zeitlimit + while time.time() < ende: + sl = sammellauf.aktueller() + if sl and sl["status"] != "laeuft": + return sl + time.sleep(0.05) + raise AssertionError("„Alle aktualisieren“ wurde nicht fertig") + + +def _auf_lauf(lauf_id: str) -> dict: + ende = time.time() + 20 + while time.time() < ende: + lauf = updates.lauf_lesen(lauf_id) + if lauf and lauf["status"] != "laeuft": + return lauf + time.sleep(0.05) + raise AssertionError(f"Lauf {lauf_id} wurde nicht fertig") + + +# --- Plan ----------------------------------------------------------------------------------------------- + +def test_plan_reihenfolge_nadeloehre_zuletzt_und_der_host_ganz_am_ende(homelab): + plan = sammellauf.plan() + assert [(s["ziel"], s["baustein"]) for s in plan["schritte"]] == ALLE + assert plan["schritte"][0] == {"ziel": "ct-102", "name": "NetBird", "baustein": "app", "was": "0.77.1 → 0.79.0", + "rueckweg_art": "snapshot"} + assert [(s["name"], s["was"], s["rueckweg_art"]) for s in plan["schritte"][2:]] == [ + ("Proxmox Backup Server", "58 Pakete", "keiner"), ("NPMplus", "3 Pakete", "snapshot"), + ("AdGuard Home", "2 Pakete", "snapshot"), ("Proxmox-Host", "5 Pakete", "keiner")] + assert plan["hinweis"] == ("Ohne Rückweg: Proxmox Backup Server – scheitert dort das Update, geht es nicht von " + "selbst zurück. Der Proxmox-Host kommt zuletzt und hat keinen Rückweg; neu gestartet " + "wird er nicht.") + + +def test_plan_nur_mit_knopf_und_nie_der_neustart(homelab, monkeypatch): + bericht = _bericht() + bericht["host"]["neustart_noetig"] = True + kanal.bericht_speichern(bericht) + # Gitea wartet nach einer frischen Skriptänderung: „neu“, aber ohne Knopf — also nicht dabei. + monkeypatch.setattr(karenz, "skript_geaendert", lambda kennung: time.time() - 3600 if kennung == "gitea" else 0.0) + ziele = [(s["ziel"], s["baustein"]) for s in sammellauf.plan()["schritte"]] + assert ziele == [z for z in ALLE if z != ("ct-104", "app")] and ("pve", "neustart") not in ziele + + +def test_plan_docker_nur_wenn_echt(homelab, monkeypatch): + monkeypatch.setenv("MC_ARCANE_KEY", "schluessel-fuer-den-test") + monkeypatch.setattr(inventar.arcane, "images_mit_update", lambda url: [ + {"name": "ghcr.io/tobi/nerdquiz:latest", "aktuell": None, "neu": None, "umgebung": 0, "benutzt": ["nerdquiz"]}]) + plan = sammellauf.plan() + assert "vm-106" not in [s["ziel"] for s in plan["schritte"]] + assert plan["hinweis"].endswith("Die Docker-Images (Arcane) sind nicht dabei, solange Docker-Updates nur als " + "Probelauf laufen.") + monkeypatch.setenv("MC_ARCANE_ECHT", "1") + plan = sammellauf.plan() + assert [s["ziel"] for s in plan["schritte"]] == ["ct-102", "ct-104", "ct-105", "vm-106", "ct-101", "ct-100", "pve"] + docker = plan["schritte"][3] + assert (docker["baustein"], docker["was"], docker["rueckweg_art"]) == ("docker", "1 Images", "keiner") + assert "Ohne Rückweg: Proxmox Backup Server und Arcane (Docker)" in plan["hinweis"] + + +# --- Ablauf --------------------------------------------------------------------------------------------- + +def test_gruen_nacheinander_mit_einer_sammelmeldung(homelab): + antwort = sammellauf.starten() + assert antwort["ok"] and antwort["sammellauf"]["status"] == "laeuft" + assert set(antwort["sammellauf"]) == {"id", "status", "start", "ende", "aktuell", "schritte", "text"} + assert set(antwort["sammellauf"]["schritte"][0]) == {"ziel", "name", "baustein", "was", "status", "lauf", "text"} + sl = _auf_ende() + assert sl["status"] == "fertig" and sl["aktuell"] is None and sl["ende"] >= sl["start"] + assert [s["status"] for s in sl["schritte"]] == ["eingespielt"] * 6 + assert sl["text"] == "6 Updates eingespielt, alle Prüfungen grün." + assert homelab.ausfuehrer.protokoll == [ + ("snapshot", 102), ("update", 102), ("bericht", None), + ("snapshot", 104), ("update", 104), ("bericht", None), + ("os_update", 105), ("bericht", None), # PBS: weder Snapshot noch Sicherung + ("snapshot", 101), ("os_update", 101), ("bericht", None), + ("snapshot", 100), ("os_update", 100), ("bericht", None), + ("host_update", None), ("bericht", None)] + # Die Erfolgsmeldungen der Schritte bleiben aus; es kommt genau die eine Sammelmeldung. + assert homelab.meldungen == [("Homelab: 6 Updates eingespielt, alle Prüfungen grün.", False)] + laeufe = {x["id"]: x for x in updates.laeufe(20)} + assert all(laeufe[s["lauf"]]["gemeldet"] is False and laeufe[s["lauf"]]["sammellauf"] == sl["id"] + for s in sl["schritte"]) + assert laeufe[sl["schritte"][1]["lauf"]]["version_neu"] == "1.27.3" + # Im Update-Verlauf sind die Schritte EIN Lauf mit eigenem Anlass. + assert len(homelab.verlauf) == 6 and len({v[0] for v in homelab.verlauf}) == 1 + assert {v[1] for v in homelab.verlauf} == {"Alle aktualisieren"} + assert [v[2:4] for v in homelab.verlauf][:2] == [("ct-102", "eingespielt"), ("ct-104", "eingespielt")] + + +def test_rot_stoppt_und_meldet_dringend(homelab): + homelab.ausfuehrer.unveraendert.add(104) # Gitea: die Version bleibt → Prüfung rot → zurück + assert sammellauf.starten()["ok"] + sl = _auf_ende() + assert sl["status"] == "abgebrochen" and sl["text"] == "Abgebrochen bei Gitea: Update zurückgerollt." + assert [s["status"] for s in sl["schritte"]] == ["eingespielt", "zurueckgerollt"] + ["uebersprungen"] * 4 + assert sl["schritte"][2]["text"] == "Nicht mehr gelaufen: „Alle aktualisieren“ wurde abgebrochen." + assert homelab.ausfuehrer.protokoll[-1] == ("zurueck", 104) + assert not {v for _, v in homelab.ausfuehrer.protokoll} & {105, 101, 100} + assert ("host_update", None) not in homelab.ausfuehrer.protokoll + (gitea, dringend_1), (sammel, dringend_2) = homelab.meldungen # NetBirds Erfolg bleibt still + assert dringend_1 and gitea.startswith("Gitea: Update gescheitert") and "zurück auf den Snapshot" in gitea + assert dringend_2 and sammel == ("Homelab: „Alle aktualisieren“ abgebrochen bei Gitea (Update zurückgerollt). " + "Vorher eingespielt: NetBird. Nicht mehr gelaufen: Proxmox Backup Server, " + "NPMplus, AdGuard Home und Proxmox-Host.") + assert not sammellauf.laeuft() and not updates.laufende_ziele() + + +def test_nicht_begonnen_stoppt_auch_und_die_fehlermeldung_bleibt(homelab): + homelab.ausfuehrer.fehlschlag[("snapshot", 102)] = (1, "snapshot feature is not available") + assert sammellauf.starten()["ok"] + sl = _auf_ende() + assert [s["status"] for s in sl["schritte"]] == ["fehler"] + ["uebersprungen"] * 5 + assert homelab.ausfuehrer.protokoll == [("snapshot", 102)] + assert homelab.meldungen[0] == (("NetBird: Update nicht begonnen — snapshot gescheitert: snapshot feature is " + "not available. Am Gerät wurde nichts geändert."), False) + assert homelab.meldungen[1][1] is True and "abgebrochen bei NetBird (Update gescheitert)" in homelab.meldungen[1][0] + + +def test_abgelehnter_schritt_wird_uebersprungen_und_es_geht_weiter(homelab, monkeypatch): + gesperrt = {"an": False} + monkeypatch.setattr(karenz, "skript_geaendert", + lambda kennung: time.time() - 3600 if gesperrt["an"] and kennung == "gitea" else 0.0) + homelab.ausfuehrer.weiter.clear() + assert sammellauf.starten()["ok"] + gesperrt["an"] = True # das Update-Skript von Gitea ändert sich mitten im Lauf + homelab.ausfuehrer.weiter.set() + sl = _auf_ende() + assert sl["status"] == "fertig" + assert [s["status"] for s in sl["schritte"]] == ["eingespielt", "uebersprungen"] + ["eingespielt"] * 4 + assert sl["schritte"][1]["text"].startswith("Das Update-Skript wurde am ") + assert sl["text"].startswith("5 Updates eingespielt, alle Prüfungen grün. Nicht gelaufen: Gitea (Das Update-") + assert homelab.meldungen == [("Homelab: " + sl["text"], False)] + + +# --- Sperre --------------------------------------------------------------------------------------------- + +def test_nur_ein_sammellauf_und_kein_einzelner_knopf_waehrenddessen(homelab): + homelab.ausfuehrer.weiter.clear() # der erste Schritt hängt am Snapshot + assert sammellauf.starten()["ok"] + assert sammellauf.starten() == {"ok": False, "detail": SPERRE} + assert updates.starten("ct-104", "app") == {"ok": False, "detail": SPERRE} + assert updates.starten("pve", "neustart") == {"ok": False, "detail": SPERRE} + assert updates.starten("vm-106", "docker") == {"ok": False, "detail": SPERRE} + homelab.ausfuehrer.weiter.set() + assert _auf_ende()["status"] == "fertig" + assert not sammellauf.laeuft() + + +def test_nicht_neben_einem_einzelnen_update(homelab): + homelab.ausfuehrer.weiter.clear() + lauf = updates.starten("ct-104", "app") + assert lauf["ok"] + assert sammellauf.starten() == {"ok": False, "detail": "Gerade läuft ein einzelnes Update (Gitea). „Alle " + "aktualisieren“ geht, sobald es fertig ist."} + homelab.ausfuehrer.weiter.set() + assert _auf_lauf(lauf["lauf"])["ergebnis"] == "eingespielt" + assert homelab.meldungen == [("Gitea: eingespielt, Prüfung grün.", False)] # allein: wie bisher gemeldet + assert sammellauf.aktueller() is None + + +def test_ohne_ausfuehrer_im_nur_lesen_modus_und_ohne_updates(homelab, monkeypatch): + monkeypatch.setattr(kanal, "zuletzt", lambda: time.time() - 3600) + assert sammellauf.starten() == {"ok": False, "detail": "Der Ausführer auf dem Proxmox-Host ist nicht verbunden. " + "Ohne ihn geht kein Update."} + monkeypatch.setattr(kanal, "zuletzt", lambda: time.time()) + nur_lesen = _bericht() + nur_lesen["host"]["nur_lesen"] = True + kanal.bericht_speichern(nur_lesen) + assert "Nur-Lesen-Modus" in sammellauf.starten()["detail"] + (homelab.daten / "pve-bericht.json").unlink() + assert sammellauf.starten() == {"ok": False, "detail": "Gerade gibt es kein Update mit Knopf, also nichts zu tun."} + assert sammellauf.aktueller() is None and homelab.ausfuehrer.protokoll == [] + + +# --- Neustart und Absturz ------------------------------------------------------------------------------- + +def _offener_sammellauf(gitea_lauf: dict) -> None: + """So stand es auf der Platte, als der Homelab-Teil mitten im Gitea-Schritt neu startete.""" + jetzt = time.time() + updates._merken({"id": "l-netbird", "ziel": "ct-102", "baustein": "app", "name": "NetBird", "status": "fertig", + "ergebnis": "eingespielt", "text": "NetBird: eingespielt, Prüfung grün.", "start": jetzt - 300, + "ende": jetzt - 200, "schritte": [], "sammellauf": "s1", "sammellauf_start": jetzt - 400}) + updates._merken({"id": "l-gitea", "ziel": "ct-104", "baustein": "app", "name": "Gitea", "start": jetzt - 100, + "ende": None, "ergebnis": None, "text": None, "schritte": ["update …"], "sammellauf": "s1", + "sammellauf_start": jetzt - 400, **gitea_lauf}) + schritt = {"baustein": "app", "lauf": None, "text": None} + sammellauf._merken({"id": "s1", "status": "laeuft", "start": jetzt - 400, "ende": None, "aktuell": 1, + "text": None, "meldung": None, "dringend": False, "schritte": [ + {**schritt, "ziel": "ct-102", "name": "NetBird", "was": "0.77.1 → 0.79.0", + "status": "eingespielt", "lauf": "l-netbird", "text": "NetBird: eingespielt, Prüfung grün."}, + {**schritt, "ziel": "ct-104", "name": "Gitea", "was": "1.27.2 → 1.27.3", "status": "laeuft", + "lauf": "l-gitea"}, + {**schritt, "ziel": "pve", "name": "Proxmox-Host", "baustein": "pakete", "was": "5 Pakete", + "status": "wartet"}]}) + + +def _auf_meldungen(meldungen: list, anzahl: int) -> list: + ende = time.time() + 10 + while len(meldungen) < anzahl and time.time() < ende: + time.sleep(0.02) + return meldungen + + +def test_neustart_mitten_im_lauf_bricht_ab(homelab): + _offener_sammellauf({"status": "laeuft"}) + assert updates.starten("ct-104", "app") == {"ok": False, "detail": SPERRE} + updates.unterbrochene_abschliessen() # so ruft es der Start der App (Lebenszyklus in app.py) + sl = sammellauf.aktueller() + assert (sl["status"], sl["text"], sl["aktuell"]) == ("abgebrochen", "unterbrochen (Neustart)", None) + assert sl["ende"] is not None + assert [(s["status"], s["text"]) for s in sl["schritte"]] == [ + ("eingespielt", "NetBird: eingespielt, Prüfung grün."), ("fehler", "unterbrochen (Neustart)"), + ("uebersprungen", "Nicht mehr gelaufen: „Alle aktualisieren“ wurde abgebrochen.")] + assert updates.lauf_lesen("l-gitea")["status"] == "unterbrochen" + assert _auf_meldungen(homelab.meldungen, 1) == [( + ("Homelab: „Alle aktualisieren“ wurde unterbrochen: Der Homelab-Teil ist mitten im Lauf neu gestartet (bei " + "Gitea). Vorher eingespielt: NetBird. Nicht mehr gelaufen: Proxmox-Host. Bitte den Stand in der Übersicht " + "prüfen."), True)] + assert not sammellauf.laeuft() # der einzelne Knopf geht wieder + + +def test_neustart_nach_fertigem_schritt_zaehlt_dessen_ergebnis(homelab): + _offener_sammellauf({"status": "fertig", "ergebnis": "eingespielt", "text": "Gitea: eingespielt, Prüfung grün.", + "ende": time.time()}) + updates.unterbrochene_abschliessen() + sl = sammellauf.aktueller() + assert [s["status"] for s in sl["schritte"]] == ["eingespielt", "eingespielt", "uebersprungen"] + assert sl["status"] == "abgebrochen" and sl["text"] == "unterbrochen (Neustart)" + assert "Vorher eingespielt: NetBird und Gitea." in _auf_meldungen(homelab.meldungen, 1)[0][0] + + +def test_absturz_im_ablauf_gibt_die_sperre_frei(homelab, monkeypatch): + def kaputt(sl, schritt): + raise KeyError("kaputt") + monkeypatch.setattr(sammellauf, "_schritt", kaputt) + assert sammellauf.starten()["ok"] + sl = _auf_ende() + assert sl["status"] == "abgebrochen" and sl["text"] == "Interner Fehler: KeyError." + assert [s["status"] for s in sl["schritte"]] == ["uebersprungen"] * 6 + assert homelab.meldungen[-1][1] is True and not sammellauf.laeuft() + + +# --- Schnittstellen ------------------------------------------------------------------------------------- + +def _client() -> TestClient: + from routers import homelab as homelab_router + app = FastAPI() + app.include_router(homelab_router.router) + return TestClient(app) + + +def test_schnittstellen(homelab): + c = _client() + assert c.get("/api/homelab/sammellauf").json() == {"sammellauf": None} + plan = c.get("/api/homelab/alle/plan").json() + assert [(s["ziel"], s["baustein"]) for s in plan["schritte"]] == ALLE and plan["hinweis"] + homelab.ausfuehrer.weiter.clear() + antwort = c.post("/api/homelab/alle") + assert antwort.status_code == 200 and antwort.json()["ok"] is True + zweite = c.post("/api/homelab/alle") + assert zweite.status_code == 200 and zweite.json() == {"ok": False, "detail": SPERRE} + einzeln = c.post("/api/homelab/ziele/ct-104/update") + assert einzeln.status_code == 409 and einzeln.json()["detail"] == SPERRE + ende = time.time() + 10 # der Faden setzt gleich den ersten Schritt auf „läuft“ + while (laufend := c.get("/api/homelab/sammellauf").json()["sammellauf"])["aktuell"] is None and time.time() < ende: + time.sleep(0.02) + assert laufend["status"] == "laeuft" and laufend["aktuell"] == 0 and laufend["schritte"][0]["status"] == "laeuft" + homelab.ausfuehrer.weiter.set() + _auf_ende() + fertig = c.get("/api/homelab/sammellauf").json()["sammellauf"] + assert fertig["status"] == "fertig" and fertig["id"] == antwort.json()["sammellauf"]["id"] + assert "meldung" not in fertig and "dringend" not in fertig diff --git a/docs/ARCHITEKTUR.md b/docs/ARCHITEKTUR.md index 49b94c7..a8f8222 100644 --- a/docs/ARCHITEKTUR.md +++ b/docs/ARCHITEKTUR.md @@ -281,10 +281,52 @@ flowchart LR gerade berichtet, bevorzugt nachts 02:00–05:00 (war die Instanz letzte Nacht aus, eben gleich). Keine Meldungen; scheitert die Suche für einen Gast zweimal hintereinander, wird es ein gelber Hinweis des Wächters. Zustand in `/var/lib/mc2/homelab-pflege.json`. Weil damit zwei Prozesse Aufträge anlegen, schreibt `kanal.py` unter `flock`. +- **„Alle aktualisieren“** (`sammellauf.py`, seit 24.09.): alle Updates mit Knopf nacheinander, jeder Schritt ein + gewöhnlicher Lauf von „Jetzt updaten“ (`updates.starten`), auf dessen Ende der nächste wartet. Dabei ist nur, was + „neu“ ist und eine Aktion hat; was in der Wartezeit steht, fehlt. Docker über Arcane nur, wenn es echt läuft (ein + Probelauf ändert nichts). Reihenfolge: die Gäste nach VMID außer NPMplus und AdGuard, dann NPMplus, dann AdGuard + (Nadelöhre für Proxy und DNS), ganz zuletzt die Pakete des Proxmox-Hosts; der Neustart des Hosts ist nie dabei. + `GET /api/homelab/alle/plan` zeigt diese Reihenfolge mit dem Rückweg je Schritt und einem Hinweis (was keinen + Rückweg hat, dass der Host zuletzt kommt). `POST /api/homelab/alle` startet, wenn es etwas zu tun gibt, der + Ausführer verbunden ist (und nicht im Nur-Lesen-Modus) und gerade kein einzelnes Update läuft. + - Endet ein Schritt mit `zurueckgerollt` oder `fehler` (auch „Update nicht begonnen“), hört der Sammellauf auf: die + übrigen Schritte `uebersprungen`, Status `abgebrochen`, dringende Meldung. Lehnt `updates.starten` einen Schritt ab + (etwa weil die Wartezeit inzwischen greift), wird nur dieser übersprungen. Läuft alles durch, kommt eine + Sammelmeldung („Homelab: 5 Updates eingespielt, alle Prüfungen grün.“); die Erfolgsmeldungen der Schritte hält + `updates._ende` so lange zurück (`gemeldet: false` am Lauf), Fehlermeldungen nicht. Im Update-Verlauf stehen die + Schritte als ein Lauf mit dem Anlass „Alle aktualisieren“. + - Höchstens ein Sammellauf; solange er läuft, lehnt „Jetzt updaten“ ab („Es läuft gerade ‚Alle aktualisieren‘.“). + Prüfen und Anlegen geschehen unter `updates.START_SPERRE`. Stand in `/var/lib/mc2/homelab-sammellauf.json` (die + letzten 20; `GET /api/homelab/sammellauf` liefert den laufenden oder den letzten). Startet der Homelab-Teil mitten + im Lauf neu, macht `updates.unterbrochene_abschliessen()` beim Start daraus `abgebrochen` mit dem Text + „unterbrochen (Neustart)“ und meldet es dringend. +- **Protokoll** (`protokoll.py`, seit 24.09.): `GET /api/homelab/protokoll?grenze=200&berichte=0` führt zusammen, was + geschah, neueste zuerst: Aufträge an den Ausführer (`kanal.py` hebt jetzt die letzten 500 erledigten auf; die + jüngsten 60 mit ganzer Ausgabe, ältere mit den letzten 4000 Zeichen), Update-Läufe mit ihren Schritten, + Sammelläufe, den Verlauf der Hinweise des Wächters (der Verlauf in `mc2-waechter.json` trägt seit 24.09. die Stufe + mit), die Zeilen des Melde-Logs (`MC_NOTIFY_LOG`, im Container `/var/lib/mc2/notify.log`) und die Ereignisse des + wöchentlichen Suchens (`homelab-pflege.json`, Liste `ereignisse`). Berichte nur mit `berichte=1`: die Aufträge + `bericht` der Läufe und die Berichte, die der Ausführer von sich aus schickt (ihr Eingang steht in + `/var/lib/mc2/ausfuehrer-berichte.json`, die letzten 150). Den Betreff einer Meldung schreibt `notify.sh` nicht ins + Log; er folgt aus dem Absender (Lauf, Sammellauf, Wächter, Telegram-Test, Morgenmeldung). Nichts im Protokoll + enthält Geheimnisse: Die Schlüssel dieser Instanz (Arcane, Ausführer) und alles, was wie ein Token aussieht + (Bot-Token, Bearer, `name=wert` mit verräterischem Namen, Zugangsdaten in Adressen), wird zu `***`; Steuerzeichen der + Konsole fallen weg. +- **Einstellungen des Homelab-Teils** (`einstellungen.py`, seit 24.09.): `GET /api/homelab/einstellungen` (Arcane: + Adresse aus dem Bericht, Herkunft des Schlüssels `hinterlegt`/`fehlt`/`umgebung`, echt und woher; Wartezeit in + Stunden; Ausführer). `POST …/arcane-schluessel` prüft den Schlüssel zuerst bei Arcane mit denselben Aufrufen, die die + Übersicht braucht (`/api/environments`, dann deren Images); nur wenn Arcane ihn annimmt, landet er in + `/var/lib/mc2/arcane.key` (0600). Er steht nie in einer Antwort, einem Protokoll oder einer Fehlermeldung; den Body + liest der Dienst selbst, damit keine 422-Antwort ihn wiederholt. `DELETE …/arcane-schluessel` löscht die Datei, + `POST …/arcane-echt` (`{"an": bool}`) schaltet echte Docker-Updates in `/var/lib/mc2/homelab-einstellungen.json`. + Was nicht geht, beantworten diese Endpunkte wie „Alle aktualisieren“ mit `{"ok": false, "detail": …}` und HTTP 200. - **Arcane und Docker** (`services/homelab/arcane.py`): Arcanes eigene Version kommt öffentlich über `/api/app-version`; die Docker-Images mit neuerem Stand und der Updater brauchen einen Arcane-API-Schlüssel - (`MC_ARCANE_KEY`, Kopfzeile `X-API-Key`). Docker-Updates laufen zuerst nur als Probelauf (`dryRun`); echt erst - mit `MC_ARCANE_ECHT=1`. Die Arcane-VM braucht dafür kein Etikett, weil der Ausführer nicht beteiligt ist. + (Kopfzeile `X-API-Key`): `MC_ARCANE_KEY` in `/etc/mc2/homelab.env`, sonst der Schlüssel aus den Einstellungen + (`arcane.key`); beides wird bei jedem Zugriff gelesen, ein neuer Schlüssel gilt ohne Neustart. Docker-Updates laufen + zuerst nur als Probelauf (`dryRun`); echt, wenn der Schalter in den Einstellungen an ist. Setzt die Umgebung + `MC_ARCANE_ECHT`, gewinnt sie (`1` = echt). Die Arcane-VM braucht dafür kein Etikett, weil der Ausführer nicht + beteiligt ist. - **Wächter** in der Rolle `homelab`: Platte, Partner (die KI-Box), Ausführer (kein Bericht seit 30 min = rot), jede Weboberfläche der freigegebenen Gäste (außer mitten in ihrem Update-Lauf; das Ergebnis meldet der Lauf), die Platten der laufenden Container (ab 80 % gelb, ab 90 % rot — ext4 hält 5 % für root zurück; der Ausführer schickt `platte` mit), das diff --git a/docs/BETRIEB.md b/docs/BETRIEB.md index 30a197e..32640bb 100644 --- a/docs/BETRIEB.md +++ b/docs/BETRIEB.md @@ -58,6 +58,7 @@ sudo journalctl -u llama-swap -n 100 # Motor (System-Dienst) | `[Box-Problem]` | Wächter | neuer roter Hinweis; nach 6 h erneut mit „Immer noch:" | | `[Box wieder ok]` | Wächter | ein gemeldeter roter Hinweis ist erledigt | | `[Homelab-Problem]` / `[Homelab wieder ok]` | Wächter der Homelab-Instanz | wie oben, sobald diese Instanz läuft | +| `[Homelab-Update]` / `[Alarm] Homelab-Update` | „Jetzt updaten“ und „Alle aktualisieren“ im Homelab | Ergebnis je Lauf, dringend bei Rot; bei „Alle aktualisieren“ (seit 24.09.) nur Fehler einzeln, am Ende eine Sammelmeldung, dringend bei Abbruch | | `[Modell-Radar]` | `backend/radar_lauf.py` | ein Kandidat hat den Nachttest bestanden | | `[Sicherung]` | `backend/services/probe_wiederherstellung.py` | die monatliche Probe-Wiederherstellung war rot (normale Dringlichkeit) | | `[Stack-Radar]` | `jobs/stack-radar.sh` | Samstagsbericht | @@ -253,6 +254,28 @@ laufen lassen. Achtung: `ausfuehrer-einrichten.sh` schreibt `/etc/mc2-ausfuehrer - **Wartezeit nach Skriptänderung:** Ist `ct/.sh` bei community-scripts jünger als `MC_HOMELAB_KARENZ_H` Stunden (Standard 48), zeigt die Karte „Update bereit“ ohne Knopf und nennt, ab wann es geht. Ändern oder abschalten (`0`): `MC_HOMELAB_KARENZ_H=…` in `/etc/mc2/homelab.env` im Container, dann `systemctl restart mc2-homelab`. +- **Arcane-Schlüssel (seit 24.09. über die Oberfläche):** in Arcane unter Einstellungen → API-Schlüssel anlegen, dann in + der Oberfläche unter Einstellungen (Karte Homelab) eintragen. Der Homelab-Teil fragt Arcane erst, ob der Schlüssel + gilt (Umgebungen und Images lesen); nur dann speichert er ihn in `/var/lib/mc2/arcane.key` (0600, Eigentümer `mc2`), + und die Übersicht sieht die Docker-Images ohne Neustart. Lehnt Arcane ab (etwa „HTTP 401“) oder antwortet nicht, + wird nichts gespeichert. Der Schlüssel erscheint danach nirgends mehr (Antwort, Protokoll, Journal). Löschen: + derselbe Knopf in der Oberfläche oder `rm /var/lib/mc2/arcane.key`. `MC_ARCANE_KEY` in `/etc/mc2/homelab.env` geht + weiter vor; solange es gesetzt ist, lehnt die Oberfläche das Eintragen ab und sagt warum. +- **Docker-Updates echt statt Probelauf:** Schalter in den Einstellungen, gespeichert in + `/var/lib/mc2/homelab-einstellungen.json` (`{"arcane": {"echt": true}}`). Setzt `/etc/mc2/homelab.env` + `MC_ARCANE_ECHT`, gewinnt die Umgebung (`1` = echt, sonst Probelauf), und der Schalter sagt es. +- **„Alle aktualisieren“ (seit 24.09.):** spielt alle Updates mit Knopf nacheinander ein — die Gäste nach VMID, dann + NPMplus, dann AdGuard, zuletzt die Pakete des Proxmox-Hosts (ohne Neustart). Ist ein Schritt rot (zurückgerollt oder + gescheitert), bleibt der Rest stehen, und es kommt eine dringende Meldung; sonst am Ende eine Sammelmeldung. + Solange er läuft, lehnen die einzelnen Knöpfe „Jetzt updaten“ ab. Stand: + `curl -s http://192.168.178.31:9001/api/homelab/sammellauf` bzw. `/var/lib/mc2/homelab-sammellauf.json`. Startet + `mc2-homelab` mitten im Lauf neu (auch durch einen Deploy), gilt er als abgebrochen („unterbrochen (Neustart)“, mit + dringender Meldung); der Schritt, der gerade lief, steht auf „fehler“ — den Stand des Geräts in der Übersicht prüfen. +- **Protokoll (seit 24.09.):** `curl -s 'http://192.168.178.31:9001/api/homelab/protokoll?grenze=50' | jq` zeigt + Aufträge, Läufe, Sammelläufe, Hinweise, Meldungen und das wöchentliche Suchen, neueste zuerst; `&berichte=1` auch die + Berichte des Ausführers. Die Quellen bleiben die Dateien in `/var/lib/mc2` (`ausfuehrer-auftraege.json` hebt jetzt + die letzten 500 erledigten Aufträge auf, `ausfuehrer-berichte.json` die Eingänge der letzten 150 Berichte) und das + Melde-Log `/var/lib/mc2/notify.log`. - **Wöchentliches Suchen:** Der Steward legt für jeden freigegebenen, laufenden Container, dessen Paketlisten älter als 7 Tage sind, den Auftrag `suchen` an (nur `apt-get update` bzw. `apk update` im Gast, es wird nichts installiert): nachts 02:00–05:00, höchstens einmal je Gast und Tag, nicht während eines Updates; war die Instanz nachts aus, gleich diff --git a/docs/wissen/OFFENE-FAEDEN.md b/docs/wissen/OFFENE-FAEDEN.md index 463c307..16ab454 100644 --- a/docs/wissen/OFFENE-FAEDEN.md +++ b/docs/wissen/OFFENE-FAEDEN.md @@ -31,9 +31,10 @@ Technik: [ARCHITEKTUR.md](../ARCHITEKTUR.md), Abschnitt „Der Homelab-Teil“; Proxmox-Host gelegt (`/root/mc2-archiv/adguard-querylog-bis-2026-08-09.json.zst`, 44 MB) und aus dem Container genommen; jetzt 71 % belegt. Damit es nicht wieder vollläuft: in AdGuard unter Einstellungen → Allgemein → Abfrageprotokoll die Aufbewahrung z. B. auf 30 Tage stellen. Volle Gastplatten meldet seit 24.09. der Wächter. -1. **Arcane-API-Schlüssel fehlt.** Ohne ihn zeigt die Arcane-Karte die Docker-Images als „unklar“. Anlegen in Arcane - (Einstellungen → API-Schlüssel), dann `MC_ARCANE_KEY=…` in `/etc/mc2/homelab.env` im Container 107 und - `systemctl restart mc2-homelab mc2-homelab-steward`. Docker-Updates bleiben Probelauf bis `MC_ARCANE_ECHT=1`. +1. **Arcane-API-Schlüssel fehlt (User).** Ohne ihn zeigt die Arcane-Karte die Docker-Images als „unklar“. Seit 24.09. + in der Oberfläche eintragbar: in Arcane unter Einstellungen → API-Schlüssel anlegen, dann in der Oberfläche unter + Einstellungen (Karte Homelab) einfügen. Arcane prüft ihn vor dem Speichern; ein Neustart ist nicht nötig. + Docker-Updates bleiben Probelauf, bis der Schalter „Docker-Updates echt“ dort an ist. 2. **Feste IP für Container 107.** Er hat seine Adresse per DHCP (192.168.178.31); Partner-Adresse der Box, Ausführer-Konfiguration und der Deploy-Schritt 8 hängen daran. In der Fritzbox die Adresse fest zuordnen. 3. **Die Arcane-VM (106) trägt kein Etikett.** Für Arcane und Docker braucht es keins (eigene Schnittstelle); für