- Sammellauf (services/homelab/sammellauf.py): alle Updates mit Knopf nacheinander über updates.starten, Gäste nach VMID, dann NPMplus und AdGuard, zuletzt die Host-Pakete (nie der Neustart). Rot stoppt den Rest (übersprungen, abgebrochen, dringende Meldung); grün gibt eine Sammelmeldung, die Erfolgsmeldungen der Schritte bleiben so lange aus. Nur einer gleichzeitig, der einzelne Knopf lehnt solange ab (START_SPERRE). Ein Neustart mitten im Lauf macht ihn beim Start zu „abgebrochen“, „unterbrochen (Neustart)“. - Protokoll (services/homelab/protokoll.py): Aufträge, Läufe, Sammelläufe, Wächter-Verlauf, Melde-Log und wöchentliches Suchen, neueste zuerst, ohne Geheimnisse. Der Kanal hebt 500 erledigte Aufträge auf und vermerkt eingehende Berichte; die Pflege führt eine kurze Ereignisliste, der Wächter-Verlauf die Stufe. - Einstellungen (services/homelab/einstellungen.py): Arcane-Schlüssel erst gegen Arcane prüfen, dann in arcane.key (0600) speichern, nie zurückgeben; echte Docker-Updates per Schalter. Die Umgebung geht vor. - Schnittstellen als eigener Block in routers/homelab.py vor den Ausführer-Endpunkten. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
354 lines
17 KiB
Python
354 lines
17 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. 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 <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.
|
||
|
||
„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
|
||
am Ende sagt es; Fehler werden weiter sofort gemeldet.
|
||
"""
|
||
|
||
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]"
|
||
BETREFF_ALARM = "[Alarm] Homelab-Update"
|
||
SAMMELLAUF_SPERRE = "Es läuft gerade ‚Alle aktualisieren‘."
|
||
|
||
_lock = threading.RLock() # _merken liest und schreibt unter derselben Sperre
|
||
# Prüfen und Anlegen eines Laufs am Stück — auch „Alle aktualisieren“ legt sich unter ihr an. Sonst könnte ein
|
||
# einzelner Knopf genau zwischen Prüfung und Anlegen des Sammellaufs durchrutschen.
|
||
START_SPERRE = threading.Lock()
|
||
|
||
|
||
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 lauf_lesen(lauf_id: str) -> dict | None:
|
||
return next((x for x in _laeufe() if x.get("id") == lauf_id), None)
|
||
|
||
|
||
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 — ebenso ein laufendes
|
||
„Alle aktualisieren“ (danach, damit es den unterbrochenen Lauf schon als solchen sieht)."""
|
||
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)
|
||
from services.homelab import sammellauf # sammellauf importiert dieses Modul
|
||
sammellauf.unterbrochene_abschliessen()
|
||
|
||
|
||
def _sammellauf_laeuft() -> bool:
|
||
from services.homelab import sammellauf
|
||
return sammellauf.laeuft()
|
||
|
||
|
||
def _melden(text: str, dringend: bool = False) -> None:
|
||
try:
|
||
announce.notify_telegram(BETREFF_ALARM 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, aus_auftrag=True)
|
||
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)
|
||
nachher = _frischer_gast(lauf, vmid)
|
||
if baustein == "app" and nachher:
|
||
lauf["version_neu"] = (nachher.get("app") or {}).get("version") # fürs Protokoll
|
||
fehler = pruefen(gast, nachher, 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:
|
||
# Schritt von „Alle aktualisieren“: Den Erfolg sagt die Sammelmeldung am Ende; Fehler gehen sofort raus.
|
||
still = bool(lauf.get("sammellauf")) and ergebnis == "eingespielt" and not dringend
|
||
lauf.update(status="fertig", ergebnis=ergebnis, text=text, ende=time.time(), dringend=dringend,
|
||
gemeldet=not still)
|
||
_merken(lauf)
|
||
try:
|
||
# 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)
|
||
anlass = "Alle aktualisieren" if lauf.get("sammellauf") else "Update von Hand"
|
||
update_verlauf.eintragen(start.isoformat(timespec="seconds"), anlass, lauf["ziel"], ergebnis, text)
|
||
except Exception:
|
||
log.warning("homelab: Verlauf nicht geschrieben", exc_info=True)
|
||
if not still:
|
||
_melden(text, dringend=dringend)
|
||
|
||
|
||
def _neuer_lauf(ziel_id: str, baustein: str, name: str, sammellauf: dict | None = None, **mehr: object) -> 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": [], **mehr}
|
||
if sammellauf:
|
||
lauf.update(sammellauf=sammellauf["id"], sammellauf_start=sammellauf["start"])
|
||
_merken(lauf)
|
||
return lauf
|
||
|
||
|
||
def starten(ziel_id: str, baustein: str, sammellauf: dict | None = None) -> dict:
|
||
"""Knopf „Jetzt updaten“. Rückgabe {"ok": True, "lauf": id} oder {"ok": False, "detail": …}.
|
||
|
||
sammellauf ({"id", "start"}) setzt nur „Alle aktualisieren“ (sammellauf.py) für seine Schritte; ohne ihn lehnt
|
||
der Knopf ab, solange ein Sammellauf läuft."""
|
||
if baustein not in ("app", "os", "pakete", "neustart", "docker"):
|
||
return {"ok": False, "detail": "Unbekannter Baustein."}
|
||
with START_SPERRE:
|
||
if sammellauf is None and _sammellauf_laeuft():
|
||
return {"ok": False, "detail": SAMMELLAUF_SPERRE}
|
||
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
|
||
host = ((kanal.bericht() or {}).get("bericht") or {}).get("host") or {}
|
||
mehr = {"pakete": len(host.get("updates") or [])} if baustein == "pakete" else {}
|
||
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, sammellauf)
|
||
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}
|
||
# Was vorher war, fürs Protokoll: die App-Version bzw. die Zahl der Pakete.
|
||
mehr = ({"version_alt": (gast.get("app") or {}).get("version")} if baustein == "app"
|
||
else {"pakete": (gast.get("os_updates") or {}).get("anzahl")})
|
||
lauf = _neuer_lauf(ziel_id, baustein, name, sammellauf, **mehr)
|
||
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"]}
|