Files
mission-control-v2/backend/services/homelab/updates.py
T
HitonabiandClaude Opus 5.5 5e9227c4a3 phase3+4: Homelab-Teil (Ausfuehrer, Ziele, Jetzt updaten mit Rueckweg) und Seite Homelab fuer alle Geraete
- deploy/homelab/ausfuehrer.py: laeuft als root auf dem Proxmox-Host, holt Auftraege beim Homelab-Teil
  ab (Pull, kein offener Port), feste Aktionsliste, prueft Etiketten selbst; Bericht gegen den echten
  Host erprobt (nur lesend). Kein Proxmox-Schluessel im Container noetig.
- services/homelab: Kanal mit gemeinsamem Geheimnis, App-Katalog, Inventar -> Ziele im gemeinsamen
  Modell (GitHub-Versionen, Webpruefung, alte Paketlisten = unklar), Jetzt updaten: Snapshot ->
  Update -> Pruefung -> bei Rot zurueck + dringende Meldung
- Waechter in der Rolle homelab: Ausfuehrer schweigt, Gaeste antworten nicht
- kern/github.py fuer beide Rollen (auch untagged Releases mit Version im Namen)
- Oberflaeche: Seite Homelab zeigt alle Geraete als Karten (KI-Box ueber /api/ziele, Homelab ueber
  /api/homelab/ziele) mit Stand, Rueckweg und Knopf samt Rueckfrage
- Einrichtung als Skripte (container-anlegen, ausrollen, ausfuehrer-einrichten, box-partner) — noch
  nicht ausgefuehrt, jeder Schritt braucht das User-OK
- frontend-bauen-box.sh: Frontend-Pruefung und Build auf der Box, wenn der PC keinen Speicher hat

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 18:39:59 +02:00

213 lines
9.1 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, 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.Lock()
def _pfad() -> Path:
return einstellungen().daten_dir / "homelab-laeufe.json"
def _laeufe() -> list[dict]:
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 _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 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"):
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 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 = {"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)
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"]}