homelab: „Alle aktualisieren“, Protokoll und Arcane-Schlüssel über die Oberfläche

- Sammellauf (services/homelab/sammellauf.py): alle Updates mit Knopf nacheinander über updates.starten,
  Gäste nach VMID, dann NPMplus und AdGuard, zuletzt die Host-Pakete (nie der Neustart). Rot stoppt den Rest
  (übersprungen, abgebrochen, dringende Meldung); grün gibt eine Sammelmeldung, die Erfolgsmeldungen der
  Schritte bleiben so lange aus. Nur einer gleichzeitig, der einzelne Knopf lehnt solange ab (START_SPERRE).
  Ein Neustart mitten im Lauf macht ihn beim Start zu „abgebrochen“, „unterbrochen (Neustart)“.
- Protokoll (services/homelab/protokoll.py): Aufträge, Läufe, Sammelläufe, Wächter-Verlauf, Melde-Log und
  wöchentliches Suchen, neueste zuerst, ohne Geheimnisse. Der Kanal hebt 500 erledigte Aufträge auf und
  vermerkt eingehende Berichte; die Pflege führt eine kurze Ereignisliste, der Wächter-Verlauf die Stufe.
- Einstellungen (services/homelab/einstellungen.py): Arcane-Schlüssel erst gegen Arcane prüfen, dann in
  arcane.key (0600) speichern, nie zurückgeben; echte Docker-Updates per Schalter. Die Umgebung geht vor.
