251 lines
11 KiB
Python
251 lines
11 KiB
Python
"""„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. Snapshot (wo möglich; beim PBS verhindert der Bind-Mount ihn — dann ohne Rückweg, die Rückfrage sagt es)
|
|
2. Update: die App per Community-Script (`update`, still) oder die Pakete des Gasts
|
|
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 und Snapshot da → zurück auf den Snapshot → dringende Meldung.
|
|
Grün → ältere Snapshots des Orchestrators für diesen Gast weg → Meldung „eingespielt“.
|
|
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.
|
|
"""
|
|
|
|
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
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
WARTEN_NACH_UPDATE_S = 20
|
|
ZEITLIMIT_UPDATE_S = 45 * 60
|
|
ZEITLIMIT_KURZ_S = 15 * 60
|
|
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 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 _gast_lauf(lauf: dict, gast: dict) -> None:
|
|
vmid, baustein = gast["vmid"], lauf["baustein"]
|
|
snapshot = None
|
|
try:
|
|
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 "")
|
|
snapshot = m.group(1) if m else None
|
|
_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, behalten=snapshot)
|
|
_ende(lauf, "eingespielt", f"{lauf['name']}: eingespielt, Prüfung grün.")
|
|
return
|
|
if snapshot:
|
|
try:
|
|
_auftrag(lauf, "zurueck", {"vmid": vmid, "snapshot": snapshot}, ZEITLIMIT_KURZ_S)
|
|
_ende(lauf, "zurueckgerollt",
|
|
f"{lauf['name']}: Update gescheitert ({' '.join(fehler)}) — automatisch zurück auf den Snapshot "
|
|
f"{snapshot}. Läuft wieder wie vorher.", dringend=True)
|
|
return
|
|
except RuntimeError as exc:
|
|
fehler.append(f"Auch der Rückweg scheiterte: {exc}")
|
|
_ende(lauf, "fehler", f"{lauf['name']}: Update gescheitert — {' '.join(fehler)}"
|
|
+ ("" if snapshot else " Es gab keinen Snapshot, also keinen automatischen Rückweg."),
|
|
dringend=True)
|
|
|
|
|
|
def _aufraeumen(lauf: dict, vmid: int, behalten: str | None) -> None:
|
|
"""Ältere Snapshots des Orchestrators für diesen Gast löschen; den neuesten behalten."""
|
|
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.")
|
|
|
|
|
|
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 any(x.get("ziel") == ziel_id and x.get("status") == "laeuft" for x in _laeufe()):
|
|
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"))
|
|
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"]}
|