Die Update-Liste zeigte bei Arcane „Snapshot vorher, bei Rot zurück“, obwohl das Docker-Update keinen Snapshot anlegte (und der Ausführer VM 106 ohne Etikett gar nicht anfassen darf). Jetzt: - Gäste ohne Freigabe haben Rückweg „keiner“, Rückfrage und Plan sagen es. - Echte Docker-Updates legen vorher einen Snapshot der VM an, wenn sie freigegeben ist, prüfen danach (Fehler-Container, Arcane antwortet binnen 3 min) und gehen bei Rot zurück – wie bei den Containern. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
330 lines
16 KiB
Python
330 lines
16 KiB
Python
"""„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 <Datenordner>/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) # Snapshot der VM nur mit Etikett watcher — rueckweg_art sagt es
|
||
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()
|