"""„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 — solange ihn niemand quittiert hat (seit 25.09.2026: vorher stand der letzte für immer auf der Update-Seite).""" alle = _alle() if laufend := next((x for x in reversed(alle) if x.get("status") == "laeuft"), None): return oeffentlich(laufend) letzter = alle[-1] if alle else None return None if not letzter or letzter.get("quittiert") else oeffentlich(letzter) def quittieren(sl_id: str) -> bool: """Einen beendeten Sammellauf als gesehen markieren; das Protokoll behält ihn.""" with _lock: sl = next((x for x in _alle() if x["id"] == sl_id), None) if not sl or sl.get("status") == "laeuft": return False sl["quittiert"] = True _merken(sl) return True 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()