Merge branch 'worktree-agent-a8a38897ceeaf6694' into wartung/oberflaeche-v4

This commit is contained in:
Hitonabi
2026-09-25 10:11:34 +02:00
18 changed files with 2400 additions and 65 deletions
+3
View File
@@ -5,5 +5,8 @@
inventar.py Bericht + neueste Versionen + Erreichbarkeit → Ziele im gemeinsamen Modell (kern/ziele.py)
karenz.py Wartezeit nach einer Änderung am Update-Skript (community-scripts, ungepinnt von GitHub)
updates.py „Jetzt updaten“: Snapshot bzw. Sicherung → Update → Prüfung → bei Rot zurück, mit Meldung
sammellauf.py „Alle aktualisieren“: alle Updates mit Knopf nacheinander, bei Rot Schluss
protokoll.py was wann geschah (Aufträge, Läufe, Hinweise, Meldungen, Pflege), ohne Geheimnisse
einstellungen.py Arcane-Schlüssel und Docker-echt-Schalter aus der Oberfläche, Stand für „Einstellungen“
pflege.py wöchentliches Suchen (Paketlisten der Gäste), im Wächter-Takt des Stewards
"""
+142 -8
View File
@@ -3,22 +3,34 @@
Arcane (getarcaneapp/arcane) verwaltet die Docker-Container der VM (NerdQuiz, Rippy …) und hat eine
eigene Schnittstelle (OpenAPI unter /api/openapi.json). Öffentlich ist nur die eigene Version
(/api/app-version mit currentVersion, newestVersion, updateAvailable). Alles über Images braucht einen
API-Schlüssel (Kopfzeile X-API-Key), den man in Arcane anlegt — MC_ARCANE_KEY in /etc/mc2/homelab.env.
API-Schlüssel (Kopfzeile X-API-Key), den man in Arcane anlegt.
Der Schlüssel (seit 24.09.2026 auch über die Oberfläche): MC_ARCANE_KEY in /etc/mc2/homelab.env geht vor, sonst
<Datenordner>/arcane.key (0600, schreibt nur die Oberfläche nach einer Prüfung gegen Arcane, einstellungen.py).
Beides wird bei jedem Zugriff gelesen: Ein neuer Schlüssel gilt ohne Neustart. Er steht nie in einer Antwort,
einem Protokoll oder einer Fehlermeldung.
Docker-Updates laufen über Arcanes eigenen Updater (POST /environments/{id}/updater/run). User-Entscheid
24.09.: zuerst nur als Probelauf (dryRun); echte Updates erst mit MC_ARCANE_ECHT=1.
24.09.: zuerst nur als Probelauf (dryRun); echt erst, wenn der Schalter in den Einstellungen an ist
(<Datenordner>/homelab-einstellungen.json). Setzt die Umgebung MC_ARCANE_ECHT, gewinnt sie („1“ = echt).
"""
import json
import os
import threading
import time
from datetime import datetime
from pathlib import Path
import httpx
from kern.einstellungen import einstellungen
from kern.zeit import LOCAL_TZ
ZEITLIMIT = httpx.Timeout(20.0, connect=5.0)
CACHE_S = 120
_cache: dict[str, tuple[float, object]] = {}
_lock = threading.Lock()
_datei_lock = threading.Lock()
def _gemerkt(schluessel: str, holen):
@@ -33,14 +45,114 @@ def _gemerkt(schluessel: str, holen):
return wert
def cache_leeren() -> None:
"""Nach einem neuen oder gelöschten Schlüssel: Die Übersicht fragt Arcane gleich neu."""
with _lock:
_cache.clear()
# --- Schlüssel ----------------------------------------------------------------------------------------
def _schluessel_pfad() -> Path:
return einstellungen().daten_dir / "arcane.key"
def _schluessel_datei() -> str:
try:
return _schluessel_pfad().read_text(encoding="utf-8").strip()
except (OSError, UnicodeDecodeError):
return ""
def schluessel() -> str:
return os.environ.get("MC_ARCANE_KEY", "").strip()
"""Der geltende Schlüssel: MC_ARCANE_KEY, sonst die Datei, sonst leer."""
return os.environ.get("MC_ARCANE_KEY", "").strip() or _schluessel_datei()
def schluessel_quelle() -> str:
"""„umgebung“ (MC_ARCANE_KEY), „hinterlegt“ (Datei im Datenordner) oder „fehlt“."""
if os.environ.get("MC_ARCANE_KEY", "").strip():
return "umgebung"
return "hinterlegt" if _schluessel_datei() else "fehlt"
def schluessel_speichern(wert: str) -> None:
"""Atomar und von Anfang an nur für den Dienst lesbar (0600): erst eine Nachbardatei, dann ersetzen."""
pfad = _schluessel_pfad()
tmp = pfad.with_name(pfad.name + ".tmp")
with _datei_lock:
pfad.parent.mkdir(parents=True, exist_ok=True)
tmp.unlink(missing_ok=True)
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(wert.strip() + "\n")
os.replace(tmp, pfad)
cache_leeren()
def schluessel_loeschen() -> bool:
"""Die Datei weg. True, wenn es eine gab."""
with _datei_lock:
pfad = _schluessel_pfad()
da = pfad.exists()
pfad.unlink(missing_ok=True)
cache_leeren()
return da
# --- Echt statt Probelauf -----------------------------------------------------------------------------
def _einstellungen_pfad() -> Path:
return einstellungen().daten_dir / "homelab-einstellungen.json"
def _einstellungen_lesen() -> dict:
try:
daten = json.loads(_einstellungen_pfad().read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
return daten if isinstance(daten, dict) else {}
def _echt_eingestellt() -> bool | None:
arcane = _einstellungen_lesen().get("arcane")
wert = arcane.get("echt") if isinstance(arcane, dict) else None
return wert if isinstance(wert, bool) else None
def _echt_umgebung() -> str:
return os.environ.get("MC_ARCANE_ECHT", "").strip()
def echt() -> bool:
return os.environ.get("MC_ARCANE_ECHT", "") == "1"
"""Echte Docker-Updates statt Probelauf? Die Umgebung (MC_ARCANE_ECHT) gewinnt, dann der Schalter, sonst nein."""
if umgebung := _echt_umgebung():
return umgebung == "1"
return bool(_echt_eingestellt())
def echt_quelle() -> str:
"""„umgebung“, „einstellung“ oder „standard“ (Probelauf)."""
if _echt_umgebung():
return "umgebung"
return "einstellung" if _echt_eingestellt() is not None else "standard"
def echt_speichern(an: bool) -> None:
pfad = _einstellungen_pfad()
with _datei_lock:
daten = _einstellungen_lesen()
arcane = daten.get("arcane") if isinstance(daten.get("arcane"), dict) else {}
daten.update(arcane={**arcane, "echt": bool(an)},
geaendert=datetime.now(LOCAL_TZ).isoformat(timespec="seconds"))
pfad.parent.mkdir(parents=True, exist_ok=True)
tmp = pfad.with_name(pfad.name + ".tmp")
tmp.write_text(json.dumps(daten, ensure_ascii=False, indent=1), encoding="utf-8")
os.replace(tmp, pfad)
# --- Schnittstelle ------------------------------------------------------------------------------------
def app_version(url: str) -> dict | None:
"""Arcanes eigene Version (öffentlich): {"installiert", "neu", "update"} oder None."""
return _gemerkt(f"version:{url}", lambda: _app_version(url))
@@ -57,14 +169,14 @@ def _app_version(url: str) -> dict | None:
"update": bool(d.get("updateAvailable"))}
def _get(url: str, pfad: str) -> object:
r = httpx.get(f"{url}/api{pfad}", headers={"X-API-Key": schluessel()}, timeout=ZEITLIMIT)
def _get(url: str, pfad: str, mit_schluessel: str | None = None) -> object:
r = httpx.get(f"{url}/api{pfad}", headers={"X-API-Key": mit_schluessel or schluessel()}, timeout=ZEITLIMIT)
r.raise_for_status()
return r.json()
def umgebungen(url: str) -> list[dict]:
daten = _get(url, "/environments?limit=50")
def umgebungen(url: str, mit_schluessel: str | None = None) -> list[dict]:
daten = _get(url, "/environments?limit=50", mit_schluessel)
return [e for e in (daten or {}).get("data") or [] if isinstance(e, dict) and e.get("id") is not None]
@@ -88,6 +200,28 @@ def _images_mit_update(url: str) -> list[dict]:
return ergebnis
def schluessel_pruefen(url: str, wert: str) -> dict:
"""Nimmt Arcane diesen Schlüssel an? Geprüft wird mit denselben Aufrufen, die die Übersicht braucht: die
Umgebungen, dann ihre Images. {"ok": True, "umgebungen", "images", "mit_update"} oder {"ok": False, "status":
HTTP-Code bzw. None, "art": "abgelehnt" | "http" | "netz" | "antwort"}. Nichts davon enthält den Schlüssel."""
try:
liste = umgebungen(url, wert)
images = mit_update = 0
for umgebung in liste:
daten = _get(url, f"/environments/{umgebung['id']}/images?limit=500", wert)
eintraege = [i for i in (daten or {}).get("data") or [] if isinstance(i, dict)]
images += len(eintraege)
mit_update += sum(1 for i in eintraege if (i.get("updateInfo") or {}).get("hasUpdate"))
except httpx.HTTPStatusError as exc:
status = exc.response.status_code
return {"ok": False, "status": status, "art": "abgelehnt" if status in (401, 403) else "http"}
except httpx.HTTPError:
return {"ok": False, "status": None, "art": "netz"}
except (ValueError, AttributeError, TypeError):
return {"ok": False, "status": None, "art": "antwort"}
return {"ok": True, "umgebungen": len(liste), "images": images, "mit_update": mit_update}
def updater(url: str, probelauf: bool) -> dict:
"""Arcanes Updater für alle Umgebungen. Rückgabe: je Umgebung das Ergebnis (geprüft, aktualisiert, Fehler)."""
ergebnisse = {}
+129
View File
@@ -0,0 +1,129 @@
"""Einstellungen des Homelab-Teils (24.09.2026): was sich unter „Einstellungen“ für das Homelab einstellen lässt.
• Arcane-API-Schlüssel eintragen oder löschen. Vor dem Speichern prüft Arcane ihn mit denselben Aufrufen, die die
Übersicht braucht; lehnt es ab oder antwortet nicht, wird nichts gespeichert. Er liegt in
<Datenordner>/arcane.key (0600); MC_ARCANE_KEY in /etc/mc2/homelab.env geht vor (arcane.py).
• Docker echt echte Docker-Updates über Arcane statt Probelauf (<Datenordner>/homelab-einstellungen.json);
MC_ARCANE_ECHT in der Umgebung gewinnt.
stand() zeigt dazu die Wartezeit nach Skriptänderungen (karenz.py) und den Ausführer (verbunden, Nur-Lesen,
Sicherungsspeicher). Der Schlüssel verlässt diesen Teil nie: Keine Antwort, kein Protokolleintrag und keine
Fehlermeldung enthält ihn, auch kein Stück davon.
"""
import json
import logging
import re
import time
from services.homelab import apps, arcane, inventar, kanal, karenz
log = logging.getLogger(__name__)
UMGEBUNG_SCHLUESSEL = ("Der Arcane-Schlüssel kommt aus der Umgebung (MC_ARCANE_KEY in /etc/mc2/homelab.env) und "
"gilt vor einem hier eingetragenen. Erst dort entfernen, dann hier eintragen.")
_FORM = re.compile(r"[\x21-\x7e]{8,512}") # druckbares ASCII ohne Leerzeichen, wie ihn Arcane ausgibt
def arcane_url() -> str | None:
"""Adresse der Arcane-Oberfläche aus dem Bericht des Ausführers (die VM, deren Name „arcane“ ist)."""
eingang = kanal.bericht()
for gast in ((eingang or {}).get("bericht") or {}).get("gaeste") or []:
app = apps.app_fuer((gast.get("app") or {}).get("kennung"), gast.get("name"))
if app and app.kennung == "arcane" and (url := apps.adresse(app, gast.get("ip"))):
return url.rstrip("/")
return None
def stand() -> dict:
"""GET /api/homelab/einstellungen."""
zuletzt = kanal.zuletzt()
host = ((kanal.bericht() or {}).get("bericht") or {}).get("host") or {}
sicherung = host.get("sicherung") if isinstance(host.get("sicherung"), dict) else {}
return {
"arcane": {"url": arcane_url() or "", "schluessel": arcane.schluessel_quelle(), "echt": arcane.echt(),
"echt_quelle": arcane.echt_quelle()},
"karenz_h": round(karenz.karenz_s() / 3600),
"ausfuehrer": {"verbunden": bool(zuletzt and time.time() - zuletzt < inventar.BERICHT_ALT_S),
"zuletzt": zuletzt, "nur_lesen": bool(host.get("nur_lesen")),
"sicherung_speicher": sicherung.get("speicher") or None},
}
def schluessel_aus_body(roh: bytes) -> str | None:
"""Der Schlüssel aus dem Body {"schluessel": "…"} — ohne Pydantic, dessen Fehlermeldung (422) die Eingabe
wiederholen würde. None, wenn der Body nicht passt."""
try:
daten = json.loads(roh.decode("utf-8")) if roh else None
except (UnicodeDecodeError, ValueError):
return None
wert = daten.get("schluessel") if isinstance(daten, dict) else None
return wert if isinstance(wert, str) else None
def _abgelehnt(ergebnis: dict, url: str) -> str:
status, art = ergebnis.get("status"), ergebnis.get("art")
if art == "abgelehnt":
text = f"Arcane lehnt den Schlüssel ab (HTTP {status})."
if status == 403:
text += " Ihm fehlt das Recht, Umgebungen und Images zu lesen."
return text
if art == "http":
return f"Arcane antwortet mit HTTP {status}. Der Schlüssel ist nicht gespeichert."
if art == "netz":
return f"Arcane ist nicht erreichbar ({url}). Der Schlüssel ist nicht gespeichert."
return "Arcane antwortet nicht wie erwartet. Der Schlüssel ist nicht gespeichert."
def arcane_schluessel_setzen(wert: str | None) -> dict:
"""POST /api/homelab/einstellungen/arcane-schluessel: erst gegen Arcane prüfen, dann speichern."""
if arcane.schluessel_quelle() == "umgebung":
return {"ok": False, "detail": UMGEBUNG_SCHLUESSEL}
wert = (wert or "").strip()
if not _FORM.fullmatch(wert):
return {"ok": False, "detail": "Das sieht nicht wie ein Arcane-API-Schlüssel aus (leer, zu kurz oder mit "
"Leerzeichen). Nichts gespeichert."}
url = arcane_url()
if not url:
return {"ok": False, "detail": "Arcane steht nicht im Bericht des Ausführers, die Adresse ist unbekannt. "
"Nichts gespeichert."}
ergebnis = arcane.schluessel_pruefen(url, wert)
if not ergebnis["ok"]:
log.info("homelab: Arcane-Schlüssel nicht übernommen (%s, HTTP %s)", ergebnis.get("art"), ergebnis.get("status"))
return {"ok": False, "detail": _abgelehnt(ergebnis, url)}
try:
arcane.schluessel_speichern(wert)
except OSError as exc:
return {"ok": False, "detail": f"Arcane nimmt den Schlüssel an, aber er ließ sich nicht speichern "
f"({exc.__class__.__name__})."}
images, neu, anzahl = ergebnis["images"], ergebnis["mit_update"], ergebnis["umgebungen"]
log.info("homelab: Arcane-Schlüssel gespeichert (%d Images geprüft)", images)
return {"ok": True, "text": (f"Arcane nimmt den Schlüssel an: {images} Images in {anzahl} "
f"{'Umgebung' if anzahl == 1 else 'Umgebungen'} geprüft, {neu} davon mit neuerem "
"Stand. Die Übersicht zeigt die Docker-Images ab sofort.")}
def arcane_schluessel_loeschen() -> dict:
"""DELETE /api/homelab/einstellungen/arcane-schluessel: die Datei weg."""
try:
da = arcane.schluessel_loeschen()
except OSError as exc:
return {"ok": False, "detail": f"Der Schlüssel ließ sich nicht löschen ({exc.__class__.__name__})."}
text = "Der Arcane-Schlüssel ist gelöscht." if da else "Es war kein Arcane-Schlüssel hinterlegt."
if arcane.schluessel_quelle() == "umgebung":
text += " Die Umgebung setzt weiter einen (MC_ARCANE_KEY); der gilt."
return {"ok": True, "text": text}
def arcane_echt_setzen(an: bool) -> dict:
"""POST /api/homelab/einstellungen/arcane-echt: echte Docker-Updates statt Probelauf."""
if arcane.echt_quelle() == "umgebung":
return {"ok": False, "detail": f"Die Umgebung legt das fest (MC_ARCANE_ECHT in /etc/mc2/homelab.env: Docker-"
f"Updates laufen {'echt' if arcane.echt() else 'als Probelauf'}). Sie gewinnt "
"vor diesem Schalter."}
try:
arcane.echt_speichern(an)
except OSError as exc:
return {"ok": False, "detail": f"Der Schalter ließ sich nicht speichern ({exc.__class__.__name__})."}
return {"ok": True, "text": ("Docker-Updates über Arcane laufen ab jetzt echt: Arcane holt die neuen Images und "
"startet die Container neu." if an else
"Docker-Updates über Arcane laufen wieder nur als Probelauf; geändert wird nichts.")}
+2 -2
View File
@@ -180,8 +180,8 @@ def _arcane_bausteine(basis: str | None, zid: str) -> list[BausteinStand]:
selbst.zustand = "aktuell"
docker = BausteinStand(id="docker", name="Docker-Images", zustand="unbekannt")
if not arcane.schluessel():
docker.grund = ("Es fehlt ein Arcane-API-Schlüssel (MC_ARCANE_KEY): ohne ihn sieht der Orchestrator "
"die Images nicht.")
docker.grund = ("Es fehlt ein Arcane-API-Schlüssel: Ohne ihn sieht der Orchestrator die Images nicht. "
"Eintragen unter Einstellungen (Arcane-API-Schlüssel).")
elif basis:
try:
images = arcane.images_mit_update(basis)
+43 -5
View File
@@ -8,6 +8,10 @@ für den Dienst lesbar); die Einrichtung auf dem Proxmox-Host kopiert es in /etc
Aufträge liegen in <Datenordner>/ausfuehrer-auftraege.json und überleben so einen Neustart dieses Teils.
Zwei Prozesse schreiben hinein: die Oberfläche (Knöpfe, Abholen durch den Ausführer) und seit 24.09.2026 der
Steward (wöchentliches Suchen, pflege.py). Lesen, ändern, schreiben geschieht deshalb unter einer Dateisperre.
Erledigte Aufträge bleiben (seit 24.09.2026) bis zu BEHALTEN Stück stehen — das Protokoll der Oberfläche
(protokoll.py) liest sie. Die jüngsten VOLLTEXT behalten ihre ganze Ausgabe, ältere nur deren Ende (TEXT_KURZ
Zeichen, so viel zeigt das Protokoll): Die Datei wird bei jedem Auftrag gelesen und geschrieben.
"""
import hmac
@@ -31,7 +35,10 @@ except ImportError: # Windows (Entwicklung): nur die Thread-Sperre
AKTIONEN = {"bericht", "snapshot", "update", "os_update", "suchen", "zurueck", "snapshot_loeschen",
"host_update", "host_neustart", "sichern", "sicherung_zurueck", "sicherung_loeschen"}
VERLOREN_S = 3 * 3600 # abgeholt, aber nie beantwortet (Ausführer abgestürzt, Host neu gestartet)
BEHALTEN = 60 # so viele erledigte Aufträge bleiben sichtbar
BEHALTEN = 500 # so viele erledigte Aufträge bleiben sichtbar (Protokoll; bis 24.09.2026: 60)
VOLLTEXT = 60 # die jüngsten erledigten behalten ihre ganze Ausgabe …
TEXT_KURZ = 4000 # … ältere nur deren letzte Zeichen
BERICHTE_BEHALTEN = 150 # Eingänge der Berichte, die der Ausführer von sich aus schickt (alle 10 min: gut ein Tag)
_lock = threading.Lock()
_kontakt: dict = {"zuletzt": None}
@@ -110,9 +117,31 @@ def zuletzt() -> float | None:
# --- Bericht ------------------------------------------------------------------------------
def bericht_speichern(daten: dict) -> None:
def bericht_speichern(daten: dict, aus_auftrag: bool = False) -> None:
"""Den neuesten Bericht ablegen. Schickt ihn der Ausführer von sich aus (alle 10 min und nach jedem Auftrag),
wird sein Eingang kurz vermerkt — das Protokoll zeigt ihn auf Wunsch. aus_auftrag: ein Lauf hat ihn als Auftrag
„bericht“ geholt (updates.py); dann steht er dort schon."""
jetzt = time.time()
with _lock:
_json_schreiben(_dir() / "pve-bericht.json", {"empfangen": time.time(), "bericht": daten})
_json_schreiben(_dir() / "pve-bericht.json", {"empfangen": jetzt, "bericht": daten})
if aus_auftrag:
return
host = daten.get("host") if isinstance(daten.get("host"), dict) else {}
gaeste = [g for g in daten.get("gaeste") or [] if isinstance(g, dict)]
eingang = {"empfangen": jetzt, "host_version": host.get("version"), "gaeste": len(gaeste),
"laufend": sum(1 for g in gaeste if g.get("status") == "running"),
"host_updates": len(host["updates"]) if isinstance(host.get("updates"), list) else None}
pfad = _dir() / "ausfuehrer-berichte.json"
eingaenge = _json_lesen(pfad, [])
eingaenge = eingaenge if isinstance(eingaenge, list) else []
_json_schreiben(pfad, (eingaenge + [eingang])[-BERICHTE_BEHALTEN:])
def berichte() -> list[dict]:
"""Die vermerkten Eingänge von Berichten (älteste zuerst): Zeit, Host-Version, Zahl der Gäste und Host-Updates."""
with _lock:
daten = _json_lesen(_dir() / "ausfuehrer-berichte.json", [])
return [e for e in daten if isinstance(e, dict)] if isinstance(daten, list) else []
def bericht() -> dict | None:
@@ -136,6 +165,10 @@ def _alle() -> list[dict]:
def _merken(auftraege: list[dict]) -> None:
offen = [a for a in auftraege if a["status"] in ("wartet", "laeuft")]
fertig = [a for a in auftraege if a["status"] not in ("wartet", "laeuft")][-BEHALTEN:]
for a in fertig[:-VOLLTEXT]:
text = a.get("text")
if isinstance(text, str) and len(text) > TEXT_KURZ:
a["text"] = "…" + text[-(TEXT_KURZ - 1):]
_json_schreiben(_pfad(), sorted(offen + fertig, key=lambda a: a["erstellt"]))
@@ -154,17 +187,22 @@ def anlegen(aktion: str, parameter: dict | None = None) -> str:
def naechster() -> dict | None:
"""Für den Ausführer: der älteste wartende Auftrag, ab jetzt „läuft“. Hängengebliebene werden
vorher als verloren abgeschlossen."""
vorher als verloren abgeschlossen. Geschrieben wird nur, wenn sich etwas ändert: Der Ausführer fragt alle
fünf Sekunden."""
jetzt = time.time()
with _sperre():
auftraege = _alle()
geaendert = False
for a in auftraege:
if a["status"] == "laeuft" and jetzt - (a["abgeholt"] or jetzt) > VERLOREN_S:
a.update(status="verloren", fertig=jetzt, text="Der Ausführer hat nie geantwortet.")
geaendert = True
auftrag = next((a for a in auftraege if a["status"] == "wartet"), None)
if auftrag:
auftrag.update(status="laeuft", abgeholt=jetzt)
_merken(auftraege)
geaendert = True
if geaendert:
_merken(auftraege)
return {k: auftrag[k] for k in ("id", "aktion", "parameter")} if auftrag else None
+24 -2
View File
@@ -15,7 +15,9 @@ Die Übersicht ist nur so ehrlich wie die Paketlisten in den Gästen, und die er
Der Steward der Rolle homelab ruft pruefe_paketlisten() in jedem Wächter-Takt auf (services/waechter.py,
PRUEFUNGEN). Zustand in <Datenordner>/homelab-pflege.json; den schreibt nur der Steward. Die Aufträge selbst
landen im Kanal (kanal.py, unter Dateisperre, weil auch die Oberfläche dort schreibt).
landen im Kanal (kanal.py, unter Dateisperre, weil auch die Oberfläche dort schreibt). Seit 24.09.2026 steht dort
auch eine kurze Ereignisliste (angestoßen, gescheitert, wieder ok; die letzten EREIGNISSE_MAX) — das Protokoll der
Oberfläche liest sie (protokoll.py).
"""
import json
@@ -35,6 +37,7 @@ log = logging.getLogger(__name__)
LISTEN_ALT_S = 7 * 24 * 3600
NACHT_VON, NACHT_BIS = 2, 5 # volle Stunden, Europe/Berlin
FEHLSCHLAEGE_HINWEIS = 2
EREIGNISSE_MAX = 200
_lock = threading.Lock()
@@ -82,10 +85,21 @@ def _fehlerzeile(text: str | None) -> str:
return (wichtig or zeilen or ["ohne Angabe"])[-1][:200]
def _ereignis(daten: dict, ts: float, vmid: object, name: object, art: str, text: str) -> None:
"""Für das Protokoll der Oberfläche; die Liste bleibt kurz."""
try:
vmid = int(vmid)
except (TypeError, ValueError):
return
liste = daten["ereignisse"] if isinstance(daten.get("ereignisse"), list) else []
liste.append({"ts": ts, "vmid": vmid, "name": str(name or vmid), "art": art, "text": text})
daten["ereignisse"] = liste[-EREIGNISSE_MAX:]
def _einsammeln(daten: dict) -> bool:
"""Ergebnisse der eigenen Aufträge übernehmen: fertig → Zähler auf null, gescheitert → eins mehr."""
geaendert = False
for eintrag in daten["gaeste"].values():
for vmid, eintrag in daten["gaeste"].items():
aid = eintrag.get("auftrag")
if not aid:
continue
@@ -93,10 +107,15 @@ def _einsammeln(daten: dict) -> bool:
if a is not None and a.get("status") in ("wartet", "laeuft"):
continue
if a is not None and a.get("status") == "fertig":
if eintrag.get("fehlschlaege"):
_ereignis(daten, a.get("fertig") or time.time(), vmid, eintrag.get("name"), "wieder_ok",
"Die Paketlisten ließen sich wieder erneuern.")
eintrag["fehlschlaege"], eintrag["fehler"] = 0, None
elif a is not None: # fehler, verloren
eintrag["fehlschlaege"] = int(eintrag.get("fehlschlaege") or 0) + 1
eintrag["fehler"] = _fehlerzeile(a.get("text"))
_ereignis(daten, a.get("fertig") or time.time(), vmid, eintrag.get("name"), "gescheitert",
f"{eintrag['fehlschlaege']}. Fehlschlag in Folge: {eintrag['fehler']}")
eintrag["auftrag"] = None # nicht mehr in der Liste: vergessen, nicht zählen
geaendert = True
return geaendert
@@ -173,6 +192,9 @@ def pruefe_paketlisten(jetzt: float | None = None) -> list:
log.info("pflege (trocken): würde die Paketlisten von %s erneuern", eintrag["name"])
continue
eintrag.update(tag=heute, auftrag=kanal.anlegen("suchen", {"vmid": vmid}), angestossen=jetzt)
listen = (g.get("os_updates") or {}).get("listen_stand")
_ereignis(daten, jetzt, vmid, eintrag["name"], "angestossen",
f"Die Paketlisten sind vom {datetime.fromtimestamp(listen, LOCAL_TZ):%d.%m.%Y}.")
geaendert = True
log.info("pflege: Paketlisten von %s sind alt → suchen", eintrag["name"])
if geaendert:
+517
View File
@@ -0,0 +1,517 @@
"""Protokoll des Homelab-Teils (24.09.2026): was wann geschah, aus allen Quellen, neueste zuerst.
Quellen, alle nur gelesen:
auftrag Aufträge an den Ausführer (kanal.py): Aktion, Gerät, Status, Code; die Ausgabe als Einzelheiten.
Berichte nur auf Wunsch (berichte=1): die Aufträge „bericht“ der Update-Läufe und die Berichte, die
der Ausführer von sich aus schickt (alle zehn Minuten; kanal.py vermerkt ihren Eingang).
lauf „Jetzt updaten“-Läufe mit ihren Schritten (updates.py)
sammellauf „Alle aktualisieren“ (sammellauf.py)
hinweis Hinweise des Wächters: erschienen, erledigt, Selbstreparatur (der Verlauf in mc2-waechter.json;
ein Hinweis, der dort schon herausgefallen ist, erscheint mit seinem Beginn)
meldung was notify.sh an Telegram gab oder für die Morgenmeldung zurücklegte (Melde-Log des Containers,
MC_NOTIFY_LOG). Den Betreff schreibt notify.sh nicht mit; er folgt aus dem Absender: Lauf,
Sammellauf, Wächter, Telegram-Test oder Morgenmeldung.
pflege das wöchentliche Suchen (pflege.py): angestoßen, gescheitert, wieder ok
Kein Eintrag enthält Geheimnisse: schwaerzen() nimmt die bekannten Schlüssel dieser Instanz (Arcane, Ausführer) und
alles, was wie ein Zugangs-Token aussieht, aus Titeln, Texten und Ausgaben; Steuerzeichen der Konsole fallen weg.
"""
import json
import os
import re
from collections.abc import Callable
from datetime import datetime
from pathlib import Path
from kern.einstellungen import einstellungen
from kern.zeit import LOCAL_TZ
from services.einstellungen import melde_log
from services.homelab import apps, arcane, kanal, pflege, sammellauf, updates
GRENZE_MAX = 1000
DETAILS_MAX = 4000
TEXT_MAX = 300
MELDELOG_MAX = 512 * 1024 # so viel vom Ende des Melde-Logs
STUFE_HINWEIS = {"rot": "fehler", "gelb": "warn"}
# Betreffzeilen der Absender im Homelab-Teil (notify.sh schreibt sie nicht ins Melde-Log).
BETREFF_WAECHTER = ("[Homelab-Problem]", "[Homelab wieder ok]") # waechter.BETREFF_* in der Rolle homelab
BETREFF_TEST = "[Test]" # services/einstellungen.telegram_test
BETREFF_MORGEN = "[Morgenmeldung Homelab]" # deploy/homelab/mc2-homelab-morgenmeldung.service
LUCY_ZUSATZ = " (Diese Meldung kam auch an Lucy.)" # hängt waechter._telegram an
# Aktion → (erledigt, gescheitert, offen, kurz). Titel sind „Was · Gerät“.
_AKTION = {
"bericht": ("Bericht geholt", "Bericht gescheitert", "Bericht angefordert", "Bericht"),
"snapshot": ("Snapshot angelegt", "Snapshot gescheitert", "Snapshot wird angelegt", "Snapshot"),
"update": ("Update-Skript gelaufen", "Update-Skript gescheitert", "Update-Skript läuft", "Update-Skript"),
"os_update": ("Pakete eingespielt", "Pakete einspielen gescheitert", "Pakete werden eingespielt",
"Pakete einspielen"),
"suchen": ("Paketlisten erneuert", "Paketlisten erneuern gescheitert", "Paketlisten werden erneuert",
"Paketlisten erneuern"),
"zurueck": ("Auf den Snapshot zurückgesetzt", "Zurücksetzen gescheitert", "Wird auf den Snapshot zurückgesetzt",
"Zurücksetzen"),
"snapshot_loeschen": ("Alter Snapshot gelöscht", "Snapshot löschen gescheitert", "Alter Snapshot wird gelöscht",
"Snapshot löschen"),
"sichern": ("Sicherung angelegt", "Sicherung gescheitert", "Sicherung läuft", "Sicherung"),
"sicherung_zurueck": ("Sicherung zurückgespielt", "Zurückspielen gescheitert", "Sicherung wird zurückgespielt",
"Zurückspielen"),
"sicherung_loeschen": ("Alte Sicherung gelöscht", "Sicherung löschen gescheitert", "Alte Sicherung wird gelöscht",
"Sicherung löschen"),
"host_update": ("Host-Pakete eingespielt", "Host-Pakete gescheitert", "Host-Pakete werden eingespielt",
"Host-Pakete"),
"host_neustart": ("Neustart ausgelöst", "Neustart gescheitert", "Neustart angefordert", "Neustart"),
}
_HOST_AKTIONEN = {"bericht", "host_update", "host_neustart"}
_LAUF_WAS = {"app": "Update", "os": "Paket-Update", "pakete": "Paket-Update", "docker": "Docker-Update",
"neustart": "Neustart"}
_SCHRITT_WORT = {"wartet": "wartet", "laeuft": "läuft", "eingespielt": "eingespielt",
"zurueckgerollt": "zurückgerollt", "fehler": "gescheitert", "uebersprungen": "übersprungen"}
_MELDUNG = re.compile(r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) "
r"(OK telegram direkt|OK telegram|QUEUED für Morgen-Digest|FALLBACK \((.*?)\)): ?(.*)$")
_WEG = {"OK telegram direkt": ("Meldung raus", "info", "Direkt an die Telegram-Bot-API gesendet."),
"OK telegram": ("Meldung raus", "info", "Über Hermes an Telegram gesendet."),
"QUEUED für Morgen-Digest": ("Meldung zurückgelegt", "info",
"Nachtruhe: Sie kommt mit der Morgenmeldung um 07:00.")}
_FEHLERZEILE = ("E:", "ERROR", "Error", "error:", "FATAL", "Fehler", "Abgelehnt", "Zeitlimit", "Traceback")
# --- Geheimnisse und Konsolen-Zeichen -------------------------------------------------------------------
_ANSI = re.compile(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-Z\\-_])")
_STEUER = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
_GEHEIM = (
# Telegram-Bot-Token, frei oder in Adressen (…/bot<Token>/sendMessage: dort ohne Wortgrenze davor)
(re.compile(r"(?<!\d)\d{6,12}:[A-Za-z0-9_-]{30,}"), "***"),
# Zugangsdaten in Adressen: https://nutzer:passwort@host
(re.compile(r"(?i)\b([a-z][a-z0-9+.-]*://)[^/\s:@]+:[^/\s@]+@"), r"\1***@"),
# Kopfzeilen: Authorization: Bearer …, X-API-Key: …, X-MC2-Ausfuehrer: …
(re.compile(r"(?i)\b(authorization|proxy-authorization|x-api-key|x-mc2-ausfuehrer|cookie|set-cookie)"
r"([\"']?\s*[:=]\s*[\"']?)(?:(?:bearer|basic|token)\s+)?[^\s\"',;]+"), r"\1\2***"),
# Name=Wert bzw. "name": "wert", wenn der Name verrät, dass der Wert geheim ist
(re.compile(r"(?i)\b([\w.-]*(?:token|passwor[dt]|secret|api[_-]?key|apikey|kennwort|passphrase|"
r"private[_-]?key|access[_-]?key)[\w.-]*)([\"']?\s*[:=]\s*[\"']?)[^\s\"',;&]+"), r"\1\2***"),
(re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._~+/=-]{8,}"), "Bearer ***"),
# bekannte Token-Formen: GitHub, GitLab, Slack, JWT
(re.compile(r"\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{20,}|"
r"xox[abpr]-[A-Za-z0-9-]{10,})"), "***"),
(re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}"), "***"),
)
def _ausfuehrer_token() -> str:
"""Das Geheimnis des Kanals, falls es schon eines gibt — ohne es zu erzeugen (kanal.token() täte das)."""
if wert := os.environ.get("MC_AUSFUEHRER_TOKEN", "").strip():
return wert
try:
return (einstellungen().daten_dir / "ausfuehrer.token").read_text(encoding="utf-8").strip()
except (OSError, UnicodeDecodeError):
return ""
def _schwaerzer() -> Callable[[str | None], str | None]:
"""Eine Schwärz-Funktion mit den bekannten Schlüsseln dieser Instanz (einmal je Protokoll gelesen)."""
bekannt = sorted({w for w in (arcane.schluessel(), _ausfuehrer_token()) if len(w) >= 8}, key=len, reverse=True)
def schwaerzen(text: str | None) -> str | None:
if text is None:
return None
text = _ANSI.sub("", str(text))
# Fortschrittsbalken überschreiben ihre Zeile mit \r: Stehen bleibt, was die Konsole zuletzt zeigte.
zeilen = []
for zeile in text.split("\n"):
teile = [t for t in zeile.split("\r") if t.strip()]
zeilen.append(teile[-1] if teile else "")
text = _STEUER.sub("", "\n".join(zeilen))
for wert in bekannt:
text = text.replace(wert, "***")
for muster, ersatz in _GEHEIM:
text = muster.sub(ersatz, text)
return text
return schwaerzen
def schwaerzen(text: str | None) -> str | None:
"""Geheimnisse und Steuerzeichen aus einem Text (für Tests und Einzelfälle; eintraege() liest einmal)."""
return _schwaerzer()(text)
def _kuerzen(text: str | None, laenge: int = DETAILS_MAX) -> str | None:
"""Das Ende langer Ausgaben (dort stehen Ergebnis und Fehler), wenn möglich ab einem Zeilenanfang."""
if not text or not text.strip():
return None
text = text.strip("\n")
if len(text) <= laenge:
return text
rest = text[-(laenge - 2):]
if "\n" in rest[:300]:
rest = rest.split("\n", 1)[1]
return "…\n" + rest
def _zeile(text: str | None) -> str | None:
"""Ein Text für das Feld „text“: eine Zeile, höchstens TEXT_MAX Zeichen."""
if not text or not str(text).strip():
return None
ganz = str(text).strip()
erste = ganz.splitlines()[0].strip()
if len(erste) > TEXT_MAX:
return erste[:TEXT_MAX - 1].rstrip() + "…"
return erste + ("…" if "\n" in ganz else "")
# --- Geräte ---------------------------------------------------------------------------------------------
def _geraete() -> dict[int, tuple[str, str]]:
"""VMID → (Ziel-ID, Name) aus dem letzten Bericht des Ausführers."""
geraete: dict[int, tuple[str, str]] = {}
for g in ((kanal.bericht() or {}).get("bericht") or {}).get("gaeste") or []:
if not isinstance(g, dict) or not isinstance(g.get("vmid"), int):
continue
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
zid = f"{'ct' if g.get('art') == 'lxc' else 'vm'}-{g['vmid']}"
geraete[g["vmid"]] = (zid, app.name if app else str(g.get("name") or zid))
return geraete
def _eintrag(zeit: object, art: str, titel: str, text: str | None, stufe: str, ziel: str | None,
details: str | None, schw: Callable) -> dict | None:
if not isinstance(zeit, (int, float)):
return None
return {"zeit": float(zeit), "art": art, "titel": schw(titel), "text": schw(text), "stufe": stufe,
"ziel": ziel, "details": schw(details)}
# --- Aufträge an den Ausführer --------------------------------------------------------------------------
def _dauer(bis: object, von: object) -> str | None:
if not (isinstance(bis, (int, float)) and isinstance(von, (int, float))) or bis < von:
return None
s = round(bis - von)
if s < 60:
return f"{s} s"
if s < 3600:
return f"{s // 60} min"
return f"{s // 3600} h {s % 3600 // 60} min"
def _gegenstand(aktion: str, p: dict, ausgabe: str) -> str | None:
"""Worum es ging: der Snapshot bzw. die Sicherung."""
if aktion in ("zurueck", "snapshot_loeschen"):
return str(p.get("snapshot") or "") or None
if aktion in ("sicherung_zurueck", "sicherung_loeschen"):
return str(p.get("sicherung") or "").rsplit("/", 1)[-1] or None
if aktion == "snapshot" and (m := re.search(r"snapshot=(\S+)", ausgabe)):
return m.group(1)
if aktion == "sichern" and (m := re.search(r"^sicherung=(\S+)$", ausgabe, re.MULTILINE)):
return m.group(1).rsplit("/", 1)[-1]
return None
def _fehlerzeile(ausgabe: str) -> str | None:
zeilen = [z.strip() for z in ausgabe.splitlines() if z.strip()]
wichtig = [z for z in zeilen if z.startswith(_FEHLERZEILE)]
zeile = (wichtig or zeilen or [None])[-1]
return zeile[:200] if zeile else None
def _bericht_satz(version: object, gaeste: object, laufend: object, host_updates: object) -> str:
teile = [f"Proxmox VE {version}" if version else None, f"{gaeste} Gäste, davon {laufend} laufend",
f"{host_updates} Paket-Updates auf dem Host" if isinstance(host_updates, int) else None]
return ", ".join(t for t in teile if t) + "."
def _bericht_kurz(ausgabe: str) -> str | None:
"""Ein Bericht ist JSON; statt seines Endes eine Zusammenfassung."""
try:
b = json.loads(ausgabe)
except ValueError:
return None
if not isinstance(b, dict):
return None
host = b.get("host") if isinstance(b.get("host"), dict) else {}
gaeste = [g for g in b.get("gaeste") or [] if isinstance(g, dict)]
return _bericht_satz(host.get("version"), len(gaeste), sum(1 for g in gaeste if g.get("status") == "running"),
len(host["updates"]) if isinstance(host.get("updates"), list) else None)
def _bericht_eingang(e: dict, schw: Callable) -> dict | None:
"""Ein Bericht, den der Ausführer von sich aus geschickt hat (kein Auftrag)."""
return _eintrag(e.get("empfangen"), "auftrag", "Bericht empfangen · Proxmox-Host",
_bericht_satz(e.get("host_version"), e.get("gaeste"), e.get("laufend"), e.get("host_updates")),
"info", "pve", None, schw)
def _auftrag(a: dict, geraete: dict, schw: Callable) -> dict | None:
aktion = str(a.get("aktion") or "?")
erledigt, gescheitert, offen, kurz = _AKTION.get(aktion, (aktion, f"{aktion} gescheitert", f"{aktion} läuft",
aktion))
p = a.get("parameter") if isinstance(a.get("parameter"), dict) else {}
ziel, name = None, None
if aktion in _HOST_AKTIONEN:
ziel, name = "pve", "Proxmox-Host"
elif isinstance(p.get("vmid"), int):
ziel, name = geraete.get(p["vmid"], (None, f"Gast {p['vmid']}"))
status, code = a.get("status"), a.get("code")
ausgabe = str(a.get("text") or "")
gegenstand = _gegenstand(aktion, p, ausgabe)
dauer = _dauer(a.get("fertig"), a.get("abgeholt"))
kopf = ", ".join(t for t in (gegenstand, f"nach {dauer}" if dauer else None,
f"Code {code}" if code is not None else None) if t)
if status == "fertig":
titel, stufe, text = erledigt, "ok", kopf or None
elif status == "fehler" and code == 2 and ausgabe.startswith("Abgelehnt"):
titel, stufe, text = f"{kurz} abgelehnt", "warn", _fehlerzeile(ausgabe)
elif status == "fehler":
grund = _fehlerzeile(ausgabe)
titel, stufe, text = gescheitert, "fehler", f"{kopf}: {grund}" if grund else kopf or None
elif status == "verloren":
titel, stufe, text = f"{kurz}: keine Antwort", "fehler", "Der Ausführer hat nie geantwortet."
elif status == "laeuft":
titel, stufe, text = offen, "info", "Der Ausführer ist dran."
else:
titel, stufe, text = offen, "info", "Wartet auf den Ausführer."
details = _bericht_kurz(ausgabe) if aktion == "bericht" else None
return _eintrag(a.get("fertig") or a.get("abgeholt") or a.get("erstellt"), "auftrag",
f"{titel} · {name}" if name else titel, text, stufe, ziel,
details or _kuerzen(schw(ausgabe)), schw)
# --- Läufe und Sammelläufe ------------------------------------------------------------------------------
def _anzahl_pakete(n: object, wort: str) -> str:
return f"{n} {wort}{'' if n == 1 else 'e'} eingespielt" if isinstance(n, int) and n > 0 else f"{wort}e eingespielt"
def _lauf(x: dict, schw: Callable) -> dict | None:
name = str(x.get("name") or x.get("ziel") or "?")
baustein, ergebnis, status, text = x.get("baustein"), x.get("ergebnis"), x.get("status"), x.get("text")
was = _LAUF_WAS.get(str(baustein), "Update")
if status == "laeuft":
titel, stufe = f"{was} läuft · {name}", "info"
elif status == "unterbrochen":
titel, stufe = f"{was} unterbrochen · {name}", "fehler"
text = text or "Der Homelab-Teil wurde während des Laufs neu gestartet; ob das Update durchlief, zeigt der " \
"nächste Bericht."
elif ergebnis == "eingespielt":
stufe = "ok"
if baustein == "app":
titel = f"Update eingespielt · {name}" + (f" {x['version_neu']}" if x.get("version_neu") else "")
elif baustein == "docker":
titel = f"Docker-Images aktualisiert · {name}"
elif baustein == "pakete":
titel = f"{_anzahl_pakete(x.get('pakete'), 'Host-Paket')} · {name}"
else:
titel = f"{_anzahl_pakete(x.get('pakete'), 'Paket')} · {name}"
elif ergebnis == "zurueckgerollt":
titel, stufe = f"{was} zurückgerollt · {name}", "warn"
elif ergebnis == "fehler" and "Update nicht begonnen" in str(text or ""):
titel, stufe = f"{was} nicht begonnen · {name}", "warn"
elif ergebnis == "fehler":
titel, stufe = f"{was} gescheitert · {name}", "fehler"
elif ergebnis == "neustart":
titel, stufe = f"Neustart ausgelöst · {name}", "info"
elif ergebnis == "offen":
titel, stufe = f"Docker-Probelauf · {name}", "info"
else:
titel, stufe = f"{was} beendet · {name}", "info"
zeilen = [str(s) for s in x.get("schritte") or []]
if x.get("sammellauf"):
zeilen.append("Teil von „Alle aktualisieren“.")
if x.get("gemeldet") is False:
zeilen.append("Keine eigene Meldung: Die Sammelmeldung am Ende sagt es.")
return _eintrag(x.get("ende") or x.get("start"), "lauf", titel, text, stufe, x.get("ziel"),
"\n".join(zeilen) or None, schw)
def _sammellauf(sl: dict, schw: Callable) -> dict | None:
schritte = [s for s in sl.get("schritte") or [] if isinstance(s, dict)]
anzahl = len(schritte)
eingespielt = sum(1 for s in schritte if s.get("status") == "eingespielt")
status = sl.get("status")
if status == "laeuft":
i = sl.get("aktuell")
titel = "Alle aktualisieren läuft" + (f" · Schritt {i + 1} von {anzahl}" if isinstance(i, int) else "")
stufe = "info"
elif status == "abgebrochen":
bei = next((s.get("name") for s in schritte if s.get("status") in ("fehler", "zurueckgerollt")), None)
titel, stufe = "Alle aktualisieren abgebrochen" + (f" · {bei}" if bei else ""), "fehler"
else:
titel = f"Alle aktualisieren fertig · {eingespielt} von {anzahl} eingespielt"
stufe = "ok" if eingespielt == anzahl else "info"
details = "\n".join(f"{s.get('name')} ({s.get('was')}): {_SCHRITT_WORT.get(str(s.get('status')), s.get('status'))}"
+ (f" – {s['text']}" if s.get("text") else "") for s in schritte)
return _eintrag(sl.get("ende") or sl.get("start"), "sammellauf", titel, sl.get("text"), stufe, None,
details or None, schw)
# --- Hinweise des Wächters ------------------------------------------------------------------------------
def _waechter_pfad() -> Path:
"""Wie waechter.STORE_PATH — hier gelesen, ohne den Wächter zu laden (der zieht Module der KI-Box nach)."""
return Path(os.environ.get("MC_WAECHTER_STORE", str(einstellungen().daten_dir / "mc2-waechter.json")))
def _ziel_aus(hinweis_id: str) -> str | None:
if m := re.search(r"\b(ct|vm)-(\d+)\b", hinweis_id):
return f"{m.group(1)}-{m.group(2)}"
if m := re.fullmatch(r"gast-platte:(\d+)", hinweis_id):
return f"ct-{m.group(1)}"
return "pve" if hinweis_id == "ausfuehrer" else None
def _hinweise(schw: Callable) -> list[dict]:
try:
daten = json.loads(_waechter_pfad().read_text(encoding="utf-8"))
except (OSError, ValueError):
return []
daten = daten if isinstance(daten, dict) else {}
aktuell = daten.get("hinweise") if isinstance(daten.get("hinweise"), dict) else {}
eintraege, erschienen = [], set()
for v in daten.get("verlauf") or []:
if not isinstance(v, dict):
continue
art, hid = v.get("art"), str(v.get("id") or "")
titel, h = str(v.get("text") or hid), aktuell.get(hid) if isinstance(aktuell.get(hid), dict) else {}
if art == "neu":
erschienen.add(hid)
e = _eintrag(v.get("ts"), "hinweis", f"Hinweis: {titel}", h.get("text"),
STUFE_HINWEIS.get(str(v.get("stufe") or h.get("stufe")), "warn"), _ziel_aus(hid), None, schw)
elif art == "erledigt":
e = _eintrag(v.get("ts"), "hinweis", f"Erledigt: {titel}", None, "ok", _ziel_aus(hid), None, schw)
elif art == "auto":
e = _eintrag(v.get("ts"), "hinweis", titel, "Selbstreparatur des Wächters.", "info", _ziel_aus(hid),
None, schw)
else:
continue
eintraege.append(e)
for hid, h in aktuell.items():
if hid not in erschienen and isinstance(h, dict):
eintraege.append(_eintrag(h.get("seit"), "hinweis", f"Hinweis: {h.get('titel') or hid}", h.get("text"),
STUFE_HINWEIS.get(str(h.get("stufe")), "warn"), _ziel_aus(hid), None, schw))
return eintraege
# --- Meldungen ------------------------------------------------------------------------------------------
def _meldelog() -> list[list]:
"""[Zeit, Weg, Grund, Text] je Eintrag des Melde-Logs; Zeilen ohne Zeitstempel gehören zur Meldung davor."""
try:
with melde_log().open("rb") as f:
f.seek(0, os.SEEK_END)
groesse = f.tell()
f.seek(max(0, groesse - MELDELOG_MAX))
roh = f.read().decode("utf-8", "replace")
except OSError:
return []
if groesse > MELDELOG_MAX:
roh = roh.split("\n", 1)[-1] # angeschnittene erste Zeile
eintraege: list[list] = []
for zeile in roh.splitlines():
if m := _MELDUNG.match(zeile):
try:
zeit = datetime.strptime(m.group(1), "%Y-%m-%d %H:%M:%S").replace(tzinfo=LOCAL_TZ).timestamp()
except ValueError:
continue
weg = "FALLBACK" if m.group(2).startswith("FALLBACK") else m.group(2)
eintraege.append([zeit, weg, m.group(3), m.group(4)])
elif eintraege:
eintraege[-1][3] += "\n" + zeile
return eintraege
def _betreffe(laeufe: list[dict], sammellaeufe: list[dict]) -> dict[str, str]:
"""Meldungstext → Betreff für das, was Läufe und Sammelläufe gemeldet haben."""
bekannt = {}
for x in laeufe:
if x.get("text") and x.get("gemeldet") is not False:
dringend = x.get("dringend")
if dringend is None: # Läufe von vor dem 24.09.2026 abends: wie updates._ende es entschied
dringend = x.get("ergebnis") in ("zurueckgerollt", "fehler") and "nicht begonnen" not in x["text"]
bekannt[x["text"]] = updates.BETREFF_ALARM if dringend else updates.BETREFF
for sl in sammellaeufe:
if sl.get("meldung"):
bekannt[sl["meldung"]] = updates.BETREFF_ALARM if sl.get("dringend") else updates.BETREFF
return bekannt
def _betreff(text: str, bekannt: dict[str, str]) -> str | None:
if text in bekannt:
return bekannt[text]
if text.endswith(LUCY_ZUSATZ):
return BETREFF_WAECHTER[1] if text.startswith("Erledigt:") else BETREFF_WAECHTER[0]
if text.startswith("Testmeldung aus den Einstellungen"):
return BETREFF_TEST
if text.startswith("Guten Morgen, Commander."):
return BETREFF_MORGEN
return None
def _meldungen(bekannt: dict[str, str], schw: Callable) -> list[dict]:
eintraege = []
for zeit, weg, grund, text in _meldelog():
betreff = _betreff(text, bekannt)
if weg == "FALLBACK":
titel, stufe, wie = "Meldung ging nicht raus", "fehler", f"Telegram ging nicht: {grund or 'ohne Angabe'}"
else:
titel, stufe, wie = _WEG[weg]
kurz = _zeile(text)
details = wie if kurz == text.strip() else f"{wie}\n\n{text.strip()}"
eintraege.append(_eintrag(zeit, "meldung", f"{titel} · {betreff}" if betreff else titel, kurz, stufe, None,
details, schw))
return eintraege
# --- Pflege ---------------------------------------------------------------------------------------------
_PFLEGE = {"angestossen": ("Wöchentliche Suche angestoßen", "info"),
"gescheitert": ("Wöchentliche Suche gescheitert", "warn"),
"wieder_ok": ("Wöchentliche Suche klappt wieder", "ok")}
def _pflege(geraete: dict, schw: Callable) -> list[dict]:
daten = pflege.zustand()
ereignisse = [e for e in daten.get("ereignisse") or [] if isinstance(e, dict)]
eintraege = []
for e in ereignisse:
if e.get("art") not in _PFLEGE or not isinstance(e.get("vmid"), int):
continue
titel, stufe = _PFLEGE[e["art"]]
name = e.get("name") or geraete.get(e["vmid"], (None, f"Gast {e['vmid']}"))[1]
eintraege.append(_eintrag(e.get("ts"), "pflege", f"{titel} · {name}", e.get("text"), stufe,
f"ct-{e['vmid']}", None, schw))
# Stände von vor dem 24.09.2026 abends haben keine Ereignisliste: dann wenigstens der letzte Anstoß je Gast.
schon = {(e.get("vmid"), e.get("ts")) for e in ereignisse if e.get("art") == "angestossen"}
for vmid, g in (daten.get("gaeste") or {}).items():
if not (isinstance(g, dict) and isinstance(g.get("angestossen"), (int, float)) and str(vmid).isdigit()):
continue
if (int(vmid), g["angestossen"]) not in schon:
name = g.get("name") or geraete.get(int(vmid), (None, f"Gast {vmid}"))[1]
eintraege.append(_eintrag(g["angestossen"], "pflege", f"{_PFLEGE['angestossen'][0]} · {name}",
"Die Paketlisten waren älter als 7 Tage.", "info", f"ct-{vmid}", None, schw))
return eintraege
# --- Zusammen -------------------------------------------------------------------------------------------
def eintraege(grenze: int = 200, berichte: bool = False) -> list[dict]:
"""Alle Einträge, neueste zuerst, höchstens `grenze` (1 bis GRENZE_MAX)."""
grenze = max(1, min(int(grenze), GRENZE_MAX))
schw = _schwaerzer()
geraete = _geraete()
laeufe = updates.laeufe(100)
sammellaeufe = sammellauf.liste()
alle: list[dict | None] = []
alle += [_auftrag(a, geraete, schw) for a in kanal.liste() if berichte or a.get("aktion") != "bericht"]
if berichte:
alle += [_bericht_eingang(e, schw) for e in kanal.berichte()]
alle += [_lauf(x, schw) for x in laeufe if isinstance(x, dict)]
alle += [_sammellauf(sl, schw) for sl in sammellaeufe]
alle += _hinweise(schw)
alle += _meldungen(_betreffe(laeufe, sammellaeufe), schw)
alle += _pflege(geraete, schw)
liste = [e for e in alle if e is not None]
liste.sort(key=lambda e: e["zeit"], reverse=True)
return liste[:grenze]
+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()
+82 -38
View File
@@ -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"]}
+7 -4
View File
@@ -628,8 +628,11 @@ def _speichere() -> None:
log.warning("waechter: Stand %s nicht schreibbar", STORE_PATH, exc_info=True)
def _verlauf(art: str, hinweis_id: str, text: str) -> None:
_stand["verlauf"].append({"ts": time.time(), "art": art, "id": hinweis_id, "text": text})
def _verlauf(art: str, hinweis_id: str, text: str, stufe: str | None = None) -> None:
eintrag = {"ts": time.time(), "art": art, "id": hinweis_id, "text": text}
if stufe: # seit 24.09.2026: das Protokoll des Homelab-Teils zeigt, wie ernst ein Hinweis war
eintrag["stufe"] = stufe
_stand["verlauf"].append(eintrag)
del _stand["verlauf"][:-VERLAUF_MAX]
@@ -700,7 +703,7 @@ def takt() -> None:
aktionen=b.aktionen, zuletzt=jetzt, takte=k["takte"])
hinweise[b.id] = h
if neu:
_verlauf("neu", b.id, b.titel)
_verlauf("neu", b.id, b.titel, b.stufe)
if b.stufe == "rot" and (neu or jetzt - h.get("gemeldet", 0.0) >= REMIND_S):
vorsatz = "" if neu else "Immer noch: "
_telegram(BETREFF_PROBLEM, f"{vorsatz}{b.titel}. {b.text}")
@@ -714,7 +717,7 @@ def takt() -> None:
if update and hid.startswith(("dienst:", "kern:")):
continue
h = hinweise.pop(hid)
_verlauf("erledigt", hid, h.get("titel", hid))
_verlauf("erledigt", hid, h.get("titel", hid), h.get("stufe"))
if h.get("stufe") == "rot" and h.get("gemeldet"):
_telegram(BETREFF_OK, f"Erledigt: {h.get('titel', hid)}.")
for kid in list(kandidaten):