- Schnittstellen als eigener Block in routers/homelab.py vor den Ausführer-Endpunkten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-25 10:09:14 +02:00
co-authored by Claude Opus 5.5
parent bf1153cb66
commit 8ec261b8b2
15 changed files with 2329 additions and 60 deletions
+67
View File
@@ -90,6 +90,73 @@ def laeufe() -> dict:
return {"laeufe": updates.laeufe()} return {"laeufe": updates.laeufe()}
# --- „Alle aktualisieren“, Protokoll, Einstellungen (24.09.2026) ------------------------------------------------
# GET /api/homelab/alle/plan was „Alle aktualisieren“ täte, in dieser Reihenfolge
# POST /api/homelab/alle „Alle aktualisieren“ starten
# GET /api/homelab/sammellauf der laufende oder der letzte Sammellauf
# GET /api/homelab/protokoll?grenze=200&berichte=0 was wann geschah, neueste zuerst
# GET /api/homelab/einstellungen Arcane (Schlüssel, echt), Wartezeit, Ausführer
# POST /api/homelab/einstellungen/arcane-schluessel {"schluessel": …}: erst gegen Arcane prüfen, dann speichern
# DELETE /api/homelab/einstellungen/arcane-schluessel
# POST /api/homelab/einstellungen/arcane-echt {"an": bool}: echte Docker-Updates statt Probelauf
# Was nicht geht, beantworten sie mit {"ok": false, "detail": …} und HTTP 200, wie der Telegram-Test (die Oberfläche
# liest ok). Die Dienste laden die Endpunkte selbst, wie hinweise().
@router.get("/alle/plan")
def alle_plan() -> dict:
from services.homelab import sammellauf
return sammellauf.plan()
@router.post("/alle")
def alle_starten() -> dict:
from services.homelab import sammellauf
return sammellauf.starten()
@router.get("/sammellauf")
def sammellauf_stand() -> dict:
from services.homelab import sammellauf
return {"sammellauf": sammellauf.aktueller()}
@router.get("/protokoll")
def protokoll_lesen(grenze: int = 200, berichte: bool = False) -> dict:
from services.homelab import protokoll
return {"eintraege": protokoll.eintraege(grenze, berichte)}
@router.get("/einstellungen")
def einstellungen_stand() -> dict:
from services.homelab import einstellungen as homelab_einstellungen
return homelab_einstellungen.stand()
@router.post("/einstellungen/arcane-schluessel")
async def arcane_schluessel_setzen(request: Request) -> dict:
"""Den Body liest der Dienst selbst: Ein Pydantic-Modell würde bei falscher Form mit 422 antworten und die
Eingabe, also den Schlüssel, dabei wiederholen."""
from services.homelab import einstellungen as homelab_einstellungen
wert = homelab_einstellungen.schluessel_aus_body(await request.body())
return await asyncio.to_thread(homelab_einstellungen.arcane_schluessel_setzen, wert)
@router.delete("/einstellungen/arcane-schluessel")
def arcane_schluessel_loeschen() -> dict:
from services.homelab import einstellungen as homelab_einstellungen
return homelab_einstellungen.arcane_schluessel_loeschen()
class ArcaneEcht(BaseModel):
an: bool
@router.post("/einstellungen/arcane-echt")
def arcane_echt(schalter: ArcaneEcht) -> dict:
from services.homelab import einstellungen as homelab_einstellungen
return homelab_einstellungen.arcane_echt_setzen(schalter.an)
@router.get("/ausfuehrer") @router.get("/ausfuehrer")
def ausfuehrer() -> dict: def ausfuehrer() -> dict:
zuletzt = kanal.zuletzt() zuletzt = kanal.zuletzt()
+3
View File
@@ -5,5 +5,8 @@
inventar.py Bericht + neueste Versionen + Erreichbarkeit → Ziele im gemeinsamen Modell (kern/ziele.py) 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) 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 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 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 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 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/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 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 os
import threading import threading
import time import time
from datetime import datetime
from pathlib import Path
import httpx import httpx
from kern.einstellungen import einstellungen
from kern.zeit import LOCAL_TZ
ZEITLIMIT = httpx.Timeout(20.0, connect=5.0) ZEITLIMIT = httpx.Timeout(20.0, connect=5.0)
CACHE_S = 120 CACHE_S = 120
_cache: dict[str, tuple[float, object]] = {} _cache: dict[str, tuple[float, object]] = {}
_lock = threading.Lock() _lock = threading.Lock()
_datei_lock = threading.Lock()
def _gemerkt(schluessel: str, holen): def _gemerkt(schluessel: str, holen):
@@ -33,14 +45,114 @@ def _gemerkt(schluessel: str, holen):
return wert 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: 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: 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: def app_version(url: str) -> dict | None:
"""Arcanes eigene Version (öffentlich): {"installiert", "neu", "update"} oder None.""" """Arcanes eigene Version (öffentlich): {"installiert", "neu", "update"} oder None."""
return _gemerkt(f"version:{url}", lambda: _app_version(url)) 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"))} "update": bool(d.get("updateAvailable"))}
def _get(url: str, pfad: str) -> object: def _get(url: str, pfad: str, mit_schluessel: str | None = None) -> object:
r = httpx.get(f"{url}/api{pfad}", headers={"X-API-Key": schluessel()}, timeout=ZEITLIMIT) r = httpx.get(f"{url}/api{pfad}", headers={"X-API-Key": mit_schluessel or schluessel()}, timeout=ZEITLIMIT)
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
def umgebungen(url: str) -> list[dict]: def umgebungen(url: str, mit_schluessel: str | None = None) -> list[dict]:
daten = _get(url, "/environments?limit=50") 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] 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 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: def updater(url: str, probelauf: bool) -> dict:
"""Arcanes Updater für alle Umgebungen. Rückgabe: je Umgebung das Ergebnis (geprüft, aktualisiert, Fehler).""" """Arcanes Updater für alle Umgebungen. Rückgabe: je Umgebung das Ergebnis (geprüft, aktualisiert, Fehler)."""
ergebnisse = {} 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" selbst.zustand = "aktuell"
docker = BausteinStand(id="docker", name="Docker-Images", zustand="unbekannt") docker = BausteinStand(id="docker", name="Docker-Images", zustand="unbekannt")
if not arcane.schluessel(): if not arcane.schluessel():
docker.grund = ("Es fehlt ein Arcane-API-Schlüssel (MC_ARCANE_KEY): ohne ihn sieht der Orchestrator " docker.grund = ("Es fehlt ein Arcane-API-Schlüssel: Ohne ihn sieht der Orchestrator die Images nicht. "
"die Images nicht.") "Eintragen unter Einstellungen (Arcane-API-Schlüssel).")
elif basis: elif basis:
try: try:
images = arcane.images_mit_update(basis) 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. 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 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. 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 import hmac
@@ -31,7 +35,10 @@ except ImportError: # Windows (Entwicklung): nur die Thread-Sperre
AKTIONEN = {"bericht", "snapshot", "update", "os_update", "suchen", "zurueck", "snapshot_loeschen", AKTIONEN = {"bericht", "snapshot", "update", "os_update", "suchen", "zurueck", "snapshot_loeschen",
"host_update", "host_neustart", "sichern", "sicherung_zurueck", "sicherung_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) 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() _lock = threading.Lock()
_kontakt: dict = {"zuletzt": None} _kontakt: dict = {"zuletzt": None}
@@ -110,9 +117,31 @@ def zuletzt() -> float | None:
# --- Bericht ------------------------------------------------------------------------------ # --- 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: 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: def bericht() -> dict | None:
@@ -136,6 +165,10 @@ def _alle() -> list[dict]:
def _merken(auftraege: list[dict]) -> None: def _merken(auftraege: list[dict]) -> None:
offen = [a for a in auftraege if a["status"] in ("wartet", "laeuft")] 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:] 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"])) _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: def naechster() -> dict | None:
"""Für den Ausführer: der älteste wartende Auftrag, ab jetzt „läuft“. Hängengebliebene werden """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() jetzt = time.time()
with _sperre(): with _sperre():
auftraege = _alle() auftraege = _alle()
geaendert = False
for a in auftraege: for a in auftraege:
if a["status"] == "laeuft" and jetzt - (a["abgeholt"] or jetzt) > VERLOREN_S: 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.") 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) auftrag = next((a for a in auftraege if a["status"] == "wartet"), None)
if auftrag: if auftrag:
auftrag.update(status="laeuft", abgeholt=jetzt) 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 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, 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 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 import json
@@ -35,6 +37,7 @@ log = logging.getLogger(__name__)
LISTEN_ALT_S = 7 * 24 * 3600 LISTEN_ALT_S = 7 * 24 * 3600
NACHT_VON, NACHT_BIS = 2, 5 # volle Stunden, Europe/Berlin NACHT_VON, NACHT_BIS = 2, 5 # volle Stunden, Europe/Berlin
FEHLSCHLAEGE_HINWEIS = 2 FEHLSCHLAEGE_HINWEIS = 2
EREIGNISSE_MAX = 200
_lock = threading.Lock() _lock = threading.Lock()
@@ -82,10 +85,21 @@ def _fehlerzeile(text: str | None) -> str:
return (wichtig or zeilen or ["ohne Angabe"])[-1][:200] 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: def _einsammeln(daten: dict) -> bool:
"""Ergebnisse der eigenen Aufträge übernehmen: fertig → Zähler auf null, gescheitert → eins mehr.""" """Ergebnisse der eigenen Aufträge übernehmen: fertig → Zähler auf null, gescheitert → eins mehr."""
geaendert = False geaendert = False
for eintrag in daten["gaeste"].values(): for vmid, eintrag in daten["gaeste"].items():
aid = eintrag.get("auftrag") aid = eintrag.get("auftrag")
if not aid: if not aid:
continue continue
@@ -93,10 +107,15 @@ def _einsammeln(daten: dict) -> bool:
if a is not None and a.get("status") in ("wartet", "laeuft"): if a is not None and a.get("status") in ("wartet", "laeuft"):
continue continue
if a is not None and a.get("status") == "fertig": 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 eintrag["fehlschlaege"], eintrag["fehler"] = 0, None
elif a is not None: # fehler, verloren elif a is not None: # fehler, verloren
eintrag["fehlschlaege"] = int(eintrag.get("fehlschlaege") or 0) + 1 eintrag["fehlschlaege"] = int(eintrag.get("fehlschlaege") or 0) + 1
eintrag["fehler"] = _fehlerzeile(a.get("text")) 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 eintrag["auftrag"] = None # nicht mehr in der Liste: vergessen, nicht zählen
geaendert = True geaendert = True
return geaendert 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"]) log.info("pflege (trocken): würde die Paketlisten von %s erneuern", eintrag["name"])
continue continue
eintrag.update(tag=heute, auftrag=kanal.anlegen("suchen", {"vmid": vmid}), angestossen=jetzt) 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 geaendert = True
log.info("pflege: Paketlisten von %s sind alt → suchen", eintrag["name"]) log.info("pflege: Paketlisten von %s sind alt → suchen", eintrag["name"])
if geaendert: 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“. 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 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. 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 import json
@@ -39,8 +43,13 @@ ZEITLIMIT_UPDATE_S = 45 * 60
ZEITLIMIT_KURZ_S = 15 * 60 ZEITLIMIT_KURZ_S = 15 * 60
ZEITLIMIT_SICHERUNG_S = 40 * 60 # Sicherung und Zurückspielen (der Ausführer gibt nach 30 min auf) ZEITLIMIT_SICHERUNG_S = 40 * 60 # Sicherung und Zurückspielen (der Ausführer gibt nach 30 min auf)
BETREFF = "[Homelab-Update]" 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 _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: def _pfad() -> Path:
@@ -69,6 +78,10 @@ def laeufe(anzahl: int = 20) -> list[dict]:
return list(reversed(_laeufe()))[:anzahl] 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]: def laufende_ziele() -> set[str]:
"""Ziele mit einem laufenden Update. Liest nur die Datei — geht also auch im Steward (Wächter, Pflege).""" """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"} 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: 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(): for lauf in _laeufe():
if lauf.get("status") == "laeuft": if lauf.get("status") == "laeuft":
lauf.update(status="unterbrochen", ende=time.time()) lauf.update(status="unterbrochen", ende=time.time())
lauf["schritte"].append("Der Homelab-Teil wurde während des Laufs neu gestartet.") lauf["schritte"].append("Der Homelab-Teil wurde während des Laufs neu gestartet.")
_merken(lauf) _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: def _melden(text: str, dringend: bool = False) -> None:
try: try:
announce.notify_telegram("[Alarm] Homelab-Update" if dringend else BETREFF, text) announce.notify_telegram(BETREFF_ALARM if dringend else BETREFF, text)
except Exception: except Exception:
log.warning("homelab: Meldung ging nicht raus", exc_info=True) log.warning("homelab: Meldung ging nicht raus", exc_info=True)
@@ -112,7 +133,7 @@ def _frischer_gast(lauf: dict, vmid: int) -> dict | None:
try: try:
a = _auftrag(lauf, "bericht", {}, 5 * 60) a = _auftrag(lauf, "bericht", {}, 5 * 60)
bericht = json.loads(a.get("text") or "{}") bericht = json.loads(a.get("text") or "{}")
kanal.bericht_speichern(bericht) kanal.bericht_speichern(bericht, aus_auftrag=True)
except (RuntimeError, ValueError): except (RuntimeError, ValueError):
return None return None
return next((g for g in bericht.get("gaeste") or [] if g.get("vmid") == vmid), 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: try:
_auftrag(lauf, "update" if baustein == "app" else "os_update", {"vmid": vmid}, ZEITLIMIT_UPDATE_S) _auftrag(lauf, "update" if baustein == "app" else "os_update", {"vmid": vmid}, ZEITLIMIT_UPDATE_S)
time.sleep(WARTEN_NACH_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: except RuntimeError as exc:
fehler = [str(exc)] fehler = [str(exc)]
if not fehler: 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: 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) _merken(lauf)
try: try:
start = datetime.fromtimestamp(lauf["start"], LOCAL_TZ).isoformat(timespec="seconds") # Die Schritte eines Sammellaufs stehen im Update-Verlauf als EIN Lauf (gleicher Beginn, eigener Anlass).
update_verlauf.eintragen(start, "Update von Hand", lauf["ziel"], ergebnis, text) 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: except Exception:
log.warning("homelab: Verlauf nicht geschrieben", exc_info=True) 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", 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) _merken(lauf)
return lauf return lauf
def starten(ziel_id: str, baustein: str) -> dict: 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": …}.""" """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"): if baustein not in ("app", "os", "pakete", "neustart", "docker"):
return {"ok": False, "detail": "Unbekannter Baustein."} return {"ok": False, "detail": "Unbekannter Baustein."}
if laeuft(ziel_id): with START_SPERRE:
return {"ok": False, "detail": "Für dieses Gerät läuft schon ein Update."} if sammellauf is None and _sammellauf_laeuft():
if ziel_id == "pve": return {"ok": False, "detail": SAMMELLAUF_SPERRE}
name, gast = "Proxmox-Host", None if laeuft(ziel_id):
else: return {"ok": False, "detail": "Für dieses Gerät läuft schon ein Update."}
gast = inventar.gast(ziel_id) if ziel_id == "pve":
if not gast: name, gast = "Proxmox-Host", None
return {"ok": False, "detail": "Dieses Gerät steht nicht im Bericht des Ausführers."} host = ((kanal.bericht() or {}).get("bericht") or {}).get("host") or {}
if baustein == "docker": mehr = {"pakete": len(host.get("updates") or [])} if baustein == "pakete" else {}
app = apps.app_fuer(None, gast.get("name")) else:
basis = (apps.adresse(app, gast.get("ip")) or "").rstrip("/") gast = inventar.gast(ziel_id)
if not (app and app.kennung == "arcane" and basis and arcane.schluessel()): if not gast:
return {"ok": False, "detail": "Docker-Updates gehen nur über Arcane mit API-Schlüssel."} return {"ok": False, "detail": "Dieses Gerät steht nicht im Bericht des Ausführers."}
lauf = _neuer_lauf(ziel_id, baustein, app.name) if baustein == "docker":
threading.Thread(target=_docker_lauf, args=(lauf, basis), name=f"homelab-{ziel_id}", daemon=True).start() app = apps.app_fuer(None, gast.get("name"))
return {"ok": True, "lauf": lauf["id"]} basis = (apps.adresse(app, gast.get("ip")) or "").rstrip("/")
if not gast.get("erlaubt"): if not (app and app.kennung == "arcane" and basis and arcane.schluessel()):
return {"ok": False, "detail": "Dieses Gerät ist nicht freigegeben (Etikett watcher fehlt)."} return {"ok": False, "detail": "Docker-Updates gehen nur über Arcane mit API-Schlüssel."}
app = apps.app_fuer((gast.get("app") or {}).get("kennung"), gast.get("name")) lauf = _neuer_lauf(ziel_id, baustein, app.name, sammellauf)
name = app.name if app else str(gast.get("name")) threading.Thread(target=_docker_lauf, args=(lauf, basis), name=f"homelab-{ziel_id}",
if baustein == "app" and (not app or app.weg != "skript"): daemon=True).start()
return {"ok": False, "detail": f"{name} aktualisiert sich nicht über das Community-Script " return {"ok": True, "lauf": lauf["id"]}
"(eigene Oberfläche oder Pakete)."} if not gast.get("erlaubt"):
if baustein == "app" and (sperre := karenz.pruefen(app.kennung)["gesperrt"]): return {"ok": False, "detail": "Dieses Gerät ist nicht freigegeben (Etikett watcher fehlt)."}
return {"ok": False, "detail": sperre} app = apps.app_fuer((gast.get("app") or {}).get("kennung"), gast.get("name"))
lauf = _neuer_lauf(ziel_id, baustein, 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)) 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() threading.Thread(target=ziel, args=(lauf,), name=f"homelab-{ziel_id}", daemon=True).start()
return {"ok": True, "lauf": lauf["id"]} return {"ok": True, "lauf": lauf["id"]}
+7 -4
View File
@@ -626,8 +626,11 @@ def _speichere() -> None:
log.warning("waechter: Stand %s nicht schreibbar", STORE_PATH, exc_info=True) log.warning("waechter: Stand %s nicht schreibbar", STORE_PATH, exc_info=True)
def _verlauf(art: str, hinweis_id: str, text: str) -> None: def _verlauf(art: str, hinweis_id: str, text: str, stufe: str | None = None) -> None:
_stand["verlauf"].append({"ts": time.time(), "art": art, "id": hinweis_id, "text": text}) 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] del _stand["verlauf"][:-VERLAUF_MAX]
@@ -698,7 +701,7 @@ def takt() -> None:
aktionen=b.aktionen, zuletzt=jetzt, takte=k["takte"]) aktionen=b.aktionen, zuletzt=jetzt, takte=k["takte"])
hinweise[b.id] = h hinweise[b.id] = h
if neu: 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): if b.stufe == "rot" and (neu or jetzt - h.get("gemeldet", 0.0) >= REMIND_S):
vorsatz = "" if neu else "Immer noch: " vorsatz = "" if neu else "Immer noch: "
_telegram(BETREFF_PROBLEM, f"{vorsatz}{b.titel}. {b.text}") _telegram(BETREFF_PROBLEM, f"{vorsatz}{b.titel}. {b.text}")
@@ -712,7 +715,7 @@ def takt() -> None:
if update and hid.startswith(("dienst:", "kern:")): if update and hid.startswith(("dienst:", "kern:")):
continue continue
h = hinweise.pop(hid) 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"): if h.get("stufe") == "rot" and h.get("gemeldet"):
_telegram(BETREFF_OK, f"Erledigt: {h.get('titel', hid)}.") _telegram(BETREFF_OK, f"Erledigt: {h.get('titel', hid)}.")
for kid in list(kandidaten): for kid in list(kandidaten):
+1 -1
View File
@@ -119,7 +119,7 @@ def test_ziele_aus_dem_echten_bericht(daten):
assert z["vm-106"]["name"] == "Arcane (Docker)" assert z["vm-106"]["name"] == "Arcane (Docker)"
# Arcane läuft über seine eigene Schnittstelle: ohne Schlüssel sagt die Übersicht das ehrlich. # Arcane läuft über seine eigene Schnittstelle: ohne Schlüssel sagt die Übersicht das ehrlich.
assert (arcane["arcane"]["zustand"], arcane["arcane"]["kurz"]) == ("neu", "2.12.0 → 2.13.1") assert (arcane["arcane"]["zustand"], arcane["arcane"]["kurz"]) == ("neu", "2.12.0 → 2.13.1")
assert arcane["docker"]["zustand"] == "unbekannt" and "MC_ARCANE_KEY" in arcane["docker"]["grund"] assert arcane["docker"]["zustand"] == "unbekannt" and "Einstellungen" in arcane["docker"]["grund"]
def test_ohne_bericht_leer_und_ehrlich(daten): def test_ohne_bericht_leer_und_ehrlich(daten):
+260
View File
@@ -0,0 +1,260 @@
"""Einstellungen des Homelab-Teils (services/homelab/einstellungen.py, 24.09.2026): den Arcane-Schlüssel über die
Oberfläche eintragen (erst von Arcane geprüft, nie zurückgegeben) und echte Docker-Updates statt Probelauf. Arcane ist
ein kleiner Nachbau mit derselben Antwortform wie v2.12/2.13 (401 bei falschem Schlüssel)."""
import json
import logging
import os
import subprocess
import sys
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from kern import einstellungen as einstellungen_mod
from services.homelab import arcane, einstellungen, inventar, kanal
BERICHT = json.loads((Path(__file__).parent / "fixtures" / "pve-bericht.json").read_text(encoding="utf-8"))
BACKEND = Path(__file__).resolve().parents[1]
RICHTIG = "arc_RichtigerSchluessel_0123456789abcdef"
FALSCH = "arc_FalscherSchluessel_9876543210fedcba"
ARCANE_URL_AUS_DEM_BERICHT = einstellungen.arcane_url # das Original; die Tests lenken auf den Nachbau um
@pytest.fixture
def arcane_nachbau():
"""Arcane mit einer Umgebung und zwei Images, eins davon mit neuerem Stand."""
stand: dict = {"schluessel": RICHTIG, "status": None, "aufrufe": []}
class Handler(BaseHTTPRequestHandler):
def _antwort(self, code: int, daten: dict) -> None:
roh = json.dumps(daten).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(roh)))
self.end_headers()
self.wfile.write(roh)
def do_GET(self):
stand["aufrufe"].append(self.path)
if stand["status"]:
return self._antwort(stand["status"], {"title": "Fehler", "status": stand["status"]})
if self.headers.get("X-API-Key") != stand["schluessel"]:
return self._antwort(401, {"title": "Unauthorized", "status": 401,
"detail": "Unauthorized: invalid API key"})
if self.path.startswith("/api/environments?"):
return self._antwort(200, {"success": True, "data": [{"id": "0", "name": "local"}],
"pagination": {"totalItems": 1}})
if self.path.startswith("/api/environments/0/images"):
return self._antwort(200, {"success": True, "pagination": {"totalItems": 2}, "data": [
{"id": "a", "repo": "ghcr.io/tobi/nerdquiz", "tag": "latest", "usedBy": [{"name": "nerdquiz"}],
"updateInfo": {"hasUpdate": True, "currentVersion": "1.0", "latestVersion": "1.1"}},
{"id": "b", "repo": "nginx", "tag": "1.27", "usedBy": [],
"updateInfo": {"hasUpdate": False}}]})
return self._antwort(404, {"title": "Not Found", "status": 404})
def log_message(self, *args):
pass
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
yield f"http://127.0.0.1:{server.server_port}", stand
server.shutdown()
@pytest.fixture
def daten(tmp_path, monkeypatch, arcane_nachbau):
monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path))
for name in ("MC_AUSFUEHRER_TOKEN", "MC_ARCANE_KEY", "MC_ARCANE_ECHT", "MC_HOMELAB_KARENZ_H"):
monkeypatch.delenv(name, raising=False)
einstellungen_mod.einstellungen.cache_clear()
monkeypatch.setattr(kanal, "_kontakt", {"zuletzt": None})
arcane.cache_leeren()
kanal.bericht_speichern(BERICHT)
url, _ = arcane_nachbau
monkeypatch.setattr(einstellungen, "arcane_url", lambda: url) # die VM 106 zeigt hier auf den Nachbau
yield tmp_path
arcane.cache_leeren()
einstellungen_mod.einstellungen.cache_clear()
def _client() -> TestClient:
from routers import homelab as homelab_router
app = FastAPI()
app.include_router(homelab_router.router)
return TestClient(app)
def test_stand_von_anfang_an(daten, monkeypatch):
monkeypatch.setattr(einstellungen, "arcane_url", lambda: None)
assert einstellungen.stand()["arcane"]["url"] == ""
monkeypatch.setattr(einstellungen, "arcane_url", ARCANE_URL_AUS_DEM_BERICHT) # die VM 106 aus dem Bericht
stand = _client().get("/api/homelab/einstellungen").json()
assert stand == {
"arcane": {"url": "http://192.168.178.28:3552", "schluessel": "fehlt", "echt": False,
"echt_quelle": "standard"},
"karenz_h": 48,
"ausfuehrer": {"verbunden": True, "zuletzt": stand["ausfuehrer"]["zuletzt"], "nur_lesen": False,
"sicherung_speicher": None}}
bericht = json.loads(json.dumps(BERICHT))
bericht["host"].update(nur_lesen=True, sicherung={"speicher": "local", "art": "dir", "frei": 1})
kanal.bericht_speichern(bericht)
monkeypatch.setenv("MC_HOMELAB_KARENZ_H", "72")
stand = einstellungen.stand()
assert stand["ausfuehrer"]["nur_lesen"] is True and stand["ausfuehrer"]["sicherung_speicher"] == "local"
assert stand["karenz_h"] == 72
def test_richtiger_schluessel_wird_gespeichert_und_gilt_sofort(daten, arcane_nachbau, caplog):
url, stand = arcane_nachbau
caplog.set_level(logging.DEBUG)
antwort = _client().post("/api/homelab/einstellungen/arcane-schluessel", json={"schluessel": f" {RICHTIG}\n"})
assert antwort.status_code == 200
assert antwort.json() == {"ok": True, "text": "Arcane nimmt den Schlüssel an: 2 Images in 1 Umgebung geprüft, 1 "
"davon mit neuerem Stand. Die Übersicht zeigt die Docker-Images ab "
"sofort."}
assert stand["aufrufe"] == ["/api/environments?limit=50", "/api/environments/0/images?limit=500"]
datei = daten / "arcane.key"
assert datei.read_text(encoding="utf-8") == RICHTIG + "\n" and arcane.schluessel() == RICHTIG
if os.name == "posix":
assert datei.stat().st_mode & 0o077 == 0
assert _client().get("/api/homelab/einstellungen").json()["arcane"]["schluessel"] == "hinterlegt"
# Ohne Neustart: Die Übersicht fragt Arcane mit dem neuen Schlüssel und sieht die Images.
_, docker = inventar._arcane_bausteine(url, "vm-106")
assert (docker.zustand, docker.kurz, docker.aktion.label) == ("neu", "1 Images", "Probelauf")
assert RICHTIG not in caplog.text
def test_falscher_schluessel_wird_nicht_gespeichert(daten, caplog):
caplog.set_level(logging.DEBUG)
antwort = _client().post("/api/homelab/einstellungen/arcane-schluessel", json={"schluessel": FALSCH})
assert antwort.status_code == 200
assert antwort.json() == {"ok": False, "detail": "Arcane lehnt den Schlüssel ab (HTTP 401)."}
assert not (daten / "arcane.key").exists() and arcane.schluessel() == ""
assert FALSCH not in antwort.text and FALSCH not in caplog.text
def test_ohne_rechte_und_bei_anderen_fehlern_nichts_gespeichert(daten, arcane_nachbau):
_, stand = arcane_nachbau
stand["status"] = 403
assert einstellungen.arcane_schluessel_setzen(RICHTIG)["detail"] == (
"Arcane lehnt den Schlüssel ab (HTTP 403). Ihm fehlt das Recht, Umgebungen und Images zu lesen.")
stand["status"] = 500
assert einstellungen.arcane_schluessel_setzen(RICHTIG)["detail"] == (
"Arcane antwortet mit HTTP 500. Der Schlüssel ist nicht gespeichert.")
assert not (daten / "arcane.key").exists()
def test_arcane_nicht_erreichbar(daten, monkeypatch):
frei = ThreadingHTTPServer(("127.0.0.1", 0), BaseHTTPRequestHandler)
tot = f"http://127.0.0.1:{frei.server_port}"
frei.server_close() # der Port ist jetzt zu
monkeypatch.setattr(einstellungen, "arcane_url", lambda: tot)
ergebnis = einstellungen.arcane_schluessel_setzen(RICHTIG)
assert ergebnis == {"ok": False, "detail": f"Arcane ist nicht erreichbar ({tot}). Der Schlüssel ist nicht "
"gespeichert."}
monkeypatch.setattr(einstellungen, "arcane_url", lambda: None)
assert einstellungen.arcane_schluessel_setzen(RICHTIG)["detail"].startswith("Arcane steht nicht im Bericht")
assert not (daten / "arcane.key").exists()
@pytest.mark.parametrize("body", [b'"arc_nur_ein_text_0123456789"', b'{"schluessel": 12345678901}',
b'{"schluessel": ["arc_in_einer_liste_0123456789"]}', b"kein json arc_roh_0123456789",
b'{"schluessel": "arc mit leerzeichen 0123456789"}', b'{"schluessel": "kurz"}', b""])
def test_falsche_form_wiederholt_nichts(daten, arcane_nachbau, body):
_, stand = arcane_nachbau
antwort = _client().post("/api/homelab/einstellungen/arcane-schluessel", content=body,
headers={"Content-Type": "application/json"})
assert antwort.status_code == 200 and antwort.json()["ok"] is False
assert "arc_" not in antwort.text and "12345678901" not in antwort.text and "leerzeichen" not in antwort.text
assert stand["aufrufe"] == [] and not (daten / "arcane.key").exists()
def test_loeschen(daten):
assert einstellungen.arcane_schluessel_setzen(RICHTIG)["ok"]
antwort = _client().delete("/api/homelab/einstellungen/arcane-schluessel")
assert antwort.json() == {"ok": True, "text": "Der Arcane-Schlüssel ist gelöscht."}
assert not (daten / "arcane.key").exists() and arcane.schluessel_quelle() == "fehlt"
assert _client().delete("/api/homelab/einstellungen/arcane-schluessel").json()["text"] == (
"Es war kein Arcane-Schlüssel hinterlegt.")
docker = inventar._arcane_bausteine("http://127.0.0.1:9", "vm-106")[1]
assert docker.zustand == "unbekannt" and docker.grund.startswith("Es fehlt ein Arcane-API-Schlüssel")
def test_die_umgebung_geht_vor(daten, monkeypatch):
assert einstellungen.arcane_schluessel_setzen(RICHTIG)["ok"]
monkeypatch.setenv("MC_ARCANE_KEY", "arc_aus_der_umgebung_0123456789")
assert arcane.schluessel() == "arc_aus_der_umgebung_0123456789" and arcane.schluessel_quelle() == "umgebung"
assert einstellungen.stand()["arcane"]["schluessel"] == "umgebung"
assert einstellungen.arcane_schluessel_setzen(FALSCH) == {"ok": False, "detail": einstellungen.UMGEBUNG_SCHLUESSEL}
geloescht = einstellungen.arcane_schluessel_loeschen()
assert geloescht["ok"] and "Die Umgebung setzt weiter einen (MC_ARCANE_KEY); der gilt." in geloescht["text"]
assert not (daten / "arcane.key").exists()
def test_echt_schalter(daten, monkeypatch):
c = _client()
antwort = c.post("/api/homelab/einstellungen/arcane-echt", json={"an": True})
assert antwort.status_code == 200 and antwort.json()["ok"] is True
assert arcane.echt() is True
assert c.get("/api/homelab/einstellungen").json()["arcane"] | {"url": ""} == {
"url": "", "schluessel": "fehlt", "echt": True, "echt_quelle": "einstellung"}
assert json.loads((daten / "homelab-einstellungen.json").read_text(encoding="utf-8"))["arcane"] == {"echt": True}
assert c.post("/api/homelab/einstellungen/arcane-echt", json={"an": False}).json()["ok"] is True
assert arcane.echt() is False and arcane.echt_quelle() == "einstellung"
# Setzt die Umgebung MC_ARCANE_ECHT, gewinnt sie; der Schalter sagt es und ändert nichts.
monkeypatch.setenv("MC_ARCANE_ECHT", "1")
abgelehnt = c.post("/api/homelab/einstellungen/arcane-echt", json={"an": False}).json()
assert abgelehnt["ok"] is False and "MC_ARCANE_ECHT" in abgelehnt["detail"] and "echt" in abgelehnt["detail"]
assert arcane.echt() is True and arcane.echt_quelle() == "umgebung"
monkeypatch.setenv("MC_ARCANE_ECHT", "0")
assert arcane.echt() is False and einstellungen.stand()["arcane"]["echt_quelle"] == "umgebung"
assert c.post("/api/homelab/einstellungen/arcane-echt", json={"an": "vielleicht"}).status_code == 422
def test_echt_schalter_macht_aus_dem_probelauf_ein_update(daten, arcane_nachbau):
url, _ = arcane_nachbau
assert einstellungen.arcane_schluessel_setzen(RICHTIG)["ok"]
assert inventar._arcane_bausteine(url, "vm-106")[1].aktion.label == "Probelauf"
assert einstellungen.arcane_echt_setzen(True)["ok"]
assert inventar._arcane_bausteine(url, "vm-106")[1].aktion.label == "Jetzt updaten"
# --- Herkunftsprüfung (Middleware der App, Rolle homelab) ---------------------------------------------------------
_HERKUNFT = """
import json
from fastapi.testclient import TestClient
import app
c = TestClient(app.app)
fremd = {"Origin": "https://boese.example"}
eigen = {"Origin": "http://testserver"}
ergebnis = {
"schluessel_fremd": c.post("/api/homelab/einstellungen/arcane-schluessel", json={"schluessel": "x"},
headers=fremd).status_code,
"loeschen_fremd": c.delete("/api/homelab/einstellungen/arcane-schluessel", headers=fremd).status_code,
"echt_fremd": c.post("/api/homelab/einstellungen/arcane-echt", json={"an": True}, headers=fremd).status_code,
"alle_fremd": c.post("/api/homelab/alle", headers=fremd).status_code,
"schluessel_eigen": c.post("/api/homelab/einstellungen/arcane-schluessel", json={"schluessel": "x"},
headers=eigen).json(),
"lesen_fremd": c.get("/api/homelab/einstellungen", headers=fremd).status_code,
}
print(json.dumps(ergebnis))
"""
def test_herkunftspruefung_gilt_auch_hier(tmp_path):
env = {**os.environ, "MC_ROLLE": "homelab", "MC_MODELS_DIR": str(tmp_path), "MC_DATEN_DIR": str(tmp_path),
"MC_PARTNER_URL": ""}
env.pop("MC_ARCANE_KEY", None)
lauf = subprocess.run([sys.executable, "-c", _HERKUNFT], cwd=BACKEND, env=env, capture_output=True, text=True,
timeout=120)
assert lauf.returncode == 0, lauf.stderr[-2000:]
ergebnis = json.loads(lauf.stdout.strip().splitlines()[-1])
assert [ergebnis[k] for k in ("schluessel_fremd", "loeschen_fremd", "echt_fremd", "alle_fremd")] == [403] * 4
assert ergebnis["schluessel_eigen"]["ok"] is False # durchgelassen; nur zu kurz
assert ergebnis["lesen_fremd"] == 200
assert not (tmp_path / "arcane.key").exists() and not (tmp_path / "homelab-einstellungen.json").exists()
+335
View File
@@ -0,0 +1,335 @@
"""Protokoll des Homelab-Teils (services/homelab/protokoll.py, 24.09.2026): alle Quellen, sprechende deutsche Titel,
neueste zuerst — und keine Geheimnisse. Dazu die längere Aufbewahrung im Kanal und die Ereignisse der Pflege."""
import json
from collections import Counter
from datetime import datetime
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from kern import einstellungen as einstellungen_mod
from kern.zeit import LOCAL_TZ
from services import waechter
from services.homelab import kanal, pflege, protokoll, sammellauf, updates
BERICHT = json.loads((Path(__file__).parent / "fixtures" / "pve-bericht.json").read_text(encoding="utf-8"))
T = datetime(2026, 9, 24, 20, 0, tzinfo=LOCAL_TZ).timestamp()
ARCANE_KEY = "arc_Zz9TestSchluesselNurFuerDenTest"
AUSFUEHRER_TOKEN = "kanal-token-nur-fuer-den-test-0123456789"
GEHEIM = [ARCANE_KEY, AUSFUEHRER_TOKEN, "abcdefghijklmnop123", "geheim123", "AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw",
"hunter2hunter", "supergeheim123456", "ghp_0123456789abcdefghijABCDEFGHIJ"]
GEHEIME_AUSGABE = (
"\x1b[H\x1b[J\x1b[3J _______ __\nDownloading 10%\rDownloading 100%\n"
"Authorization: Bearer abcdefghijklmnop123\ncurl https://tobi:geheim123@example.org/x\n"
"TELEGRAM_BOT_TOKEN=123456789:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw\n"
"POST https://api.telegram.org/bot123456789:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw/sendMessage\n"
f"X-API-Key: {ARCANE_KEY}\nX-MC2-Ausfuehrer: {AUSFUEHRER_TOKEN}\npassword=hunter2hunter\n"
"git clone https://ghp_0123456789abcdefghijABCDEFGHIJ@github.com/x/y\n"
"E: Sub-process /usr/bin/dpkg returned an error code (1)")
def _zeit(ts: float) -> str:
return datetime.fromtimestamp(ts, LOCAL_TZ).strftime("%Y-%m-%d %H:%M:%S")
def _auftrag(aid: str, aktion: str, vmid: int | None, status: str, code: int | None, text: str | None,
ab: float, dauer: float = 3) -> dict:
return {"id": aid, "aktion": aktion, "parameter": {} if vmid is None else {"vmid": vmid}, "status": status,
"erstellt": ab, "abgeholt": None if status == "wartet" else ab + 1,
"fertig": ab + 1 + dauer if status not in ("wartet", "laeuft") else None, "code": code, "text": text}
def _lauf(lid: str, ziel: str, baustein: str, name: str, ende: float, **mehr) -> dict:
return {"id": lid, "ziel": ziel, "baustein": baustein, "name": name, "status": "fertig", "start": ende - 60,
"ende": ende, "ergebnis": None, "text": None, "schritte": ["snapshot: ok", "update: ok"], **mehr}
@pytest.fixture
def daten(tmp_path, monkeypatch):
monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path))
monkeypatch.setenv("MC_NOTIFY_LOG", str(tmp_path / "notify.log"))
for name in ("MC_AUSFUEHRER_TOKEN", "MC_ARCANE_KEY", "MC_ARCANE_ECHT", "MC_WAECHTER_STORE"):
monkeypatch.delenv(name, raising=False)
einstellungen_mod.einstellungen.cache_clear()
monkeypatch.setattr(kanal, "_kontakt", {"zuletzt": None})
kanal.bericht_speichern(BERICHT)
(tmp_path / "arcane.key").write_text(ARCANE_KEY + "\n", encoding="utf-8")
(tmp_path / "ausfuehrer.token").write_text(AUSFUEHRER_TOKEN + "\n", encoding="utf-8")
yield tmp_path
einstellungen_mod.einstellungen.cache_clear()
@pytest.fixture
def geschichte(daten):
"""Ein Abend im Homelab, aus allen Quellen."""
kanal._json_schreiben(kanal._pfad(), [
_auftrag("a1", "suchen", 104, "fertig", 0, "Hit:1 http://deb.debian.org/debian trixie InRelease\n", T),
_auftrag("a2", "snapshot", 104, "fertig", 0, "snapshot=mc2-20260924-192352\nfreeze guest filesystem", T + 10),
_auftrag("a3", "update", 104, "fehler", 1, GEHEIME_AUSGABE, T + 20, dauer=60),
_auftrag("a4", "bericht", None, "fertig", 0, json.dumps(BERICHT), T + 90),
_auftrag("a5", "os_update", 105, "fehler", 2, "Abgelehnt: Der Ausführer läuft im Nur-Lesen-Modus.", T + 100),
_auftrag("a6", "host_update", None, "verloren", None, "Der Ausführer hat nie geantwortet.", T + 110),
_auftrag("a8", "sichern", 105, "fertig", 0,
"INFO: fertig\nsicherung=local:backup/vzdump-lxc-105-2026_09_24-22_10_00.tar.zst", T + 130),
_auftrag("a9", "os_update", 101, "fertig", 0, "\n".join(f"Zeile {i}: " + "x" * 90 for i in range(200)),
T + 140),
_auftrag("a7", "suchen", 100, "wartet", None, None, T + 150),
])
for lauf in (
_lauf("l1", "ct-104", "app", "Gitea", T + 320, ergebnis="eingespielt", text="Gitea: eingespielt, Prüfung grün.",
version_alt="1.27.2", version_neu="1.27.3", dringend=False, gemeldet=True),
_lauf("l2", "ct-105", "os", "Proxmox Backup Server", T + 400, ergebnis="zurueckgerollt", dringend=True,
text="Proxmox Backup Server: Update gescheitert (Die Weboberfläche antwortet nicht.) — automatisch die "
"Sicherung von vorher zurückgespielt (vzdump-lxc-105.tar.zst). Läuft wieder wie vorher."),
_lauf("l3", "ct-102", "app", "NetBird", T + 500, ergebnis="fehler", dringend=False,
text="NetBird: Update nicht begonnen — snapshot gescheitert: kaputt. Am Gerät wurde nichts geändert."),
_lauf("l4", "pve", "pakete", "Proxmox-Host", T + 600, ergebnis="eingespielt", pakete=5, sammellauf="s1",
sammellauf_start=T + 550, dringend=False, gemeldet=False,
text="Proxmox-Host: Pakete eingespielt. Ein Neustart ist ein eigener Knopf."),
{**_lauf("l5", "ct-103", "os", "PVE Scripts Local", T + 650), "status": "unterbrochen"},
):
updates._merken(lauf)
sammellauf._merken({"id": "s1", "status": "fertig", "start": T + 550, "ende": T + 700, "aktuell": None,
"text": "1 Update eingespielt, alle Prüfungen grün.", "dringend": False,
"meldung": "Homelab: 1 Update eingespielt, alle Prüfungen grün.",
"schritte": [{"ziel": "pve", "name": "Proxmox-Host", "baustein": "pakete", "was": "5 Pakete",
"status": "eingespielt", "lauf": "l4", "text": "Proxmox-Host: Pakete eingespielt."}]})
(daten / "mc2-waechter.json").write_text(json.dumps({
"hinweise": {
"gast-platte:100": {"id": "gast-platte:100", "stufe": "gelb", "titel": "Platte von AdGuard Home zu 85 % voll",
"text": "1,7 von 2,0 GB belegt (Container 100).", "seit": T + 900},
"ausfuehrer": {"id": "ausfuehrer", "stufe": "rot", "titel": "Der Ausführer auf dem Proxmox-Host schweigt",
"text": "Seit über 30 Minuten kein Bericht.", "seit": T + 950}},
"verlauf": [
{"ts": T + 800, "art": "neu", "id": "gast:ct-104", "text": "Gitea antwortet nicht", "stufe": "rot"},
{"ts": T + 860, "art": "erledigt", "id": "gast:ct-104", "text": "Gitea antwortet nicht", "stufe": "rot"},
{"ts": T + 900, "art": "neu", "id": "gast-platte:100", "text": "Platte von AdGuard Home zu 85 % voll"}]}),
encoding="utf-8")
(daten / "notify.log").write_text("\n".join([
f"{_zeit(T + 320)} OK telegram direkt: Gitea: eingespielt, Prüfung grün.",
(f"{_zeit(T + 400)} OK telegram direkt: Proxmox Backup Server: Update gescheitert (Die Weboberfläche "
"antwortet nicht.) — automatisch die Sicherung von vorher zurückgespielt (vzdump-lxc-105.tar.zst). Läuft "
"wieder wie vorher."),
f"{_zeit(T + 700)} OK telegram direkt: Homelab: 1 Update eingespielt, alle Prüfungen grün.",
(f"{_zeit(T + 800)} OK telegram direkt: Gitea antwortet nicht. Die Weboberfläche antwortet nicht. (Diese "
"Meldung kam auch an Lucy.)"),
f"{_zeit(T + 860)} QUEUED für Morgen-Digest: Erledigt: Gitea antwortet nicht. (Diese Meldung kam auch an Lucy.)",
(f"{_zeit(T + 870)} FALLBACK (telegram fehlgeschlagen: ; Zweitweg: Telegram lehnt ab, HTTP-Fehler – Token "
"oder Chat falsch?): Testmeldung aus den Einstellungen (Homelab, 24.09. 22:10 Uhr, Kennung abc123). Kommt sie "
"an, funktioniert der Meldeweg."),
f"{_zeit(T + 880)} OK telegram direkt: Guten Morgen, Commander. Heute Nacht gab es 2 Meldungen:",
"- [Homelab-Update]: Gitea: eingespielt, Prüfung grün.",
"- [Homelab-Problem]: Gitea antwortet nicht.",
f"{_zeit(T + 890)} OK telegram direkt: Der Homelab-Teil läuft jetzt in Container 107. Nichts zu tun.",
f"{_zeit(T + 895)} OK telegram direkt: Neuer Zugang: api_key=supergeheim123456",
]) + "\n", encoding="utf-8")
pflege._schreiben({"nacht": "2026-09-24", "gaeste": {
"104": {"name": "Gitea", "tag": "2026-09-24", "auftrag": None, "angestossen": T + 1000, "fehlschlaege": 0,
"fehler": None},
"100": {"name": "AdGuard Home", "tag": "2026-09-24", "auftrag": None, "angestossen": T + 1010,
"fehlschlaege": 2, "fehler": "E: Failed to fetch"}},
"ereignisse": [
{"ts": T + 1010, "vmid": 100, "name": "AdGuard Home", "art": "angestossen",
"text": "Die Paketlisten sind vom 01.09.2026."},
{"ts": T + 1020, "vmid": 100, "name": "AdGuard Home", "art": "gescheitert",
"text": "2. Fehlschlag in Folge: E: Failed to fetch"}]})
return daten
def _nach_titel(eintraege: list[dict]) -> dict[str, dict]:
return {e["titel"]: e for e in eintraege}
def test_alle_quellen_mit_sprechenden_titeln(geschichte):
eintraege = protokoll.eintraege()
assert [e["zeit"] for e in eintraege] == sorted((e["zeit"] for e in eintraege), reverse=True)
assert all(set(e) == {"zeit", "art", "titel", "text", "stufe", "ziel", "details"} for e in eintraege)
assert {e["art"] for e in eintraege} == {"auftrag", "lauf", "sammellauf", "hinweis", "meldung", "pflege"}
assert {e["stufe"] for e in eintraege} <= {"ok", "info", "warn", "fehler"}
e = _nach_titel(eintraege)
# Aufträge an den Ausführer: Aktion und Gerät im Titel, Status und Code im Text, Ausgabe als Einzelheiten.
suchen = e["Paketlisten erneuert · Gitea"]
assert (suchen["art"], suchen["stufe"], suchen["ziel"], suchen["text"]) == ("auftrag", "ok", "ct-104",
"nach 3 s, Code 0")
assert suchen["details"] == "Hit:1 http://deb.debian.org/debian trixie InRelease"
assert e["Snapshot angelegt · Gitea"]["text"] == "mc2-20260924-192352, nach 3 s, Code 0"
update = e["Update-Skript gescheitert · Gitea"]
assert update["stufe"] == "fehler"
assert update["text"] == "nach 1 min, Code 1: E: Sub-process /usr/bin/dpkg returned an error code (1)"
assert e["Pakete einspielen abgelehnt · Proxmox Backup Server"]["stufe"] == "warn"
host = e["Host-Pakete: keine Antwort · Proxmox-Host"]
assert (host["stufe"], host["ziel"]) == ("fehler", "pve")
assert e["Paketlisten werden erneuert · AdGuard Home"]["stufe"] == "info"
assert e["Sicherung angelegt · Proxmox Backup Server"]["text"].startswith("vzdump-lxc-105-2026_09_24-22_10_00")
lang = e["Pakete eingespielt · NPMplus"]["details"]
assert lang.startswith("…\nZeile ") and len(lang) <= protokoll.DETAILS_MAX and lang.endswith("Zeile 199: " + "x" * 90)
# Update-Läufe mit ihren Schritten
assert e["Update eingespielt · Gitea 1.27.3"]["stufe"] == "ok"
assert e["Update eingespielt · Gitea 1.27.3"]["details"] == "snapshot: ok\nupdate: ok"
assert e["Paket-Update zurückgerollt · Proxmox Backup Server"]["stufe"] == "warn"
assert e["Update nicht begonnen · NetBird"]["stufe"] == "warn"
host_lauf = e["5 Host-Pakete eingespielt · Proxmox-Host"]
assert "Teil von „Alle aktualisieren“." in host_lauf["details"] and "Sammelmeldung" in host_lauf["details"]
assert e["Paket-Update unterbrochen · PVE Scripts Local"]["stufe"] == "fehler"
# Sammellauf
sl = e["Alle aktualisieren fertig · 1 von 1 eingespielt"]
assert (sl["art"], sl["stufe"], sl["text"]) == ("sammellauf", "ok", "1 Update eingespielt, alle Prüfungen grün.")
assert sl["details"] == "Proxmox-Host (5 Pakete): eingespielt – Proxmox-Host: Pakete eingespielt."
# Hinweise des Wächters: Verlauf und, was dort fehlt, aus den aktuellen Hinweisen
assert (e["Hinweis: Gitea antwortet nicht"]["stufe"], e["Hinweis: Gitea antwortet nicht"]["ziel"]) == ("fehler",
"ct-104")
assert e["Erledigt: Gitea antwortet nicht"]["stufe"] == "ok"
platte = e["Hinweis: Platte von AdGuard Home zu 85 % voll"]
assert (platte["stufe"], platte["ziel"], platte["text"]) == ("warn", "ct-100",
"1,7 von 2,0 GB belegt (Container 100).")
schweigt = e["Hinweis: Der Ausführer auf dem Proxmox-Host schweigt"]
assert (schweigt["stufe"], schweigt["ziel"], schweigt["zeit"]) == ("fehler", "pve", T + 950)
# Meldungen: Zeit, Betreff, ob raus
assert [x["text"] for x in eintraege if x["titel"] == "Meldung raus · [Homelab-Update]"] == [
"Homelab: 1 Update eingespielt, alle Prüfungen grün.", "Gitea: eingespielt, Prüfung grün."]
alarm = e["Meldung raus · [Alarm] Homelab-Update"]
assert alarm["zeit"] == T + 400 and alarm["details"] == "Direkt an die Telegram-Bot-API gesendet."
assert e["Meldung raus · [Homelab-Problem]"]["text"].startswith("Gitea antwortet nicht.")
assert e["Meldung zurückgelegt · [Homelab wieder ok]"]["stufe"] == "info"
test = e["Meldung ging nicht raus · [Test]"]
assert test["stufe"] == "fehler" and test["details"].startswith("Telegram ging nicht: telegram fehlgeschlagen: ; "
"Zweitweg: Telegram lehnt ab, HTTP-Fehler")
morgen = e["Meldung raus · [Morgenmeldung Homelab]"]
assert morgen["text"] == "Guten Morgen, Commander. Heute Nacht gab es 2 Meldungen:…"
assert morgen["details"].endswith("- [Homelab-Problem]: Gitea antwortet nicht.")
assert e["Meldung raus"]["text"] == "Der Homelab-Teil läuft jetzt in Container 107. Nichts zu tun."
meldungen = [x for x in eintraege if x["art"] == "meldung"]
assert len(meldungen) == 9 # die Folgezeilen der Morgenmeldung sind keine eigenen Meldungen
# Pflege: Ereignisse, und aus älteren Ständen der letzte Anstoß
assert e["Wöchentliche Suche gescheitert · AdGuard Home"]["stufe"] == "warn"
gitea = e["Wöchentliche Suche angestoßen · Gitea"]
assert (gitea["zeit"], gitea["text"], gitea["ziel"]) == (T + 1000, "Die Paketlisten waren älter als 7 Tage.",
"ct-104")
angestossen = [x for x in eintraege if x["titel"] == "Wöchentliche Suche angestoßen · AdGuard Home"]
assert len(angestossen) == 1 and angestossen[0]["text"] == "Die Paketlisten sind vom 01.09.2026."
def test_keine_geheimnisse_und_keine_steuerzeichen(geschichte):
alles = json.dumps(protokoll.eintraege(berichte=True), ensure_ascii=False)
for geheim in GEHEIM:
assert geheim not in alles, geheim
assert "\x1b" not in alles and "\\u001b" not in alles and "\r" not in alles and "\\r" not in alles
details = _nach_titel(protokoll.eintraege())["Update-Skript gescheitert · Gitea"]["details"]
assert "Downloading 100%" in details and "Downloading 10%" not in details
assert "Authorization: ***" in details and "https://***@example.org/x" in details
assert "TELEGRAM_BOT_TOKEN=***" in details and "/bot***/sendMessage" in details
assert "X-API-Key: ***" in details and "X-MC2-Ausfuehrer: ***" in details and "password=***" in details
assert "Neuer Zugang: api_key=***" in alles
def test_schwaerzen_einzeln(daten):
assert protokoll.schwaerzen(f"Schlüssel {ARCANE_KEY} benutzt") == "Schlüssel *** benutzt"
assert protokoll.schwaerzen('{"api_key": "abc123def456"}') == '{"api_key": "***"}'
assert protokoll.schwaerzen("Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N") == \
"Bearer ***"
harmlos = "snapshot=mc2-20260924-192352 sha256:7304d54948626355e11a855dbcde49f58468c71e4cbf43b5315d3a2f5fd01a58"
assert protokoll.schwaerzen(harmlos) == harmlos
assert protokoll.schwaerzen(None) is None
def test_berichte_nur_auf_wunsch_und_die_grenze(geschichte):
ohne = protokoll.eintraege()
assert not [x for x in ohne if x["titel"].startswith("Bericht")]
mit = _nach_titel(protokoll.eintraege(berichte=True))
bericht = mit["Bericht geholt · Proxmox-Host"]
assert bericht["details"] == "Proxmox VE 9.2.11, 7 Gäste, davon 7 laufend, 5 Paket-Updates auf dem Host."
# Die Berichte, die der Ausführer von sich aus schickt (alle 10 min), sind keine Aufträge: kanal.py vermerkt sie.
eingang = mit["Bericht empfangen · Proxmox-Host"]
assert (eingang["art"], eingang["stufe"], eingang["ziel"]) == ("auftrag", "info", "pve")
assert eingang["text"] == "Proxmox VE 9.2.11, 7 Gäste, davon 7 laufend, 5 Paket-Updates auf dem Host."
kanal.bericht_speichern(BERICHT, aus_auftrag=True) # von einem Lauf geholt: steht schon als Auftrag da
assert len(kanal.berichte()) == 1
drei = protokoll.eintraege(grenze=3)
assert drei == ohne[:3] and drei[0]["titel"] == "Wöchentliche Suche gescheitert · AdGuard Home"
assert len(protokoll.eintraege(grenze=0)) == 1 and len(protokoll.eintraege(grenze=10**6)) == len(ohne)
def test_ohne_daten_leer(daten):
(daten / "pve-bericht.json").unlink()
assert protokoll.eintraege() == []
def test_schnittstelle(geschichte):
app = FastAPI()
from routers import homelab as homelab_router
app.include_router(homelab_router.router)
c = TestClient(app)
antwort = c.get("/api/homelab/protokoll?grenze=2&berichte=1")
assert antwort.status_code == 200 and len(antwort.json()["eintraege"]) == 2
alle = c.get("/api/homelab/protokoll").json()["eintraege"]
assert len(alle) == len(protokoll.eintraege()) and not any(x["titel"].startswith("Bericht") for x in alle)
assert any(x["titel"].startswith("Bericht") for x in c.get("/api/homelab/protokoll?berichte=1").json()["eintraege"])
# --- Kanal: längere Aufbewahrung --------------------------------------------------------------------------------
def test_kanal_behaelt_500_und_kuerzt_nur_alte_ausgaben(daten):
alt = [_auftrag(f"x{i:03d}", "suchen", 104, "fertig", 0, f"{i:03d}" + "y" * 4997, T + i) for i in range(520)]
kanal._json_schreiben(kanal._pfad(), alt)
neu = kanal.anlegen("bericht")
gespeichert = json.loads(kanal._pfad().read_text(encoding="utf-8"))
fertig = [a for a in gespeichert if a["status"] == "fertig"]
assert len(fertig) == kanal.BEHALTEN == 500 and fertig[0]["id"] == "x020"
assert [a["id"] for a in gespeichert if a["status"] == "wartet"] == [neu]
assert all(len(a["text"]) == kanal.TEXT_KURZ and a["text"].startswith("…") for a in fertig[:-kanal.VOLLTEXT])
assert all(len(a["text"]) == 5000 for a in fertig[-kanal.VOLLTEXT:])
def test_kanal_schreibt_beim_abholen_nur_bei_aenderung(daten, monkeypatch):
schreiben = Counter()
echt = kanal._json_schreiben
monkeypatch.setattr(kanal, "_json_schreiben", lambda pfad, d: (schreiben.update([pfad.name]), echt(pfad, d)))
assert kanal.naechster() is None and schreiben["ausfuehrer-auftraege.json"] == 0
aid = kanal.anlegen("suchen", {"vmid": 104})
assert kanal.naechster()["id"] == aid and schreiben["ausfuehrer-auftraege.json"] == 2
assert kanal.naechster() is None and schreiben["ausfuehrer-auftraege.json"] == 2
# --- Pflege: Ereignisse für das Protokoll ------------------------------------------------------------------------
NACHT = datetime(2026, 9, 25, 3, 0, tzinfo=LOCAL_TZ).timestamp()
NAECHSTE_NACHT = datetime(2026, 9, 26, 3, 0, tzinfo=LOCAL_TZ).timestamp()
def test_pflege_merkt_sich_anstoss_fehlschlag_und_erholung(daten, monkeypatch):
stand = {"zuletzt": NACHT}
monkeypatch.setattr(kanal, "zuletzt", lambda: stand["zuletzt"])
monkeypatch.setattr(waechter, "TROCKEN", False)
def antworten(scheitert: set[int]) -> None:
while (a := kanal.naechster()):
if a["parameter"]["vmid"] in scheitert:
kanal.ergebnis(a["id"], 100, "E: Failed to fetch http://security.debian.org")
else:
kanal.ergebnis(a["id"], 0, "Hit:1 http://deb.debian.org/debian bookworm InRelease")
pflege.pruefe_paketlisten(jetzt=NACHT)
antworten({100})
pflege.pruefe_paketlisten(jetzt=NACHT + 60)
stand["zuletzt"] = NAECHSTE_NACHT
pflege.pruefe_paketlisten(jetzt=NAECHSTE_NACHT)
antworten(set())
pflege.pruefe_paketlisten(jetzt=NAECHSTE_NACHT + 60)
adguard = [(e["art"], e["text"]) for e in pflege.zustand()["ereignisse"] if e["vmid"] == 100]
assert adguard == [("angestossen", "Die Paketlisten sind vom 22.10.2025."),
("gescheitert", "1. Fehlschlag in Folge: E: Failed to fetch http://security.debian.org"),
("angestossen", "Die Paketlisten sind vom 22.10.2025."),
("wieder_ok", "Die Paketlisten ließen sich wieder erneuern.")]
# Anstöße tragen die (hier gespielte) Nacht, Ergebnisse die echte Zeit des Auftrags: nur die Menge vergleichen.
titel = Counter(e["titel"] for e in protokoll.eintraege() if e["art"] == "pflege" and e["ziel"] == "ct-100")
assert titel == Counter({"Wöchentliche Suche angestoßen · AdGuard Home": 2,
"Wöchentliche Suche gescheitert · AdGuard Home": 1,
"Wöchentliche Suche klappt wieder · AdGuard Home": 1})
+388
View File
@@ -0,0 +1,388 @@
"""„Alle aktualisieren“ (services/homelab/sammellauf.py, 24.09.2026): Reihenfolge, Stopp bei Rot, Sperre, Neustart
mitten im Lauf und zurückgehaltene Einzelmeldungen. Mit einem nachgespielten Ausführer und dem echten Bericht vom
24.09. (tests/fixtures/pve-bericht.json); NPMplus und AdGuard haben hier zusätzlich Paket-Updates."""
import json
import threading
import time
from pathlib import Path
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from kern import einstellungen as einstellungen_mod
from services.homelab import inventar, kanal, karenz, sammellauf, updates
BERICHT = json.loads((Path(__file__).parent / "fixtures" / "pve-bericht.json").read_text(encoding="utf-8"))
SPERRE = "Es läuft gerade ‚Alle aktualisieren‘."
NEU = {102: "0.79.0", 104: "1.27.3"} # NetBird und Gitea nach ihrem Update
ALLE = [("ct-102", "app"), ("ct-104", "app"), ("ct-105", "os"), ("ct-101", "os"), ("ct-100", "os"), ("pve", "pakete")]
def _bericht(versionen: dict[int, str] | None = None) -> dict:
"""Der echte Bericht mit frischen Paketlisten: PBS 58, NPMplus 3 und AdGuard 2 Pakete."""
neu = json.loads(json.dumps(BERICHT))
jetzt = int(time.time())
for g in neu["gaeste"]:
if g["vmid"] == 101:
g["os_updates"] = {"anzahl": 3, "listen_stand": jetzt}
elif g["vmid"] == 100:
g["os_updates"] = {"anzahl": 2, "listen_stand": jetzt}
elif g["vmid"] == 105:
g["os_updates"]["listen_stand"] = jetzt
if versionen and g["vmid"] in versionen:
g["app"]["version"] = versionen[g["vmid"]]
return neu
class Ausfuehrer:
"""Nachgespielt: beantwortet Aufträge der Reihe nach. Nach „update“ meldet der nächste Bericht die neue
App-Version — außer für VMIDs in `unveraendert` (dann ist die Prüfung rot). `weiter` hält ihn an;
`fehlschlag` (Aktion, VMID) → (Code, Text)."""
def __init__(self) -> None:
self.protokoll: list[tuple[str, int | None]] = []
self.weiter, self.stopp = threading.Event(), threading.Event()
self.weiter.set()
self.unveraendert: set[int] = set()
self.aktualisiert: set[int] = set()
self.fehlschlag: dict[tuple[str, int | None], tuple[int, str]] = {}
threading.Thread(target=self._laufen, daemon=True).start()
def _laufen(self) -> None:
while not self.stopp.is_set():
if not self.weiter.is_set():
time.sleep(0.01)
continue
a = kanal.naechster()
if not a:
time.sleep(0.01)
continue
vmid = a["parameter"].get("vmid")
self.protokoll.append((a["aktion"], vmid))
code, text = self.fehlschlag.get((a["aktion"], vmid), (0, ""))
if code == 0 and a["aktion"] == "snapshot":
text = "snapshot=mc2-20260924-220000"
elif code == 0 and a["aktion"] == "update":
self.aktualisiert.add(vmid)
elif code == 0 and a["aktion"] == "bericht":
text = json.dumps(_bericht({v: NEU[v] for v in self.aktualisiert - self.unveraendert if v in NEU}))
kanal.ergebnis(a["id"], code, text)
@pytest.fixture
def homelab(tmp_path, monkeypatch):
monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path))
for name in ("MC_AUSFUEHRER_TOKEN", "MC_ARCANE_KEY", "MC_ARCANE_ECHT"):
monkeypatch.delenv(name, raising=False)
einstellungen_mod.einstellungen.cache_clear()
monkeypatch.setattr(kanal, "_kontakt", {"zuletzt": None})
neueste = {"netbirdio/netbird": "0.79.0", "go-gitea/gitea": "1.27.3", "AdguardTeam/AdGuardHome": "0.107.79",
"community-scripts/ProxmoxVE-Local": "1.2.1"}
roh = {"community-scripts/ProxmoxVE-Local": "untagged-80028680f0b29a379726"}
monkeypatch.setattr(inventar, "neueste_version", lambda repo: {
"tag": neueste.get(repo, "2026-07-24-r1"), "tag_roh": roh.get(repo, neueste.get(repo, "2026-07-24-r1")),
"version": neueste.get(repo, "2026-07-24-r1"), "datum": "2026-07-24"})
monkeypatch.setattr(inventar, "erreichbar", lambda url: True)
monkeypatch.setattr(karenz, "skript_geaendert", lambda kennung: 0.0)
monkeypatch.setattr(inventar.arcane, "app_version",
lambda url: {"installiert": "2.12.0", "neu": "2.13.1", "update": True})
monkeypatch.setattr(updates, "WARTEN_NACH_UPDATE_S", 0)
echt_warten = kanal.warten
monkeypatch.setattr(kanal, "warten", lambda aid, zeitlimit_s, takt_s=2.0: echt_warten(aid, min(zeitlimit_s, 10), 0.02))
monkeypatch.setattr(sammellauf, "TAKT_S", 0.02)
meldungen: list[tuple[str, bool]] = []
def melden(text: str, dringend: bool = False) -> None:
meldungen.append((text, dringend))
monkeypatch.setattr(updates, "_melden", melden)
monkeypatch.setattr(sammellauf, "_melden", melden)
verlauf: list[tuple] = []
monkeypatch.setattr(updates.update_verlauf, "eintragen", lambda *a: verlauf.append(a))
kanal.bericht_speichern(_bericht())
ausfuehrer = Ausfuehrer()
yield SimpleNamespace(ausfuehrer=ausfuehrer, meldungen=meldungen, verlauf=verlauf, daten=tmp_path)
ausfuehrer.weiter.set()
ende = time.time() + 20 # keinen Faden in den nächsten Test mitnehmen
while (sammellauf.laeuft() or updates.laufende_ziele()) and time.time() < ende:
time.sleep(0.05)
ausfuehrer.stopp.set()
einstellungen_mod.einstellungen.cache_clear()
def _auf_ende(zeitlimit: float = 30) -> dict:
ende = time.time() + zeitlimit
while time.time() < ende:
sl = sammellauf.aktueller()
if sl and sl["status"] != "laeuft":
return sl
time.sleep(0.05)
raise AssertionError("„Alle aktualisieren“ wurde nicht fertig")
def _auf_lauf(lauf_id: str) -> dict:
ende = time.time() + 20
while time.time() < ende:
lauf = updates.lauf_lesen(lauf_id)
if lauf and lauf["status"] != "laeuft":
return lauf
time.sleep(0.05)
raise AssertionError(f"Lauf {lauf_id} wurde nicht fertig")
# --- Plan -----------------------------------------------------------------------------------------------
def test_plan_reihenfolge_nadeloehre_zuletzt_und_der_host_ganz_am_ende(homelab):
plan = sammellauf.plan()
assert [(s["ziel"], s["baustein"]) for s in plan["schritte"]] == ALLE
assert plan["schritte"][0] == {"ziel": "ct-102", "name": "NetBird", "baustein": "app", "was": "0.77.1 → 0.79.0",
"rueckweg_art": "snapshot"}
assert [(s["name"], s["was"], s["rueckweg_art"]) for s in plan["schritte"][2:]] == [
("Proxmox Backup Server", "58 Pakete", "keiner"), ("NPMplus", "3 Pakete", "snapshot"),
("AdGuard Home", "2 Pakete", "snapshot"), ("Proxmox-Host", "5 Pakete", "keiner")]
assert plan["hinweis"] == ("Ohne Rückweg: Proxmox Backup Server – scheitert dort das Update, geht es nicht von "
"selbst zurück. Der Proxmox-Host kommt zuletzt und hat keinen Rückweg; neu gestartet "
"wird er nicht.")
def test_plan_nur_mit_knopf_und_nie_der_neustart(homelab, monkeypatch):
bericht = _bericht()
bericht["host"]["neustart_noetig"] = True
kanal.bericht_speichern(bericht)
# Gitea wartet nach einer frischen Skriptänderung: „neu“, aber ohne Knopf — also nicht dabei.
monkeypatch.setattr(karenz, "skript_geaendert", lambda kennung: time.time() - 3600 if kennung == "gitea" else 0.0)
ziele = [(s["ziel"], s["baustein"]) for s in sammellauf.plan()["schritte"]]
assert ziele == [z for z in ALLE if z != ("ct-104", "app")] and ("pve", "neustart") not in ziele
def test_plan_docker_nur_wenn_echt(homelab, monkeypatch):
monkeypatch.setenv("MC_ARCANE_KEY", "schluessel-fuer-den-test")
monkeypatch.setattr(inventar.arcane, "images_mit_update", lambda url: [
{"name": "ghcr.io/tobi/nerdquiz:latest", "aktuell": None, "neu": None, "umgebung": 0, "benutzt": ["nerdquiz"]}])
plan = sammellauf.plan()
assert "vm-106" not in [s["ziel"] for s in plan["schritte"]]
assert plan["hinweis"].endswith("Die Docker-Images (Arcane) sind nicht dabei, solange Docker-Updates nur als "
"Probelauf laufen.")
monkeypatch.setenv("MC_ARCANE_ECHT", "1")
plan = sammellauf.plan()
assert [s["ziel"] for s in plan["schritte"]] == ["ct-102", "ct-104", "ct-105", "vm-106", "ct-101", "ct-100", "pve"]
docker = plan["schritte"][3]
assert (docker["baustein"], docker["was"], docker["rueckweg_art"]) == ("docker", "1 Images", "keiner")
assert "Ohne Rückweg: Proxmox Backup Server und Arcane (Docker)" in plan["hinweis"]
# --- Ablauf ---------------------------------------------------------------------------------------------
def test_gruen_nacheinander_mit_einer_sammelmeldung(homelab):
antwort = sammellauf.starten()
assert antwort["ok"] and antwort["sammellauf"]["status"] == "laeuft"
assert set(antwort["sammellauf"]) == {"id", "status", "start", "ende", "aktuell", "schritte", "text"}
assert set(antwort["sammellauf"]["schritte"][0]) == {"ziel", "name", "baustein", "was", "status", "lauf", "text"}
sl = _auf_ende()
assert sl["status"] == "fertig" and sl["aktuell"] is None and sl["ende"] >= sl["start"]
assert [s["status"] for s in sl["schritte"]] == ["eingespielt"] * 6
assert sl["text"] == "6 Updates eingespielt, alle Prüfungen grün."
assert homelab.ausfuehrer.protokoll == [
("snapshot", 102), ("update", 102), ("bericht", None),
("snapshot", 104), ("update", 104), ("bericht", None),
("os_update", 105), ("bericht", None), # PBS: weder Snapshot noch Sicherung
("snapshot", 101), ("os_update", 101), ("bericht", None),
("snapshot", 100), ("os_update", 100), ("bericht", None),
("host_update", None), ("bericht", None)]
# Die Erfolgsmeldungen der Schritte bleiben aus; es kommt genau die eine Sammelmeldung.
assert homelab.meldungen == [("Homelab: 6 Updates eingespielt, alle Prüfungen grün.", False)]
laeufe = {x["id"]: x for x in updates.laeufe(20)}
assert all(laeufe[s["lauf"]]["gemeldet"] is False and laeufe[s["lauf"]]["sammellauf"] == sl["id"]
for s in sl["schritte"])
assert laeufe[sl["schritte"][1]["lauf"]]["version_neu"] == "1.27.3"
# Im Update-Verlauf sind die Schritte EIN Lauf mit eigenem Anlass.
assert len(homelab.verlauf) == 6 and len({v[0] for v in homelab.verlauf}) == 1
assert {v[1] for v in homelab.verlauf} == {"Alle aktualisieren"}
assert [v[2:4] for v in homelab.verlauf][:2] == [("ct-102", "eingespielt"), ("ct-104", "eingespielt")]
def test_rot_stoppt_und_meldet_dringend(homelab):
homelab.ausfuehrer.unveraendert.add(104) # Gitea: die Version bleibt → Prüfung rot → zurück
assert sammellauf.starten()["ok"]
sl = _auf_ende()
assert sl["status"] == "abgebrochen" and sl["text"] == "Abgebrochen bei Gitea: Update zurückgerollt."
assert [s["status"] for s in sl["schritte"]] == ["eingespielt", "zurueckgerollt"] + ["uebersprungen"] * 4
assert sl["schritte"][2]["text"] == "Nicht mehr gelaufen: „Alle aktualisieren“ wurde abgebrochen."
assert homelab.ausfuehrer.protokoll[-1] == ("zurueck", 104)
assert not {v for _, v in homelab.ausfuehrer.protokoll} & {105, 101, 100}
assert ("host_update", None) not in homelab.ausfuehrer.protokoll
(gitea, dringend_1), (sammel, dringend_2) = homelab.meldungen # NetBirds Erfolg bleibt still
assert dringend_1 and gitea.startswith("Gitea: Update gescheitert") and "zurück auf den Snapshot" in gitea
assert dringend_2 and sammel == ("Homelab: „Alle aktualisieren“ abgebrochen bei Gitea (Update zurückgerollt). "
"Vorher eingespielt: NetBird. Nicht mehr gelaufen: Proxmox Backup Server, "
"NPMplus, AdGuard Home und Proxmox-Host.")
assert not sammellauf.laeuft() and not updates.laufende_ziele()
def test_nicht_begonnen_stoppt_auch_und_die_fehlermeldung_bleibt(homelab):
homelab.ausfuehrer.fehlschlag[("snapshot", 102)] = (1, "snapshot feature is not available")
assert sammellauf.starten()["ok"]
sl = _auf_ende()
assert [s["status"] for s in sl["schritte"]] == ["fehler"] + ["uebersprungen"] * 5
assert homelab.ausfuehrer.protokoll == [("snapshot", 102)]
assert homelab.meldungen[0] == (("NetBird: Update nicht begonnen — snapshot gescheitert: snapshot feature is "
"not available. Am Gerät wurde nichts geändert."), False)
assert homelab.meldungen[1][1] is True and "abgebrochen bei NetBird (Update gescheitert)" in homelab.meldungen[1][0]
def test_abgelehnter_schritt_wird_uebersprungen_und_es_geht_weiter(homelab, monkeypatch):
gesperrt = {"an": False}
monkeypatch.setattr(karenz, "skript_geaendert",
lambda kennung: time.time() - 3600 if gesperrt["an"] and kennung == "gitea" else 0.0)
homelab.ausfuehrer.weiter.clear()
assert sammellauf.starten()["ok"]
gesperrt["an"] = True # das Update-Skript von Gitea ändert sich mitten im Lauf
homelab.ausfuehrer.weiter.set()
sl = _auf_ende()
assert sl["status"] == "fertig"
assert [s["status"] for s in sl["schritte"]] == ["eingespielt", "uebersprungen"] + ["eingespielt"] * 4
assert sl["schritte"][1]["text"].startswith("Das Update-Skript wurde am ")
assert sl["text"].startswith("5 Updates eingespielt, alle Prüfungen grün. Nicht gelaufen: Gitea (Das Update-")
assert homelab.meldungen == [("Homelab: " + sl["text"], False)]
# --- Sperre ---------------------------------------------------------------------------------------------
def test_nur_ein_sammellauf_und_kein_einzelner_knopf_waehrenddessen(homelab):
homelab.ausfuehrer.weiter.clear() # der erste Schritt hängt am Snapshot
assert sammellauf.starten()["ok"]
assert sammellauf.starten() == {"ok": False, "detail": SPERRE}
assert updates.starten("ct-104", "app") == {"ok": False, "detail": SPERRE}
assert updates.starten("pve", "neustart") == {"ok": False, "detail": SPERRE}
assert updates.starten("vm-106", "docker") == {"ok": False, "detail": SPERRE}
homelab.ausfuehrer.weiter.set()
assert _auf_ende()["status"] == "fertig"
assert not sammellauf.laeuft()
def test_nicht_neben_einem_einzelnen_update(homelab):
homelab.ausfuehrer.weiter.clear()
lauf = updates.starten("ct-104", "app")
assert lauf["ok"]
assert sammellauf.starten() == {"ok": False, "detail": "Gerade läuft ein einzelnes Update (Gitea). „Alle "
"aktualisieren“ geht, sobald es fertig ist."}
homelab.ausfuehrer.weiter.set()
assert _auf_lauf(lauf["lauf"])["ergebnis"] == "eingespielt"
assert homelab.meldungen == [("Gitea: eingespielt, Prüfung grün.", False)] # allein: wie bisher gemeldet
assert sammellauf.aktueller() is None
def test_ohne_ausfuehrer_im_nur_lesen_modus_und_ohne_updates(homelab, monkeypatch):
monkeypatch.setattr(kanal, "zuletzt", lambda: time.time() - 3600)
assert sammellauf.starten() == {"ok": False, "detail": "Der Ausführer auf dem Proxmox-Host ist nicht verbunden. "
"Ohne ihn geht kein Update."}
monkeypatch.setattr(kanal, "zuletzt", lambda: time.time())
nur_lesen = _bericht()
nur_lesen["host"]["nur_lesen"] = True
kanal.bericht_speichern(nur_lesen)
assert "Nur-Lesen-Modus" in sammellauf.starten()["detail"]
(homelab.daten / "pve-bericht.json").unlink()
assert sammellauf.starten() == {"ok": False, "detail": "Gerade gibt es kein Update mit Knopf, also nichts zu tun."}
assert sammellauf.aktueller() is None and homelab.ausfuehrer.protokoll == []
# --- Neustart und Absturz -------------------------------------------------------------------------------
def _offener_sammellauf(gitea_lauf: dict) -> None:
"""So stand es auf der Platte, als der Homelab-Teil mitten im Gitea-Schritt neu startete."""
jetzt = time.time()
updates._merken({"id": "l-netbird", "ziel": "ct-102", "baustein": "app", "name": "NetBird", "status": "fertig",
"ergebnis": "eingespielt", "text": "NetBird: eingespielt, Prüfung grün.", "start": jetzt - 300,
"ende": jetzt - 200, "schritte": [], "sammellauf": "s1", "sammellauf_start": jetzt - 400})
updates._merken({"id": "l-gitea", "ziel": "ct-104", "baustein": "app", "name": "Gitea", "start": jetzt - 100,
"ende": None, "ergebnis": None, "text": None, "schritte": ["update …"], "sammellauf": "s1",
"sammellauf_start": jetzt - 400, **gitea_lauf})
schritt = {"baustein": "app", "lauf": None, "text": None}
sammellauf._merken({"id": "s1", "status": "laeuft", "start": jetzt - 400, "ende": None, "aktuell": 1,
"text": None, "meldung": None, "dringend": False, "schritte": [
{**schritt, "ziel": "ct-102", "name": "NetBird", "was": "0.77.1 → 0.79.0",
"status": "eingespielt", "lauf": "l-netbird", "text": "NetBird: eingespielt, Prüfung grün."},
{**schritt, "ziel": "ct-104", "name": "Gitea", "was": "1.27.2 → 1.27.3", "status": "laeuft",
"lauf": "l-gitea"},
{**schritt, "ziel": "pve", "name": "Proxmox-Host", "baustein": "pakete", "was": "5 Pakete",
"status": "wartet"}]})
def _auf_meldungen(meldungen: list, anzahl: int) -> list:
ende = time.time() + 10
while len(meldungen) < anzahl and time.time() < ende:
time.sleep(0.02)
return meldungen
def test_neustart_mitten_im_lauf_bricht_ab(homelab):
_offener_sammellauf({"status": "laeuft"})
assert updates.starten("ct-104", "app") == {"ok": False, "detail": SPERRE}
updates.unterbrochene_abschliessen() # so ruft es der Start der App (Lebenszyklus in app.py)
sl = sammellauf.aktueller()
assert (sl["status"], sl["text"], sl["aktuell"]) == ("abgebrochen", "unterbrochen (Neustart)", None)
assert sl["ende"] is not None
assert [(s["status"], s["text"]) for s in sl["schritte"]] == [
("eingespielt", "NetBird: eingespielt, Prüfung grün."), ("fehler", "unterbrochen (Neustart)"),
("uebersprungen", "Nicht mehr gelaufen: „Alle aktualisieren“ wurde abgebrochen.")]
assert updates.lauf_lesen("l-gitea")["status"] == "unterbrochen"
assert _auf_meldungen(homelab.meldungen, 1) == [(
("Homelab: „Alle aktualisieren“ wurde unterbrochen: Der Homelab-Teil ist mitten im Lauf neu gestartet (bei "
"Gitea). Vorher eingespielt: NetBird. Nicht mehr gelaufen: Proxmox-Host. Bitte den Stand in der Übersicht "
"prüfen."), True)]
assert not sammellauf.laeuft() # der einzelne Knopf geht wieder
def test_neustart_nach_fertigem_schritt_zaehlt_dessen_ergebnis(homelab):
_offener_sammellauf({"status": "fertig", "ergebnis": "eingespielt", "text": "Gitea: eingespielt, Prüfung grün.",
"ende": time.time()})
updates.unterbrochene_abschliessen()
sl = sammellauf.aktueller()
assert [s["status"] for s in sl["schritte"]] == ["eingespielt", "eingespielt", "uebersprungen"]
assert sl["status"] == "abgebrochen" and sl["text"] == "unterbrochen (Neustart)"
assert "Vorher eingespielt: NetBird und Gitea." in _auf_meldungen(homelab.meldungen, 1)[0][0]
def test_absturz_im_ablauf_gibt_die_sperre_frei(homelab, monkeypatch):
def kaputt(sl, schritt):
raise KeyError("kaputt")
monkeypatch.setattr(sammellauf, "_schritt", kaputt)
assert sammellauf.starten()["ok"]
sl = _auf_ende()
assert sl["status"] == "abgebrochen" and sl["text"] == "Interner Fehler: KeyError."
assert [s["status"] for s in sl["schritte"]] == ["uebersprungen"] * 6
assert homelab.meldungen[-1][1] is True and not sammellauf.laeuft()
# --- Schnittstellen -------------------------------------------------------------------------------------
def _client() -> TestClient:
from routers import homelab as homelab_router
app = FastAPI()
app.include_router(homelab_router.router)
return TestClient(app)
def test_schnittstellen(homelab):
c = _client()
assert c.get("/api/homelab/sammellauf").json() == {"sammellauf": None}
plan = c.get("/api/homelab/alle/plan").json()
assert [(s["ziel"], s["baustein"]) for s in plan["schritte"]] == ALLE and plan["hinweis"]
homelab.ausfuehrer.weiter.clear()
antwort = c.post("/api/homelab/alle")
assert antwort.status_code == 200 and antwort.json()["ok"] is True
zweite = c.post("/api/homelab/alle")
assert zweite.status_code == 200 and zweite.json() == {"ok": False, "detail": SPERRE}
einzeln = c.post("/api/homelab/ziele/ct-104/update")
assert einzeln.status_code == 409 and einzeln.json()["detail"] == SPERRE
ende = time.time() + 10 # der Faden setzt gleich den ersten Schritt auf „läuft“
while (laufend := c.get("/api/homelab/sammellauf").json()["sammellauf"])["aktuell"] is None and time.time() < ende:
time.sleep(0.02)
assert laufend["status"] == "laeuft" and laufend["aktuell"] == 0 and laufend["schritte"][0]["status"] == "laeuft"
homelab.ausfuehrer.weiter.set()
_auf_ende()
fertig = c.get("/api/homelab/sammellauf").json()["sammellauf"]
assert fertig["status"] == "fertig" and fertig["id"] == antwort.json()["sammellauf"]["id"]
assert "meldung" not in fertig and "dringend" not in fertig