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:
Hitonabi
2026-09-25 10:09:14 +02:00
co-authored by Claude Opus 5.5
parent bf1153cb66
commit 8ec261b8b2
15 changed files with 2329 additions and 60 deletions
+329
View File
@@ -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()