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
@@ -15,6 +15,10 @@ Ablauf für einen Gast:
|
||||
Grün → ältere Snapshots bzw. Sicherungen 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.
|
||||
|
||||
„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
|
||||
@@ -39,8 +43,13 @@ 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)
|
||||
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:
|
||||
@@ -69,6 +78,10 @@ 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"}
|
||||
@@ -79,17 +92,25 @@ def laeuft(ziel_id: str) -> bool:
|
||||
|
||||
|
||||
def unterbrochene_abschliessen() -> None:
|
||||
"""Beim Start: Läufe, die noch „läuft“ heißen, gehörten zum vorigen Prozess."""
|
||||
"""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("[Alarm] Homelab-Update" if dringend else BETREFF, text)
|
||||
announce.notify_telegram(BETREFF_ALARM if dringend else BETREFF, text)
|
||||
except Exception:
|
||||
log.warning("homelab: Meldung ging nicht raus", exc_info=True)
|
||||
|
||||
@@ -112,7 +133,7 @@ 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)
|
||||
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)
|
||||
@@ -174,7 +195,10 @@ def _gast_lauf(lauf: dict, gast: dict) -> None:
|
||||
try:
|
||||
_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)
|
||||
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:
|
||||
@@ -257,53 +281,73 @@ def _host_lauf(lauf: dict) -> None:
|
||||
|
||||
|
||||
def _ende(lauf: dict, ergebnis: str, text: str, dringend: bool = False) -> None:
|
||||
lauf.update(status="fertig", ergebnis=ergebnis, text=text, ende=time.time())
|
||||
# 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:
|
||||
start = datetime.fromtimestamp(lauf["start"], LOCAL_TZ).isoformat(timespec="seconds")
|
||||
update_verlauf.eintragen(start, "Update von Hand", lauf["ziel"], ergebnis, text)
|
||||
# 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)
|
||||
_melden(text, dringend=dringend)
|
||||
if not still:
|
||||
_melden(text, dringend=dringend)
|
||||
|
||||
|
||||
def _neuer_lauf(ziel_id: str, baustein: str, name: str) -> dict:
|
||||
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": []}
|
||||
"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) -> dict:
|
||||
"""Knopf „Jetzt updaten“. Rückgabe {"ok": True, "lauf": id} oder {"ok": False, "detail": …}."""
|
||||
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"):
|
||||
return {"ok": False, "detail": "Unbekannter Baustein."}
|
||||
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
|
||||
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)
|
||||
threading.Thread(target=_docker_lauf, args=(lauf, basis), 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}
|
||||
lauf = _neuer_lauf(ziel_id, baustein, name)
|
||||
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 {}
|
||||
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), 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"]}
|
||||
|
||||
Reference in New Issue
Block a user