Merge branch 'worktree-agent-a8a38897ceeaf6694' into wartung/oberflaeche-v4
This commit is contained in:
@@ -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
|
||||
"""
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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.")}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
@@ -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()
|
||||
@@ -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"]}
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user