- karenz.py: ct/<app>.sh juenger als MC_HOMELAB_KARENZ_H (Standard 48 h) -> Baustein "neu" ohne Knopf, updates.starten lehnt mit demselben Satz ab (Berliner Zeit). GitHub stumm -> nicht blockieren, die Rueckfrage sagt es. - Ausfuehrer: neue Aktionen sichern, sicherung_zurueck, sicherung_loeschen. vzdump auf den ersten lokalen Speicher mit Inhalt backup (oder sicherung_speicher aus /etc/mc2-ausfuehrer.json), nie auf pbs; vorher Platz pruefen (frei > belegt x 1,2); Notiz mc2-sicherung, nur solche werden zurueckgespielt/geloescht; Rueckweg: stoppen, pct restore --force auf den bisherigen rootfs-Speicher, starten. Bericht mit host.sicherung, sicherung_moeglich/_grund, eigenen Sicherungen und nur_lesen. Unerwartete Fehler werden beantwortet statt verschluckt; vzdump/restore beim Zeitlimit erst SIGTERM. - updates.py: Sicherung, wo kein Snapshot geht; bei Rot zurueckspielen, nach Gruen aeltere Sicherungen weg. Scheitert Snapshot oder Sicherung, beginnt das Update nicht (nicht dringend gemeldet). - pflege.py: "suchen" fuer Gaeste mit Paketlisten aelter als 7 Tage, nachts 02-05 Uhr (sonst nachholen), einmal je Gast und Tag, nie neben einem Update oder offenen Auftrag; gelber Waechter-Hinweis, wenn es zweimal hintereinander scheitert. Eine Registrierungszeile in waechter.py. - kanal.py: Auftragsliste unter flock, weil jetzt auch der Steward Auftraege anlegt. - inventar.py: Rueckweg und Rueckfrage fuer die Sicherung; Gaeste mitten im Update-Lauf nicht in der Webpruefung des Waechters (sonst zweiter Alarm beim Zurueckspielen). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
310 lines
14 KiB
Python
310 lines
14 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.
|
|
"""
|
|
|
|
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"]}
|