"""„Jetzt updaten“ im Homelab (Phase 4, User-Entscheide 24.09.2026). Nur per Knopf, mit Erfolgsmeldung und Erreichbarkeits-Check; ist er rot, geht es automatisch zurück und es wird gemeldet. Host-Updates gibt es mit Warnung, der Neustart ist ein eigener Knopf. Ablauf für einen Gast: 1. Rückweg anlegen: ein Snapshot; wo keiner geht (PBS: Bind-Mount), eine Sicherung (vzdump auf einen lokalen Speicher des Hosts, nie auf den PBS); geht beides nicht, ohne Rückweg — die Rückfrage sagt es. Scheitert dieser Schritt, beginnt das Update gar nicht. 2. Update: die App per Community-Script (`update`, still) oder die Pakete des Gasts. Ein frisch geändertes Update-Skript wartet erst MC_HOMELAB_KARENZ_H Stunden (karenz.py). 3. 20 s warten, frischen Bericht holen 4. Prüfen: läuft der Gast, antwortet die Weboberfläche, ist die App-Version jetzt neuer? 5. Rot → zurück auf den Snapshot bzw. die Sicherung zurückspielen → dringende Meldung. Grün → ältere Snapshots bzw. Sicherungen des Orchestrators für diesen Gast weg → Meldung „eingespielt“. 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. """ import json import logging import re import threading import time import uuid from datetime import datetime from pathlib import Path from kern.einstellungen import einstellungen from kern.zeit import LOCAL_TZ from services import announce, update_verlauf from services.homelab import apps, arcane, inventar, kanal, karenz log = logging.getLogger(__name__) WARTEN_NACH_UPDATE_S = 20 ZEITLIMIT_UPDATE_S = 45 * 60 ZEITLIMIT_KURZ_S = 15 * 60 ZEITLIMIT_SICHERUNG_S = 40 * 60 # Sicherung und Zurückspielen (der Ausführer gibt nach 30 min auf) BETREFF = "[Homelab-Update]" _lock = threading.RLock() # _merken liest und schreibt unter derselben Sperre def _pfad() -> Path: return einstellungen().daten_dir / "homelab-laeufe.json" def _laeufe() -> list[dict]: with _lock: try: daten = json.loads(_pfad().read_text(encoding="utf-8")) except (OSError, ValueError): return [] return daten if isinstance(daten, list) else [] def _merken(lauf: dict) -> None: with _lock: laeufe = [x for x in _laeufe() if x["id"] != lauf["id"]] + [lauf] _pfad().parent.mkdir(parents=True, exist_ok=True) tmp = _pfad().with_suffix(".tmp") tmp.write_text(json.dumps(laeufe[-100:], ensure_ascii=False), encoding="utf-8") tmp.replace(_pfad()) def laeufe(anzahl: int = 20) -> list[dict]: return list(reversed(_laeufe()))[:anzahl] def laufende_ziele() -> set[str]: """Ziele mit einem laufenden Update. Liest nur die Datei — geht also auch im Steward (Wächter, Pflege).""" return {str(x.get("ziel")) for x in _laeufe() if x.get("status") == "laeuft"} def laeuft(ziel_id: str) -> bool: return ziel_id in laufende_ziele() def unterbrochene_abschliessen() -> None: """Beim Start: Läufe, die noch „läuft“ heißen, gehörten zum vorigen Prozess.""" for lauf in _laeufe(): if lauf.get("status") == "laeuft": lauf.update(status="unterbrochen", ende=time.time()) lauf["schritte"].append("Der Homelab-Teil wurde während des Laufs neu gestartet.") _merken(lauf) def _melden(text: str, dringend: bool = False) -> None: try: announce.notify_telegram("[Alarm] Homelab-Update" if dringend else BETREFF, text) except Exception: log.warning("homelab: Meldung ging nicht raus", exc_info=True) def _auftrag(lauf: dict, aktion: str, parameter: dict, zeitlimit_s: float) -> dict: """Auftrag an den Ausführer und auf das Ergebnis warten. Wirft RuntimeError bei Fehler/Zeitablauf.""" aid = kanal.anlegen(aktion, parameter) lauf["schritte"].append(f"{aktion} …") _merken(lauf) a = kanal.warten(aid, zeitlimit_s) if a is None: raise RuntimeError(f"{aktion}: keine Antwort des Ausführers in {int(zeitlimit_s // 60)} Minuten") if a["status"] != "fertig": raise RuntimeError(f"{aktion} gescheitert: {str(a.get('text') or '')[-300:]}") lauf["schritte"][-1] = f"{aktion}: ok" return a def _frischer_gast(lauf: dict, vmid: int) -> dict | None: try: a = _auftrag(lauf, "bericht", {}, 5 * 60) bericht = json.loads(a.get("text") or "{}") kanal.bericht_speichern(bericht) except (RuntimeError, ValueError): return None return next((g for g in bericht.get("gaeste") or [] if g.get("vmid") == vmid), None) def pruefen(vorher: dict, nachher: dict | None, baustein: str) -> list[str]: """Was nach dem Update nicht stimmt (leer = grün).""" if nachher is None: return ["Der Gast fehlt im neuen Bericht."] fehler = [] if nachher.get("status") != "running": fehler.append(f"Der Gast läuft nicht ({nachher.get('status')}).") app = apps.app_fuer((nachher.get("app") or {}).get("kennung"), nachher.get("name")) url = apps.adresse(app, nachher.get("ip")) if url and not inventar.erreichbar_frisch(url): fehler.append(f"Die Weboberfläche ({url}) antwortet nicht.") if baustein == "app": alt, neu = (vorher.get("app") or {}).get("version"), (nachher.get("app") or {}).get("version") if alt and neu and alt == neu: fehler.append(f"Die App-Version ist unverändert ({alt}).") return fehler def _rueckweg_anlegen(lauf: dict, gast: dict) -> tuple[str, str] | None: """Vor dem Update: ("snapshot", Name), wo keiner geht ("sicherung", Archiv), sonst None. Wirft RuntimeError, wenn der Schritt scheitert (etwa zu wenig Platz) — dann beginnt das Update gar nicht.""" vmid = gast["vmid"] if gast.get("snapshot_moeglich"): a = _auftrag(lauf, "snapshot", {"vmid": vmid}, ZEITLIMIT_KURZ_S) m = re.search(r"snapshot=(mc2-\d{8}-\d{6})", a.get("text") or "") return ("snapshot", m.group(1)) if m else None if gast.get("sicherung_moeglich"): a = _auftrag(lauf, "sichern", {"vmid": vmid}, ZEITLIMIT_SICHERUNG_S) m = re.search(r"^sicherung=(\S+)$", a.get("text") or "", re.MULTILINE) if not m: raise RuntimeError("sichern: Der Ausführer nannte keine Sicherung") return ("sicherung", m.group(1)) return None def _zurueck(lauf: dict, vmid: int, rueckweg: tuple[str, str]) -> str: """Den Rückweg gehen; Rückgabe: was geschah (für die Meldung). Wirft RuntimeError, wenn er scheitert.""" art, name = rueckweg if art == "snapshot": _auftrag(lauf, "zurueck", {"vmid": vmid, "snapshot": name}, ZEITLIMIT_KURZ_S) return f"automatisch zurück auf den Snapshot {name}" _auftrag(lauf, "sicherung_zurueck", {"vmid": vmid, "sicherung": name}, ZEITLIMIT_SICHERUNG_S) return f"automatisch die Sicherung von vorher zurückgespielt ({name.rsplit('/', 1)[-1]})" def _gast_lauf(lauf: dict, gast: dict) -> None: vmid, baustein = gast["vmid"], lauf["baustein"] try: rueckweg = _rueckweg_anlegen(lauf, gast) except RuntimeError as exc: _ende(lauf, "fehler", f"{lauf['name']}: Update nicht begonnen — {str(exc).rstrip('.')}. " "Am Gerät wurde nichts geändert.") return try: _auftrag(lauf, "update" if baustein == "app" else "os_update", {"vmid": vmid}, ZEITLIMIT_UPDATE_S) time.sleep(WARTEN_NACH_UPDATE_S) fehler = pruefen(gast, _frischer_gast(lauf, vmid), baustein) except RuntimeError as exc: fehler = [str(exc)] if not fehler: _aufraeumen(lauf, vmid, rueckweg) _ende(lauf, "eingespielt", f"{lauf['name']}: eingespielt, Prüfung grün.") return if rueckweg: try: wie = _zurueck(lauf, vmid, rueckweg) _ende(lauf, "zurueckgerollt", f"{lauf['name']}: Update gescheitert ({' '.join(fehler)}) — {wie}. Läuft wieder wie vorher.", dringend=True) return except RuntimeError as exc: fehler.append(f"Auch der Rückweg scheiterte: {exc}") if rueckweg[0] == "sicherung": fehler.append(f"Die Sicherung {rueckweg[1]} liegt weiter auf dem Proxmox-Host.") _ende(lauf, "fehler", f"{lauf['name']}: Update gescheitert — {' '.join(fehler)}" + ("" if rueckweg else " Es gab weder Snapshot noch Sicherung, also keinen automatischen " "Rückweg."), dringend=True) 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.""" art, behalten = rueckweg or (None, None) gast = inventar.gast(lauf["ziel"]) or {} for name in gast.get("snapshots") or []: if name.startswith("mc2-") and name != behalten: try: _auftrag(lauf, "snapshot_loeschen", {"vmid": vmid, "snapshot": name}, ZEITLIMIT_KURZ_S) except RuntimeError: lauf["schritte"].append(f"Alter Snapshot {name} blieb stehen.") if art != "sicherung": return for volid in gast.get("sicherungen") or []: if volid != behalten: try: _auftrag(lauf, "sicherung_loeschen", {"vmid": vmid, "sicherung": volid}, ZEITLIMIT_KURZ_S) except RuntimeError: lauf["schritte"].append(f"Alte Sicherung {volid} blieb stehen.") def _docker_lauf(lauf: dict, basis: str) -> None: """Docker über Arcanes Updater — zuerst nur als Probelauf (User-Entscheid 24.09.2026).""" probe = not arcane.echt() try: ergebnisse = arcane.updater(basis, probelauf=probe) except Exception as exc: _ende(lauf, "fehler", f"Arcane: Updater gescheitert — {exc}", dringend=not probe) return gesamt = {k: sum(int((e or {}).get(k) or 0) for e in ergebnisse.values()) for k in ("checked", "updated", "failed")} namen = [str(i.get("resourceName")) for e in ergebnisse.values() for i in (e or {}).get("items") or [] if isinstance(i, dict) and i.get("updateAvailable")] if probe: _ende(lauf, "offen", f"Arcane-Probelauf: {len(namen)} von {gesamt['checked']} Containern würden aktualisiert" + (f" ({', '.join(namen[:8])})" if namen else "") + ". Geändert wurde nichts.") elif gesamt["failed"]: _ende(lauf, "fehler", f"Arcane: {gesamt['failed']} Container ließen sich nicht aktualisieren, " f"{gesamt['updated']} schon.", dringend=True) else: ok = inventar.erreichbar_frisch(basis + "/") _ende(lauf, "eingespielt" if ok else "fehler", f"Arcane: {gesamt['updated']} Container aktualisiert" + ("." if ok else ", aber Arcane antwortet danach nicht."), dringend=not ok) def _host_lauf(lauf: dict) -> None: try: if lauf["baustein"] == "neustart": _auftrag(lauf, "host_neustart", {}, 60) _ende(lauf, "neustart", "Der Proxmox-Host startet neu. Die KI-Box meldet, ob alles wiederkommt.") return _auftrag(lauf, "host_update", {}, 60 * 60) _auftrag(lauf, "bericht", {}, 5 * 60) _ende(lauf, "eingespielt", "Proxmox-Host: Pakete eingespielt. Ein Neustart ist ein eigener Knopf.") except RuntimeError as exc: _ende(lauf, "fehler", f"Proxmox-Host: Update gescheitert — {exc}", dringend=True) def _ende(lauf: dict, ergebnis: str, text: str, dringend: bool = False) -> None: lauf.update(status="fertig", ergebnis=ergebnis, text=text, ende=time.time()) _merken(lauf) try: start = datetime.fromtimestamp(lauf["start"], LOCAL_TZ).isoformat(timespec="seconds") update_verlauf.eintragen(start, "Update von Hand", lauf["ziel"], ergebnis, text) except Exception: log.warning("homelab: Verlauf nicht geschrieben", exc_info=True) _melden(text, dringend=dringend) def _neuer_lauf(ziel_id: str, baustein: str, name: str) -> dict: lauf = {"id": uuid.uuid4().hex[:12], "ziel": ziel_id, "baustein": baustein, "name": name, "status": "laeuft", "start": time.time(), "ende": None, "ergebnis": None, "text": None, "schritte": []} _merken(lauf) return lauf def starten(ziel_id: str, baustein: str) -> dict: """Knopf „Jetzt updaten“. Rückgabe {"ok": True, "lauf": id} oder {"ok": False, "detail": …}.""" if baustein not in ("app", "os", "pakete", "neustart", "docker"): return {"ok": False, "detail": "Unbekannter Baustein."} if laeuft(ziel_id): return {"ok": False, "detail": "Für dieses Gerät läuft schon ein Update."} if ziel_id == "pve": name, gast = "Proxmox-Host", None else: gast = inventar.gast(ziel_id) if not gast: return {"ok": False, "detail": "Dieses Gerät steht nicht im Bericht des Ausführers."} if baustein == "docker": app = apps.app_fuer(None, gast.get("name")) basis = (apps.adresse(app, gast.get("ip")) or "").rstrip("/") if not (app and app.kennung == "arcane" and basis and arcane.schluessel()): return {"ok": False, "detail": "Docker-Updates gehen nur über Arcane mit API-Schlüssel."} lauf = _neuer_lauf(ziel_id, baustein, app.name) threading.Thread(target=_docker_lauf, args=(lauf, basis), name=f"homelab-{ziel_id}", daemon=True).start() return {"ok": True, "lauf": lauf["id"]} if not gast.get("erlaubt"): 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 == "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)."} if baustein == "app" and (sperre := karenz.pruefen(app.kennung)["gesperrt"]): return {"ok": False, "detail": sperre} lauf = _neuer_lauf(ziel_id, baustein, name) ziel = _host_lauf if gast is None else (lambda lf: _gast_lauf(lf, gast)) threading.Thread(target=ziel, args=(lauf,), name=f"homelab-{ziel_id}", daemon=True).start() return {"ok": True, "lauf": lauf["id"]}