homelab: „Rückweg testen“ — Snapshot anlegen, absichtlich rot werten, zurück, nachprüfen
Neuer Baustein „probe“ (POST /api/homelab/ziele/{id}/rueckweg-probe): legt den
Rückweg an wie vor einem Update, wertet die Prüfung absichtlich als rot, geht
zurück und prüft danach, ob das Gerät läuft und antwortet. Kein Update dabei;
Meldung grün bzw. dringend rot. Die Probe steht im Protokoll, nicht im
Update-Verlauf und nicht unter „Zuletzt eingespielt“. Der Snapshot bleibt
liegen (wie nach einem echten Rückweg).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b816fe6d32
commit
77ecb35a6d
@@ -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"))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -20,6 +20,10 @@ danach genauso mit Snapshot vorher und Rückweg bei Rot — sofern die VM freige
|
||||
Jeder Lauf steht in <Datenordner>/homelab-laeufe.json und im strukturierten Update-Verlauf. Pro Ziel
|
||||
läuft höchstens ein Lauf; startet dieser Teil neu, gilt ein offener Lauf als unterbrochen.
|
||||
|
||||
„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,6 +380,8 @@ 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)
|
||||
# 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)
|
||||
@@ -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)."}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
})),
|
||||
|
||||
Reference in New Issue
Block a user