diff --git a/backend/routers/homelab.py b/backend/routers/homelab.py index c81b37d..8648593 100644 --- a/backend/routers/homelab.py +++ b/backend/routers/homelab.py @@ -5,6 +5,7 @@ POST /api/homelab/ziele/{id}/os-update Pakete im Gast einspielen POST /api/homelab/ziele/{id}/suchen Paketlisten im Gast erneuern POST /api/homelab/ziele/{id}/docker Docker über Arcane (zuerst Probelauf) + POST /api/homelab/ziele/{id}/rueckweg-probe Rückweg testen: Snapshot, absichtlich rot, zurück, nachprüfen POST /api/homelab/ziele/pve/neustart Proxmox-Host neu starten (eigener Knopf) GET /api/homelab/laeufe Die letzten Läufe (Updates, Snapshot-Aktionen) mit ihren Schritten GET /api/homelab/snapshots Snapshots und Sicherungen je Gerät (Aktionen: siehe unten) @@ -66,6 +67,12 @@ def docker(ziel_id: str) -> dict: return _antwort(updates.starten(ziel_id, "docker")) +@router.post("/ziele/{ziel_id}/rueckweg-probe") +def rueckweg_probe(ziel_id: str) -> dict: + """Rückweg testen (seit 25.09.2026): Snapshot anlegen, absichtlich rot werten, zurückgehen, nachprüfen.""" + return _antwort(updates.starten(ziel_id, "probe")) + + @router.post("/ziele/pve/neustart") def host_neustart() -> dict: return _antwort(updates.starten("pve", "neustart")) diff --git a/backend/services/homelab/protokoll.py b/backend/services/homelab/protokoll.py index 129e9cf..584c9cd 100644 --- a/backend/services/homelab/protokoll.py +++ b/backend/services/homelab/protokoll.py @@ -70,7 +70,7 @@ _AKTION = { } _HOST_AKTIONEN = {"bericht", "host_update", "host_neustart", "kernel_aufraeumen"} _LAUF_WAS = {"app": "Update", "os": "Paket-Update", "pakete": "Paket-Update", "docker": "Docker-Update", - "neustart": "Neustart", "kernel": "Kernel-Aufräumen"} + "neustart": "Neustart", "kernel": "Kernel-Aufräumen", "probe": "Rückweg-Probe"} _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}) " @@ -351,7 +351,7 @@ def _lauf(x: dict, schw: Callable) -> dict | None: 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 ""): + elif ergebnis == "fehler" and ("Update nicht begonnen" in str(text or "") or "Probe nicht begonnen" in str(text or "")): titel, stufe = f"{was} nicht begonnen · {name}", "warn" elif ergebnis == "fehler": titel, stufe = f"{was} gescheitert · {name}", "fehler" @@ -359,6 +359,8 @@ def _lauf(x: dict, schw: Callable) -> dict | None: titel, stufe = f"Neustart ausgelöst · {name}", "info" elif ergebnis == "aufgeraeumt": titel, stufe = f"Alte Kernel entfernt · {name}", "ok" + elif ergebnis == "probe-gruen": + titel, stufe = f"Rückweg-Probe grün · {name}", "ok" elif ergebnis == "offen": titel, stufe = f"Docker-Probelauf · {name}", "info" else: diff --git a/backend/services/homelab/updates.py b/backend/services/homelab/updates.py index 46ae658..aa6aca0 100644 --- a/backend/services/homelab/updates.py +++ b/backend/services/homelab/updates.py @@ -20,6 +20,10 @@ danach genauso mit Snapshot vorher und Rückweg bei Rot — sofern die VM freige 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. +„Rückweg testen“ (Baustein „probe“, seit 25.09.2026): Snapshot bzw. Sicherung anlegen, die Prüfung absichtlich als +rot werten, zurückgehen und nachprüfen, ob das Gerät danach läuft und antwortet. Ein Update gibt es dabei nicht; so +lässt sich der Weg zurück jederzeit echt beweisen, ohne auf ein gescheitertes Update zu warten. + „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. @@ -227,6 +231,34 @@ def _gast_lauf(lauf: dict, gast: dict) -> None: dringend=True) +def _probe_lauf(lauf: dict, gast: dict) -> None: + """Rückweg testen: anlegen, absichtlich rot werten, zurückgehen, nachprüfen. Der Snapshot bzw. die Sicherung + bleibt danach liegen (wie nach einem echten Rückweg) — wegräumen geht auf der Seite Snapshots.""" + vmid = gast["vmid"] + try: + rueckweg = _rueckweg_anlegen(lauf, gast) + except RuntimeError as exc: + _ende(lauf, "fehler", f"{lauf['name']}: Rückweg-Probe nicht begonnen — {str(exc).rstrip('.')}. " + "Am Gerät wurde nichts geändert.") + return + if not rueckweg: + _ende(lauf, "fehler", f"{lauf['name']}: Für dieses Gerät gibt es keinen Rückweg (weder Snapshot noch " + "Sicherung) — die Probe ist nicht möglich.") + return + lauf["schritte"].append("Prüfung absichtlich auf Rot gesetzt (Probe, kein Update).") + _merken(lauf) + try: + wie = _zurueck(lauf, vmid, rueckweg) + time.sleep(WARTEN_NACH_UPDATE_S) + fehler = pruefen(gast, _frischer_gast(lauf, vmid), "probe") + except RuntimeError as exc: + wie, fehler = "", [str(exc)] + if fehler: + _ende(lauf, "fehler", f"{lauf['name']}: Rückweg-Probe rot — {' '.join(fehler)}", dringend=True) + return + _ende(lauf, "probe-gruen", f"{lauf['name']}: Rückweg-Probe grün — {wie}, danach läuft das Gerät und antwortet.") + + def _aufraeumen(lauf: dict, vmid: int, rueckweg: tuple[str, str] | None) -> None: """Ältere Snapshots des Orchestrators für diesen Gast löschen, nach einer Sicherung auch ältere Sicherungen; der Rückweg dieses Laufs (der neueste) bleibt. Ebenso bleiben Snapshots, die jemand auf der Seite Snapshots per @@ -348,13 +380,15 @@ def _ende(lauf: dict, ergebnis: str, text: str, dringend: bool = False) -> None: lauf.update(status="fertig", ergebnis=ergebnis, text=text, ende=time.time(), dringend=dringend, gemeldet=not still) _merken(lauf) - try: - # 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) + # Eine Rückweg-Probe ist kein Update: Sie steht im Protokoll, nicht im Update-Verlauf. + if lauf.get("baustein") != "probe": + try: + # 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) if not still: _melden(text, dringend=dringend) @@ -373,13 +407,15 @@ def starten(ziel_id: str, baustein: str, sammellauf: dict | None = None) -> dict 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", "kernel"): + if baustein not in ("app", "os", "pakete", "neustart", "docker", "kernel", "probe"): return {"ok": False, "detail": "Unbekannter Baustein."} 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" and baustein == "probe": + return {"ok": False, "detail": "Für den Proxmox-Host gibt es keinen Rückweg zum Testen."} if ziel_id == "pve": name, gast = "Proxmox-Host", None host = ((kanal.bericht() or {}).get("bericht") or {}).get("host") or {} @@ -402,6 +438,13 @@ def starten(ziel_id: str, baustein: str, sammellauf: dict | None = None) -> dict 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 == "probe": + if not (gast.get("snapshot_moeglich") or gast.get("sicherung_moeglich")): + return {"ok": False, "detail": f"{name} hat keinen Rückweg (weder Snapshot noch Sicherung)."} + lauf = _neuer_lauf(ziel_id, baustein, name, None) + threading.Thread(target=_probe_lauf, args=(lauf, gast), name=f"homelab-{ziel_id}", + daemon=True).start() + return {"ok": True, "lauf": lauf["id"]} 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)."} diff --git a/backend/tests/test_homelab.py b/backend/tests/test_homelab.py index f2cf78f..0ddbbaf 100644 --- a/backend/tests/test_homelab.py +++ b/backend/tests/test_homelab.py @@ -222,6 +222,30 @@ def test_update_ohne_neue_version_ist_rot(ausfuehrer): assert lauf["ergebnis"] == "zurueckgerollt" and "unverändert" in lauf["text"] +def test_rueckweg_probe_gruen(ausfuehrer): + starten, protokoll, meldungen, verlauf = ausfuehrer + starten("1.27.2") + lauf = _auf_ende(updates.starten("ct-104", "probe")["lauf"]) + # Kein Update: Snapshot, zurück auf ihn, frischer Bericht — und die Probe steht nicht im Update-Verlauf. + assert protokoll == ["snapshot", "zurueck", "bericht"] + assert lauf["ergebnis"] == "probe-gruen" and "zurück auf den Snapshot mc2-20260924-190000" in lauf["text"] + assert meldungen == [(lauf["text"], False)] and verlauf == [] + + +def test_rueckweg_probe_rot_meldet_dringend(ausfuehrer, monkeypatch): + starten, _, meldungen, _ = ausfuehrer + monkeypatch.setattr(inventar, "erreichbar", lambda url: False) + starten("1.27.2") + lauf = _auf_ende(updates.starten("ct-104", "probe")["lauf"]) + assert lauf["ergebnis"] == "fehler" and "Rückweg-Probe rot" in lauf["text"] and "antwortet nicht" in lauf["text"] + assert meldungen[0][1] is True + + +def test_rueckweg_probe_nur_mit_rueckweg(ausfuehrer): + assert updates.starten("pve", "probe")["detail"].startswith("Für den Proxmox-Host") + assert updates.starten("vm-106", "probe")["detail"].startswith("Dieses Gerät ist nicht freigegeben") + + def test_nicht_freigegeben_und_unbekannt(ausfuehrer): assert updates.starten("vm-106", "app")["detail"].startswith("Dieses Gerät ist nicht freigegeben") assert updates.starten("ct-999", "app")["ok"] is False diff --git a/frontend/src/lib/typen.ts b/frontend/src/lib/typen.ts index d6f685f..0a08cc3 100644 --- a/frontend/src/lib/typen.ts +++ b/frontend/src/lib/typen.ts @@ -332,7 +332,8 @@ export interface HomelabStand { } /** Ein Lauf im Homelab (GET /api/homelab/laeufe): „Jetzt updaten“ und seit 25.09.2026 die Aktionen der Seite - * Snapshots (Bausteine snapshot-anlegen, snapshot-loeschen, snapshot-zurueck, sicherung-loeschen). */ + * Snapshots (Bausteine snapshot-anlegen, snapshot-loeschen, snapshot-zurueck, sicherung-loeschen) sowie die + * Rückweg-Probe (Baustein probe). */ export interface HomelabLauf { id: string ziel: string @@ -343,7 +344,7 @@ export interface HomelabLauf { ende: number | null ergebnis: | "eingespielt" | "zurueckgerollt" | "fehler" | "neustart" | "aufgeraeumt" | "offen" - | "angelegt" | "geloescht" | "zurueckgesetzt" | null + | "angelegt" | "geloescht" | "zurueckgesetzt" | "probe-gruen" | null text: string | null schritte: string[] } diff --git a/frontend/src/views/Uebersicht.tsx b/frontend/src/views/Uebersicht.tsx index efcdb6b..ecfb8e2 100644 --- a/frontend/src/views/Uebersicht.tsx +++ b/frontend/src/views/Uebersicht.tsx @@ -237,8 +237,9 @@ function Zuletzt({ homelabAktiv }: { homelabAktiv: boolean }) { ...(box.data?.laeufe ?? []).slice(0, 6).map((l) => ({ zeit: new Date(l.start).getTime(), bereich: "KI-Box", titel: l.anlass, chip: l.titel, art: ERGEBNIS[l.stufe] ?? "grau", })), - // Die Aktionen der Seite Snapshots sind keine Updates. - ...(lab.data?.laeufe ?? []).filter((l) => l.status !== "laeuft" && !istSnapshotLauf(l)).slice(0, 6).map((l) => ({ + // Die Aktionen der Seite Snapshots und die Rückweg-Probe sind keine Updates. + ...(lab.data?.laeufe ?? []).filter((l) => l.status !== "laeuft" && !istSnapshotLauf(l) && l.baustein !== "probe") + .slice(0, 6).map((l) => ({ zeit: l.start * 1000, bereich: "Homelab", titel: l.name, chip: l.ergebnis ?? "unterbrochen", art: (l.ergebnis ? ERGEBNIS[l.ergebnis] : "grau") ?? "grau", })),