Die Seite /homelab/snapshots (in der Seitenleiste nach „Updates“: Snapshots sind der Rückweg der Updates) zeigt je Gerät die Snapshots mit Zeitpunkt, Alter, Herkunft (vor Update / per Knopf / von Hand) und Beschreibung, beim PBS die Sicherungen des Orchestrators mit Größe. Knöpfe „Snapshot anlegen“, „Zurücksetzen“ und „Löschen“ gibt es nur für freigegebene Geräte und nur für mc2-Snapshots bzw. eigene Sicherungen; von Hand angelegte bleiben reine Ansicht. Die Rückfragen kommen vom Homelab-Teil (wie im Ziel-Modell); Zurücksetzen sagt vorher, was verloren geht und dass das Gerät neu startet. Am Handy (320–400 px) untereinander, ab md Knöpfe rechts, ab xl fünf Spalten. Entscheide: - Ausführer: kein neuer Befehl. Der Bericht bringt zusätzlich snapshot_details (snaptime, Beschreibung, eigen) und sicherung_details (ctime, Größe); snapshots und sicherungen bleiben unverändert. Keine Belegung je Snapshot — die kennt Proxmox bei LVM-Thin nicht, die Seite sagt das so. „snapshot“ nimmt einen Anlass (update oder knopf) und wählt damit eine feste Beschreibung, damit ein Snapshot auf Knopfdruck nicht „Vor einem Update“ heißt. Bis der neue Ausführer eingespielt ist, liest der Homelab-Teil den Zeitpunkt aus dem Namen. - Aktionen sind Läufe in homelab-laeufe.json (Mechanik aus updates.py): gleiche Sperren (START_SPERRE, je Gerät einer, SAMMELLAUF_SPERRE), Protokoll-Art „snapshot“ mit eigenen Titeln, der Wächter lässt ein Gerät im Zurücksetzen in Ruhe. Zusätzlich keine Aktion, solange irgendwo ein Update läuft: Der Ausführer arbeitet einen Auftrag nach dem anderen ab — hinter einem Update liefe die Aktion in ihr Zeitlimit und käme danach unbeobachtet doch noch dran. - Zurücksetzen prüft danach wie ein Update und meldet immer ([Homelab-Snapshot], bei Rot als Alarm); anlegen und löschen melden nur ihr Scheitern, das Ergebnis steht beim Gerät. Nicht im Update-Verlauf, nicht unter „Zuletzt eingespielt“. - Per Knopf angelegte Snapshots räumt kein grünes Update weg (updates._aufraeumen fragt snapshots.knopf_namen). - speicher.py: alle Snapshots in einem Vorschlag mit Verweis auf die Seite statt einer je Snapshot. - Nebenbei: Der Update-Verlauf stürzte bei einem Ergebnis ohne Eintrag ab (etwa „offen“ nach einem Docker-Probelauf); die Protokoll-Seite stürzte im Entwicklungsmodus ab (Konstante „Symbol“ gegen das Symbol.for des React Compilers); Auftragstitel „Alter Snapshot gelöscht“ heißt jetzt „Snapshot gelöscht“ (gelöscht wird auch von Hand). Tests: pytest test_homelab_snapshots.py (Ausführer-Details, Liste, Läufe mit nachgespieltem Ausführer, Sperren, Aufräumen, Schnittstelle), test_homelab_speicher.py; Vitest SnapshotListe, lib/snapshots, Speicher. Prüftor grün. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
417 lines
21 KiB
Python
417 lines
21 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 (nicht die, die jemand auf der
|
||
Seite Snapshots per Knopf angelegt hat, snapshots.py) → Meldung „eingespielt“.
|
||
Docker-Images (Arcane, VM 106) gehen über Arcanes eigenen Updater: bis zum Schalter „echt“ nur als Probelauf,
|
||
danach genauso mit Snapshot vorher und Rückweg bei Rot — sofern die VM freigegeben ist (Etikett watcher, seit
|
||
25.09.), sonst ohne; Rückfrage und Update-Liste sagen es. Nach grüner Prüfung geht dieser Snapshot gleich wieder weg.
|
||
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)
|
||
ARCANE_FRIST_S = 180 # so lange darf Arcane nach einem Docker-Update brauchen, bis es wieder antwortet
|
||
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. Ebenso bleiben Snapshots, die jemand auf der Seite Snapshots per
|
||
Knopf angelegt hat (seit 25.09.2026): Die löscht nur der User."""
|
||
from services.homelab import snapshots # snapshots importiert dieses Modul
|
||
art, behalten = rueckweg or (None, None)
|
||
gast = inventar.gast(lauf["ziel"]) or {}
|
||
per_knopf = snapshots.knopf_namen(lauf["ziel"], gast)
|
||
for name in gast.get("snapshots") or []:
|
||
if name.startswith("mc2-") and name != behalten and name not in per_knopf:
|
||
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 _antwortet_wieder(url: str) -> bool:
|
||
"""Nach dem Update starten Container neu: Arcane darf sich ARCANE_FRIST_S lang sammeln, bevor es rot heißt."""
|
||
ende = time.monotonic() + ARCANE_FRIST_S
|
||
while not inventar.erreichbar_frisch(url):
|
||
if time.monotonic() >= ende:
|
||
return False
|
||
time.sleep(10)
|
||
return True
|
||
|
||
|
||
def _docker_lauf(lauf: dict, basis: str, gast: dict) -> None:
|
||
"""Docker über Arcanes Updater — ein Probelauf, bis der Schalter in den Einstellungen auf „echt“ steht
|
||
(User-Entscheid 24.09.2026). Echt wie bei den Containern: vorher ein Snapshot der VM, wenn der Ausführer sie
|
||
anfassen darf (Etikett watcher); ist die Prüfung danach rot, geht es darauf zurück."""
|
||
probe = not arcane.echt()
|
||
rueckweg = None
|
||
if not probe and gast.get("erlaubt"):
|
||
try:
|
||
rueckweg = _rueckweg_anlegen(lauf, gast)
|
||
except RuntimeError as exc:
|
||
_ende(lauf, "fehler", f"Arcane: Update nicht begonnen — {str(exc).rstrip('.')}. "
|
||
"Am Gerät wurde nichts geändert.")
|
||
return
|
||
try:
|
||
ergebnisse = arcane.updater(basis, probelauf=probe)
|
||
except Exception as exc:
|
||
if probe:
|
||
_ende(lauf, "fehler", f"Arcane: Probelauf gescheitert — {exc}")
|
||
return
|
||
ergebnisse, fehler = {}, [f"Der Updater brach ab ({exc})."]
|
||
else:
|
||
fehler = []
|
||
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.")
|
||
return
|
||
if gesamt["failed"]:
|
||
fehler.append(f"{gesamt['failed']} Container ließen sich nicht aktualisieren, {gesamt['updated']} schon.")
|
||
if not _antwortet_wieder(basis + "/"):
|
||
fehler.append("Arcane antwortet danach nicht.")
|
||
if not fehler:
|
||
if rueckweg:
|
||
_aufraeumen(lauf, gast["vmid"], rueckweg)
|
||
# Anders als bei den Containern bleibt auch dieser nicht stehen: Die VM schreibt laufend (Docker, Rippy),
|
||
# ein Snapshot wüchse im Thin-Pool bis zum nächsten Update mit. Sein Zweck war der Weg zurück bei Rot.
|
||
try:
|
||
_auftrag(lauf, "snapshot_loeschen", {"vmid": gast["vmid"], "snapshot": rueckweg[1]}, ZEITLIMIT_KURZ_S)
|
||
except RuntimeError:
|
||
lauf["schritte"].append(f"Snapshot {rueckweg[1]} blieb stehen.")
|
||
_ende(lauf, "eingespielt", f"Arcane: {gesamt['updated']} Container aktualisiert, Prüfung grün.")
|
||
return
|
||
if rueckweg:
|
||
try:
|
||
wie = _zurueck(lauf, gast["vmid"], rueckweg)
|
||
_ende(lauf, "zurueckgerollt", f"Arcane: 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}")
|
||
_ende(lauf, "fehler", f"Arcane: Update gescheitert — {' '.join(fehler)}"
|
||
+ ("" if rueckweg else " Es gab keinen Snapshot, also keinen automatischen Rückweg."),
|
||
dringend=True)
|
||
|
||
|
||
def _host_lauf(lauf: dict) -> None:
|
||
try:
|
||
if lauf["baustein"] == "kernel":
|
||
# Welche Kernel gehen, bestimmt der Ausführer selbst (nie der laufende oder der nächste).
|
||
a = _auftrag(lauf, "kernel_aufraeumen", {}, ZEITLIMIT_SICHERUNG_S)
|
||
werte = dict(z.split("=", 1) for z in str(a.get("text") or "").splitlines()[:2] if "=" in z)
|
||
anzahl, frei = int(werte.get("entfernt") or 0), int(werte.get("frei") or 0)
|
||
_auftrag(lauf, "bericht", {}, 5 * 60)
|
||
_ende(lauf, "aufgeraeumt", f"Proxmox-Host: {anzahl} alte Kernel entfernt, "
|
||
f"{frei / 1e9:.1f} GB frei geworden.".replace(".", ",", 1)
|
||
if anzahl else "Proxmox-Host: kein alter Kernel zu entfernen.")
|
||
return
|
||
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:
|
||
was = "Kernel aufräumen" if lauf["baustein"] == "kernel" else "Update"
|
||
_ende(lauf, "fehler", f"Proxmox-Host: {was} 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", "kernel"):
|
||
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 {"kernel": len(host.get("kernel_alt") or [])} if baustein == "kernel" 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, gast), 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"]}
|