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}/os-update Pakete im Gast einspielen
|
||||||
POST /api/homelab/ziele/{id}/suchen Paketlisten im Gast erneuern
|
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}/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)
|
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/laeufe Die letzten Läufe (Updates, Snapshot-Aktionen) mit ihren Schritten
|
||||||
GET /api/homelab/snapshots Snapshots und Sicherungen je Gerät (Aktionen: siehe unten)
|
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"))
|
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")
|
@router.post("/ziele/pve/neustart")
|
||||||
def host_neustart() -> dict:
|
def host_neustart() -> dict:
|
||||||
return _antwort(updates.starten("pve", "neustart"))
|
return _antwort(updates.starten("pve", "neustart"))
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ _AKTION = {
|
|||||||
}
|
}
|
||||||
_HOST_AKTIONEN = {"bericht", "host_update", "host_neustart", "kernel_aufraeumen"}
|
_HOST_AKTIONEN = {"bericht", "host_update", "host_neustart", "kernel_aufraeumen"}
|
||||||
_LAUF_WAS = {"app": "Update", "os": "Paket-Update", "pakete": "Paket-Update", "docker": "Docker-Update",
|
_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",
|
_SCHRITT_WORT = {"wartet": "wartet", "laeuft": "läuft", "eingespielt": "eingespielt",
|
||||||
"zurueckgerollt": "zurückgerollt", "fehler": "gescheitert", "uebersprungen": "übersprungen"}
|
"zurueckgerollt": "zurückgerollt", "fehler": "gescheitert", "uebersprungen": "übersprungen"}
|
||||||
_MELDUNG = re.compile(r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) "
|
_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}"
|
titel = f"{_anzahl_pakete(x.get('pakete'), 'Paket')} · {name}"
|
||||||
elif ergebnis == "zurueckgerollt":
|
elif ergebnis == "zurueckgerollt":
|
||||||
titel, stufe = f"{was} zurückgerollt · {name}", "warn"
|
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"
|
titel, stufe = f"{was} nicht begonnen · {name}", "warn"
|
||||||
elif ergebnis == "fehler":
|
elif ergebnis == "fehler":
|
||||||
titel, stufe = f"{was} gescheitert · {name}", "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"
|
titel, stufe = f"Neustart ausgelöst · {name}", "info"
|
||||||
elif ergebnis == "aufgeraeumt":
|
elif ergebnis == "aufgeraeumt":
|
||||||
titel, stufe = f"Alte Kernel entfernt · {name}", "ok"
|
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":
|
elif ergebnis == "offen":
|
||||||
titel, stufe = f"Docker-Probelauf · {name}", "info"
|
titel, stufe = f"Docker-Probelauf · {name}", "info"
|
||||||
else:
|
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
|
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.
|
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,
|
„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
|
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.
|
am Ende sagt es; Fehler werden weiter sofort gemeldet.
|
||||||
@@ -227,6 +231,34 @@ def _gast_lauf(lauf: dict, gast: dict) -> None:
|
|||||||
dringend=True)
|
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:
|
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;
|
"""Ä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
|
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,
|
lauf.update(status="fertig", ergebnis=ergebnis, text=text, ende=time.time(), dringend=dringend,
|
||||||
gemeldet=not still)
|
gemeldet=not still)
|
||||||
_merken(lauf)
|
_merken(lauf)
|
||||||
|
# Eine Rückweg-Probe ist kein Update: Sie steht im Protokoll, nicht im Update-Verlauf.
|
||||||
|
if lauf.get("baustein") != "probe":
|
||||||
try:
|
try:
|
||||||
# Die Schritte eines Sammellaufs stehen im Update-Verlauf als EIN Lauf (gleicher Beginn, eigener Anlass).
|
# 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)
|
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
|
sammellauf ({"id", "start"}) setzt nur „Alle aktualisieren“ (sammellauf.py) für seine Schritte; ohne ihn lehnt
|
||||||
der Knopf ab, solange ein Sammellauf läuft."""
|
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."}
|
return {"ok": False, "detail": "Unbekannter Baustein."}
|
||||||
with START_SPERRE:
|
with START_SPERRE:
|
||||||
if sammellauf is None and _sammellauf_laeuft():
|
if sammellauf is None and _sammellauf_laeuft():
|
||||||
return {"ok": False, "detail": SAMMELLAUF_SPERRE}
|
return {"ok": False, "detail": SAMMELLAUF_SPERRE}
|
||||||
if laeuft(ziel_id):
|
if laeuft(ziel_id):
|
||||||
return {"ok": False, "detail": "Für dieses Gerät läuft schon ein Update."}
|
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":
|
if ziel_id == "pve":
|
||||||
name, gast = "Proxmox-Host", None
|
name, gast = "Proxmox-Host", None
|
||||||
host = ((kanal.bericht() or {}).get("bericht") or {}).get("host") or {}
|
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)."}
|
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"))
|
app = apps.app_fuer((gast.get("app") or {}).get("kennung"), gast.get("name"))
|
||||||
name = app.name if app else str(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"):
|
if baustein == "app" and (not app or app.weg != "skript"):
|
||||||
return {"ok": False, "detail": f"{name} aktualisiert sich nicht über das Community-Script "
|
return {"ok": False, "detail": f"{name} aktualisiert sich nicht über das Community-Script "
|
||||||
"(eigene Oberfläche oder Pakete)."}
|
"(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"]
|
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):
|
def test_nicht_freigegeben_und_unbekannt(ausfuehrer):
|
||||||
assert updates.starten("vm-106", "app")["detail"].startswith("Dieses Gerät ist nicht freigegeben")
|
assert updates.starten("vm-106", "app")["detail"].startswith("Dieses Gerät ist nicht freigegeben")
|
||||||
assert updates.starten("ct-999", "app")["ok"] is False
|
assert updates.starten("ct-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
|
/** 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 {
|
export interface HomelabLauf {
|
||||||
id: string
|
id: string
|
||||||
ziel: string
|
ziel: string
|
||||||
@@ -343,7 +344,7 @@ export interface HomelabLauf {
|
|||||||
ende: number | null
|
ende: number | null
|
||||||
ergebnis:
|
ergebnis:
|
||||||
| "eingespielt" | "zurueckgerollt" | "fehler" | "neustart" | "aufgeraeumt" | "offen"
|
| "eingespielt" | "zurueckgerollt" | "fehler" | "neustart" | "aufgeraeumt" | "offen"
|
||||||
| "angelegt" | "geloescht" | "zurueckgesetzt" | null
|
| "angelegt" | "geloescht" | "zurueckgesetzt" | "probe-gruen" | null
|
||||||
text: string | null
|
text: string | null
|
||||||
schritte: string[]
|
schritte: string[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -237,8 +237,9 @@ function Zuletzt({ homelabAktiv }: { homelabAktiv: boolean }) {
|
|||||||
...(box.data?.laeufe ?? []).slice(0, 6).map((l) => ({
|
...(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",
|
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.
|
// Die Aktionen der Seite Snapshots und die Rückweg-Probe sind keine Updates.
|
||||||
...(lab.data?.laeufe ?? []).filter((l) => l.status !== "laeuft" && !istSnapshotLauf(l)).slice(0, 6).map((l) => ({
|
...(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",
|
zeit: l.start * 1000, bereich: "Homelab", titel: l.name, chip: l.ergebnis ?? "unterbrochen",
|
||||||
art: (l.ergebnis ? ERGEBNIS[l.ergebnis] : "grau") ?? "grau",
|
art: (l.ergebnis ? ERGEBNIS[l.ergebnis] : "grau") ?? "grau",
|
||||||
})),
|
})),
|
||||||
|
|||||||
Reference in New Issue
Block a user