homelab: „Alle aktualisieren“, Protokoll und Arcane-Schlüssel über die Oberfläche
- 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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
bf1153cb66
commit
8ec261b8b2
@@ -0,0 +1,329 @@
|
||||
"""„Alle aktualisieren“ im Homelab (24.09.2026): alle Updates mit Knopf nacheinander.
|
||||
|
||||
Jeder Schritt ist ein gewöhnlicher Lauf von „Jetzt updaten“ (updates.starten) und wartet, bis dieser fertig ist —
|
||||
mit Rückweg, Prüfung und Zurückrollen wie beim einzelnen Knopf. Reihenfolge (User-Entscheid 24.09.2026):
|
||||
1. die Gäste nach VMID, außer NPMplus und AdGuard Home
|
||||
2. NPMplus, dann AdGuard Home: Nadelöhre (Proxy, DNS) — fällt einer aus, hängt anderes mit
|
||||
3. ganz zuletzt die Pakete des Proxmox-Hosts; neu gestartet wird der Host dabei nie
|
||||
Dabei ist nur, was „neu“ ist und einen Knopf hat. Was in der Wartezeit nach einer Skriptänderung steht (karenz.py)
|
||||
oder sich über die eigene Oberfläche aktualisiert, hat keinen Knopf und fehlt deshalb. Docker über Arcane ist nur
|
||||
dabei, wenn die Updates echt laufen: Ein Probelauf ändert nichts.
|
||||
|
||||
Endet ein Schritt mit „zurueckgerollt“ oder „fehler“, hört der Sammellauf auf: Die übrigen Schritte sind
|
||||
„uebersprungen“, er selbst ist „abgebrochen“, und es geht eine dringende Meldung raus. Läuft alles durch, kommt eine
|
||||
Sammelmeldung; die Erfolgsmeldungen der einzelnen Schritte hält updates._ende() so lange zurück, Fehlermeldungen
|
||||
nicht. Lehnt updates.starten einen Schritt ab (am Gerät ändert sich dann nichts, etwa weil die Wartezeit inzwischen
|
||||
greift), wird er übersprungen, und es geht weiter.
|
||||
|
||||
Es läuft höchstens ein Sammellauf, und solange er läuft, lehnt der einzelne Knopf ab (updates.SAMMELLAUF_SPERRE).
|
||||
Stand in <Datenordner>/homelab-sammellauf.json (die letzten BEHALTEN). Startet der Homelab-Teil mitten im Lauf neu,
|
||||
gilt er als abgebrochen, „unterbrochen (Neustart)“ (über updates.unterbrochene_abschliessen beim Start).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from kern.einstellungen import einstellungen
|
||||
|
||||
from services import announce
|
||||
from services.homelab import apps, arcane, inventar, kanal, updates
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
NADELOEHRE = ("npmplus", "adguard") # nach allen anderen Gästen, in dieser Reihenfolge
|
||||
GAST_BAUSTEINE = ("app", "os", "docker")
|
||||
TAKT_S = 2.0
|
||||
WARTEN_MAX_S = 4 * 3600 # Sicherheitsnetz; ein Lauf hat je Schritt eigene Zeitlimits (höchstens 45 min)
|
||||
BEHALTEN = 20
|
||||
UNTERBROCHEN = "unterbrochen (Neustart)"
|
||||
HOST_HINWEIS = "Der Proxmox-Host kommt zuletzt und hat keinen Rückweg; neu gestartet wird er nicht."
|
||||
# Was ein Lauf als Ergebnis hat → Stand des Schritts. „offen“ ist ein Arcane-Probelauf: geändert wurde nichts.
|
||||
STATUS = {"eingespielt": "eingespielt", "zurueckgerollt": "zurueckgerollt", "fehler": "fehler",
|
||||
"offen": "uebersprungen"}
|
||||
# Die Felder, die die Oberfläche bekommt (Vertrag GET /api/homelab/sammellauf); der Rest ist für das Protokoll.
|
||||
FELDER = ("id", "status", "start", "ende", "aktuell", "schritte", "text")
|
||||
SCHRITT_FELDER = ("ziel", "name", "baustein", "was", "status", "lauf", "text")
|
||||
|
||||
_lock = threading.RLock() # _merken liest und schreibt unter derselben Sperre
|
||||
|
||||
|
||||
# --- Stand ------------------------------------------------------------------------------------------
|
||||
|
||||
def _pfad() -> Path:
|
||||
return einstellungen().daten_dir / "homelab-sammellauf.json"
|
||||
|
||||
|
||||
def _alle() -> list[dict]:
|
||||
with _lock:
|
||||
try:
|
||||
daten = json.loads(_pfad().read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return []
|
||||
return [x for x in daten if isinstance(x, dict) and x.get("id")] if isinstance(daten, list) else []
|
||||
|
||||
|
||||
def _merken(sl: dict) -> None:
|
||||
with _lock:
|
||||
alle = [x for x in _alle() if x["id"] != sl["id"]] + [sl]
|
||||
_pfad().parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = _pfad().with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(alle[-BEHALTEN:], ensure_ascii=False), encoding="utf-8")
|
||||
tmp.replace(_pfad())
|
||||
|
||||
|
||||
def oeffentlich(sl: dict | None) -> dict | None:
|
||||
"""Ein Sammellauf, wie ihn die Oberfläche bekommt (nur die Felder des Vertrags, als Kopie)."""
|
||||
if sl is None:
|
||||
return None
|
||||
return {**{k: sl.get(k) for k in FELDER},
|
||||
"schritte": [{k: s.get(k) for k in SCHRITT_FELDER} for s in sl.get("schritte") or []]}
|
||||
|
||||
|
||||
def liste() -> list[dict]:
|
||||
"""Alle aufbewahrten Sammelläufe, neueste zuerst, mit allen Feldern (für das Protokoll)."""
|
||||
return list(reversed(_alle()))
|
||||
|
||||
|
||||
def aktueller() -> dict | None:
|
||||
"""Der laufende oder, wenn keiner läuft, der letzte Sammellauf."""
|
||||
alle = _alle()
|
||||
return oeffentlich(next((x for x in reversed(alle) if x.get("status") == "laeuft"), alle[-1] if alle else None))
|
||||
|
||||
|
||||
def laeuft() -> bool:
|
||||
return any(x.get("status") == "laeuft" for x in _alle())
|
||||
|
||||
|
||||
def _aufzaehlung(namen: list[str]) -> str:
|
||||
"""„A“, „A und B“, „A, B und C“."""
|
||||
namen = list(dict.fromkeys(namen))
|
||||
return namen[0] if len(namen) == 1 else ", ".join(namen[:-1]) + " und " + namen[-1]
|
||||
|
||||
|
||||
def _melden(text: str, dringend: bool = False) -> None:
|
||||
"""Derselbe Meldeweg und Betreff wie beim einzelnen Knopf (updates.py)."""
|
||||
try:
|
||||
announce.notify_telegram(updates.BETREFF_ALARM if dringend else updates.BETREFF, text)
|
||||
except Exception:
|
||||
log.warning("homelab: Meldung zu „Alle aktualisieren“ ging nicht raus", exc_info=True)
|
||||
|
||||
|
||||
# --- Plan -------------------------------------------------------------------------------------------
|
||||
|
||||
def _kennung(ziel_id: str) -> str | None:
|
||||
gast = inventar.gast(ziel_id) or {}
|
||||
app = apps.app_fuer((gast.get("app") or {}).get("kennung"), gast.get("name"))
|
||||
return app.kennung if app else None
|
||||
|
||||
|
||||
def _rang(schritt: dict) -> tuple[int, int]:
|
||||
"""Erst alle anderen Gäste nach VMID, dann die Nadelöhre in ihrer Reihenfolge."""
|
||||
kennung = _kennung(schritt["ziel"])
|
||||
nadel = NADELOEHRE.index(kennung) + 1 if kennung in NADELOEHRE else 0
|
||||
return nadel, int(schritt["ziel"].split("-", 1)[1])
|
||||
|
||||
|
||||
def plan() -> dict:
|
||||
"""Was „Alle aktualisieren“ jetzt täte, in dieser Reihenfolge, samt Hinweis auf das, was keinen Rückweg hat.
|
||||
{"schritte": [{"ziel", "name", "baustein", "was", "rueckweg_art"}], "hinweis": str | None}"""
|
||||
gaeste: list[dict] = []
|
||||
host: list[dict] = []
|
||||
docker_probe = False
|
||||
for z in inventar.ziele()["ziele"]:
|
||||
for b in z.get("bausteine") or []:
|
||||
if b.get("zustand") != "neu" or not b.get("aktion"):
|
||||
continue
|
||||
schritt = {"ziel": z["id"], "name": z["name"], "baustein": b["id"], "was": b.get("kurz") or b["name"],
|
||||
"rueckweg_art": z.get("rueckweg_art") or "keiner"}
|
||||
if z["id"] == "pve":
|
||||
if b["id"] == "pakete": # der Neustart ist nie dabei
|
||||
host.append({**schritt, "rueckweg_art": "keiner"})
|
||||
elif b["id"] == "docker":
|
||||
if not arcane.echt():
|
||||
docker_probe = True # ein Probelauf ist kein Update
|
||||
else:
|
||||
gaeste.append({**schritt, "rueckweg_art": "keiner"}) # Arcane legt keinen Snapshot an
|
||||
elif b["id"] in GAST_BAUSTEINE:
|
||||
gaeste.append(schritt)
|
||||
gaeste.sort(key=_rang) # sort ist stabil: je Gast bleibt die Reihenfolge seiner Bausteine
|
||||
schritte = gaeste + host
|
||||
teile = []
|
||||
ohne = [s["name"] for s in gaeste if s["rueckweg_art"] == "keiner"]
|
||||
if ohne:
|
||||
teile.append(f"Ohne Rückweg: {_aufzaehlung(ohne)} – scheitert dort das Update, geht es nicht von selbst "
|
||||
"zurück.")
|
||||
if host:
|
||||
teile.append(HOST_HINWEIS)
|
||||
if docker_probe:
|
||||
teile.append("Die Docker-Images (Arcane) sind nicht dabei, solange Docker-Updates nur als Probelauf laufen.")
|
||||
return {"schritte": schritte, "hinweis": " ".join(teile) or None}
|
||||
|
||||
|
||||
# --- Starten ----------------------------------------------------------------------------------------
|
||||
|
||||
def _ausfuehrer_grund() -> str | None:
|
||||
"""Warum der Ausführer gerade keine Updates annimmt — oder None."""
|
||||
zuletzt = kanal.zuletzt()
|
||||
if not (zuletzt and time.time() - zuletzt < inventar.BERICHT_ALT_S):
|
||||
return "Der Ausführer auf dem Proxmox-Host ist nicht verbunden. Ohne ihn geht kein Update."
|
||||
host = ((kanal.bericht() or {}).get("bericht") or {}).get("host") or {}
|
||||
if host.get("nur_lesen"):
|
||||
return "Der Ausführer auf dem Proxmox-Host läuft im Nur-Lesen-Modus und lehnt Updates ab."
|
||||
return None
|
||||
|
||||
|
||||
def starten() -> dict:
|
||||
"""Knopf „Alle aktualisieren“. {"ok": True, "sammellauf": {…}} oder {"ok": False, "detail": …}."""
|
||||
if laeuft():
|
||||
return {"ok": False, "detail": updates.SAMMELLAUF_SPERRE}
|
||||
schritte = plan()["schritte"]
|
||||
if not schritte:
|
||||
return {"ok": False, "detail": "Gerade gibt es kein Update mit Knopf, also nichts zu tun."}
|
||||
if any(s["baustein"] != "docker" for s in schritte) and (grund := _ausfuehrer_grund()):
|
||||
return {"ok": False, "detail": grund}
|
||||
with updates.START_SPERRE:
|
||||
if laeuft():
|
||||
return {"ok": False, "detail": updates.SAMMELLAUF_SPERRE}
|
||||
if laufende := [x.get("name") or x.get("ziel") for x in updates.laeufe(100) if x.get("status") == "laeuft"]:
|
||||
return {"ok": False, "detail": f"Gerade läuft ein einzelnes Update ({_aufzaehlung(laufende)}). "
|
||||
"„Alle aktualisieren“ geht, sobald es fertig ist."}
|
||||
sl = {"id": uuid.uuid4().hex[:12], "status": "laeuft", "start": time.time(), "ende": None, "aktuell": None,
|
||||
"schritte": [{"ziel": s["ziel"], "name": s["name"], "baustein": s["baustein"], "was": s["was"],
|
||||
"status": "wartet", "lauf": None, "text": None} for s in schritte],
|
||||
"text": None, "meldung": None, "dringend": False}
|
||||
_merken(sl)
|
||||
antwort = oeffentlich(sl) # eine Kopie: Der Faden ändert sl gleich weiter
|
||||
threading.Thread(target=_ablauf, args=(sl,), name="homelab-alle", daemon=True).start()
|
||||
log.info("homelab: „Alle aktualisieren“ %s mit %d Schritten gestartet", sl["id"], len(schritte))
|
||||
return {"ok": True, "sammellauf": antwort}
|
||||
|
||||
|
||||
# --- Ablauf -----------------------------------------------------------------------------------------
|
||||
|
||||
def _warten(lauf_id: str) -> dict | None:
|
||||
"""Bis der Lauf fertig ist; None, wenn er sich in WARTEN_MAX_S nicht zurückmeldet."""
|
||||
ende = time.time() + WARTEN_MAX_S
|
||||
while time.time() < ende:
|
||||
lauf = updates.lauf_lesen(lauf_id)
|
||||
if lauf is not None and lauf.get("status") != "laeuft":
|
||||
return lauf
|
||||
time.sleep(TAKT_S)
|
||||
return None
|
||||
|
||||
|
||||
def _schritt(sl: dict, schritt: dict) -> None:
|
||||
"""Einen Schritt gehen: den Lauf anstoßen, auf ihn warten, sein Ergebnis übernehmen."""
|
||||
if schritt["baustein"] == "docker" and not arcane.echt():
|
||||
schritt.update(status="uebersprungen", text="Docker-Updates über Arcane stehen inzwischen auf Probelauf.")
|
||||
return
|
||||
antwort = updates.starten(schritt["ziel"], schritt["baustein"], sammellauf={"id": sl["id"], "start": sl["start"]})
|
||||
if not antwort.get("ok"):
|
||||
schritt.update(status="uebersprungen", text=str(antwort.get("detail") or "abgelehnt"))
|
||||
return
|
||||
schritt["lauf"] = antwort["lauf"]
|
||||
_merken(sl)
|
||||
lauf = _warten(antwort["lauf"])
|
||||
if lauf is None:
|
||||
schritt.update(status="fehler", text=f"Der Lauf hat sich in {WARTEN_MAX_S // 3600} Stunden nicht "
|
||||
"zurückgemeldet.")
|
||||
return
|
||||
schritt.update(status=STATUS.get(str(lauf.get("ergebnis")), "fehler"), text=lauf.get("text"))
|
||||
|
||||
|
||||
def _ablauf(sl: dict) -> None:
|
||||
try:
|
||||
for i, schritt in enumerate(sl["schritte"]):
|
||||
sl["aktuell"] = i
|
||||
schritt["status"] = "laeuft"
|
||||
_merken(sl)
|
||||
_schritt(sl, schritt)
|
||||
_merken(sl)
|
||||
if schritt["status"] in ("zurueckgerollt", "fehler"):
|
||||
_abbrechen(sl, i)
|
||||
return
|
||||
_abschliessen(sl)
|
||||
except Exception as exc:
|
||||
# Sonst hieße der Sammellauf bis zum nächsten Neustart „läuft“, und jeder einzelne Knopf wäre gesperrt.
|
||||
log.exception("homelab: „Alle aktualisieren“ %s ist abgestürzt", sl.get("id"))
|
||||
_beenden(sl, f"Interner Fehler: {exc.__class__.__name__}.",
|
||||
f"„Alle aktualisieren“ ist mit einem internen Fehler stehen geblieben ({exc.__class__.__name__}).")
|
||||
|
||||
|
||||
def _abschliessen(sl: dict) -> None:
|
||||
eingespielt = [s for s in sl["schritte"] if s["status"] == "eingespielt"]
|
||||
uebersprungen = [s for s in sl["schritte"] if s["status"] == "uebersprungen"]
|
||||
anzahl = len(eingespielt)
|
||||
if anzahl:
|
||||
text = f"{anzahl} {'Update' if anzahl == 1 else 'Updates'} eingespielt, alle Prüfungen grün."
|
||||
else:
|
||||
text = "Kein Update eingespielt."
|
||||
if uebersprungen:
|
||||
text += " Nicht gelaufen: " + "; ".join(f"{s['name']} ({str(s.get('text') or 'abgelehnt').rstrip('.')})"
|
||||
for s in uebersprungen) + "."
|
||||
sl.update(status="fertig", ende=time.time(), aktuell=None, text=text, meldung=f"Homelab: {text}", dringend=False)
|
||||
_merken(sl)
|
||||
log.info("homelab: „Alle aktualisieren“ %s fertig: %s", sl["id"], text)
|
||||
_melden(sl["meldung"])
|
||||
|
||||
|
||||
def _beenden(sl: dict, text: str, anlass: str) -> None:
|
||||
"""Abbrechen: offene Schritte „uebersprungen“, Status „abgebrochen“, dringende Meldung."""
|
||||
rest = [s for s in sl["schritte"] if s["status"] in ("wartet", "laeuft")]
|
||||
for s in rest:
|
||||
s.update(status="uebersprungen", text="Nicht mehr gelaufen: „Alle aktualisieren“ wurde abgebrochen.")
|
||||
eingespielt = [s["name"] for s in sl["schritte"] if s["status"] == "eingespielt"]
|
||||
meldung = f"Homelab: {anlass}"
|
||||
if eingespielt:
|
||||
meldung += f" Vorher eingespielt: {_aufzaehlung(eingespielt)}."
|
||||
if rest:
|
||||
meldung += f" Nicht mehr gelaufen: {_aufzaehlung([s['name'] for s in rest])}."
|
||||
sl.update(status="abgebrochen", ende=time.time(), aktuell=None, text=text, meldung=meldung, dringend=True)
|
||||
_merken(sl)
|
||||
log.warning("homelab: „Alle aktualisieren“ %s abgebrochen: %s", sl["id"], text)
|
||||
_melden(meldung, dringend=True)
|
||||
|
||||
|
||||
def _abbrechen(sl: dict, i: int) -> None:
|
||||
schritt = sl["schritte"][i]
|
||||
wie = "zurückgerollt" if schritt["status"] == "zurueckgerollt" else "gescheitert"
|
||||
_beenden(sl, f"Abgebrochen bei {schritt['name']}: Update {wie}.",
|
||||
f"„Alle aktualisieren“ abgebrochen bei {schritt['name']} (Update {wie}).")
|
||||
|
||||
|
||||
def unterbrochene_abschliessen() -> None:
|
||||
"""Beim Start (über updates.unterbrochene_abschliessen): Ein Sammellauf, der noch „läuft“, gehörte zum vorigen
|
||||
Prozess. Er gilt als abgebrochen; die Meldung geht nebenher raus, damit der Start nicht auf Telegram wartet."""
|
||||
for sl in _alle():
|
||||
if sl.get("status") != "laeuft":
|
||||
continue
|
||||
bei = None
|
||||
for s in sl["schritte"]:
|
||||
if s.get("status") != "laeuft":
|
||||
continue
|
||||
bei = s["name"]
|
||||
# War sein Lauf noch fertig geworden, zählt dessen Ergebnis; sonst ist offen, was am Gerät geschah.
|
||||
lauf = updates.lauf_lesen(s["lauf"]) if s.get("lauf") else None
|
||||
if lauf is not None and lauf.get("status") == "fertig" and lauf.get("ergebnis") in STATUS:
|
||||
s.update(status=STATUS[lauf["ergebnis"]], text=lauf.get("text"))
|
||||
else:
|
||||
s.update(status="fehler", text=UNTERBROCHEN)
|
||||
rest = [s for s in sl["schritte"] if s.get("status") == "wartet"]
|
||||
for s in rest:
|
||||
s.update(status="uebersprungen", text="Nicht mehr gelaufen: „Alle aktualisieren“ wurde abgebrochen.")
|
||||
eingespielt = [s["name"] for s in sl["schritte"] if s.get("status") == "eingespielt"]
|
||||
meldung = ("Homelab: „Alle aktualisieren“ wurde unterbrochen: Der Homelab-Teil ist mitten im Lauf neu "
|
||||
"gestartet" + (f" (bei {bei})" if bei else "") + ".")
|
||||
if eingespielt:
|
||||
meldung += f" Vorher eingespielt: {_aufzaehlung(eingespielt)}."
|
||||
if rest:
|
||||
meldung += f" Nicht mehr gelaufen: {_aufzaehlung([s['name'] for s in rest])}."
|
||||
meldung += " Bitte den Stand in der Übersicht prüfen."
|
||||
sl.update(status="abgebrochen", ende=time.time(), aktuell=None, text=UNTERBROCHEN, meldung=meldung,
|
||||
dringend=True)
|
||||
_merken(sl)
|
||||
log.warning("homelab: „Alle aktualisieren“ %s war beim Start noch offen → abgebrochen", sl["id"])
|
||||
threading.Thread(target=_melden, args=(meldung, True), name="homelab-alle-meldung", daemon=True).start()
|
||||
Reference in New Issue
Block a user