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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a47489fa85
commit
5e9227c4a3
@@ -0,0 +1,212 @@
|
||||
"""„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"]}
|
||||
Reference in New Issue
Block a user