Merge branch 'worktree-agent-a8a38897ceeaf6694' into wartung/oberflaeche-v4
This commit is contained in:
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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 = {}
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""Einstellungen des Homelab-Teils (24.09.2026): was sich unter „Einstellungen“ für das Homelab einstellen lässt.
|
||||||
|
|
||||||
|
• Arcane-API-Schlüssel eintragen oder löschen. Vor dem Speichern prüft Arcane ihn mit denselben Aufrufen, die die
|
||||||
|
Übersicht braucht; lehnt es ab oder antwortet nicht, wird nichts gespeichert. Er liegt in
|
||||||
|
<Datenordner>/arcane.key (0600); MC_ARCANE_KEY in /etc/mc2/homelab.env geht vor (arcane.py).
|
||||||
|
• Docker echt echte Docker-Updates über Arcane statt Probelauf (<Datenordner>/homelab-einstellungen.json);
|
||||||
|
MC_ARCANE_ECHT in der Umgebung gewinnt.
|
||||||
|
stand() zeigt dazu die Wartezeit nach Skriptänderungen (karenz.py) und den Ausführer (verbunden, Nur-Lesen,
|
||||||
|
Sicherungsspeicher). Der Schlüssel verlässt diesen Teil nie: Keine Antwort, kein Protokolleintrag und keine
|
||||||
|
Fehlermeldung enthält ihn, auch kein Stück davon.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
|
||||||
|
from services.homelab import apps, arcane, inventar, kanal, karenz
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
UMGEBUNG_SCHLUESSEL = ("Der Arcane-Schlüssel kommt aus der Umgebung (MC_ARCANE_KEY in /etc/mc2/homelab.env) und "
|
||||||
|
"gilt vor einem hier eingetragenen. Erst dort entfernen, dann hier eintragen.")
|
||||||
|
_FORM = re.compile(r"[\x21-\x7e]{8,512}") # druckbares ASCII ohne Leerzeichen, wie ihn Arcane ausgibt
|
||||||
|
|
||||||
|
|
||||||
|
def arcane_url() -> str | None:
|
||||||
|
"""Adresse der Arcane-Oberfläche aus dem Bericht des Ausführers (die VM, deren Name „arcane“ ist)."""
|
||||||
|
eingang = kanal.bericht()
|
||||||
|
for gast in ((eingang or {}).get("bericht") or {}).get("gaeste") or []:
|
||||||
|
app = apps.app_fuer((gast.get("app") or {}).get("kennung"), gast.get("name"))
|
||||||
|
if app and app.kennung == "arcane" and (url := apps.adresse(app, gast.get("ip"))):
|
||||||
|
return url.rstrip("/")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def stand() -> dict:
|
||||||
|
"""GET /api/homelab/einstellungen."""
|
||||||
|
zuletzt = kanal.zuletzt()
|
||||||
|
host = ((kanal.bericht() or {}).get("bericht") or {}).get("host") or {}
|
||||||
|
sicherung = host.get("sicherung") if isinstance(host.get("sicherung"), dict) else {}
|
||||||
|
return {
|
||||||
|
"arcane": {"url": arcane_url() or "", "schluessel": arcane.schluessel_quelle(), "echt": arcane.echt(),
|
||||||
|
"echt_quelle": arcane.echt_quelle()},
|
||||||
|
"karenz_h": round(karenz.karenz_s() / 3600),
|
||||||
|
"ausfuehrer": {"verbunden": bool(zuletzt and time.time() - zuletzt < inventar.BERICHT_ALT_S),
|
||||||
|
"zuletzt": zuletzt, "nur_lesen": bool(host.get("nur_lesen")),
|
||||||
|
"sicherung_speicher": sicherung.get("speicher") or None},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def schluessel_aus_body(roh: bytes) -> str | None:
|
||||||
|
"""Der Schlüssel aus dem Body {"schluessel": "…"} — ohne Pydantic, dessen Fehlermeldung (422) die Eingabe
|
||||||
|
wiederholen würde. None, wenn der Body nicht passt."""
|
||||||
|
try:
|
||||||
|
daten = json.loads(roh.decode("utf-8")) if roh else None
|
||||||
|
except (UnicodeDecodeError, ValueError):
|
||||||
|
return None
|
||||||
|
wert = daten.get("schluessel") if isinstance(daten, dict) else None
|
||||||
|
return wert if isinstance(wert, str) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _abgelehnt(ergebnis: dict, url: str) -> str:
|
||||||
|
status, art = ergebnis.get("status"), ergebnis.get("art")
|
||||||
|
if art == "abgelehnt":
|
||||||
|
text = f"Arcane lehnt den Schlüssel ab (HTTP {status})."
|
||||||
|
if status == 403:
|
||||||
|
text += " Ihm fehlt das Recht, Umgebungen und Images zu lesen."
|
||||||
|
return text
|
||||||
|
if art == "http":
|
||||||
|
return f"Arcane antwortet mit HTTP {status}. Der Schlüssel ist nicht gespeichert."
|
||||||
|
if art == "netz":
|
||||||
|
return f"Arcane ist nicht erreichbar ({url}). Der Schlüssel ist nicht gespeichert."
|
||||||
|
return "Arcane antwortet nicht wie erwartet. Der Schlüssel ist nicht gespeichert."
|
||||||
|
|
||||||
|
|
||||||
|
def arcane_schluessel_setzen(wert: str | None) -> dict:
|
||||||
|
"""POST /api/homelab/einstellungen/arcane-schluessel: erst gegen Arcane prüfen, dann speichern."""
|
||||||
|
if arcane.schluessel_quelle() == "umgebung":
|
||||||
|
return {"ok": False, "detail": UMGEBUNG_SCHLUESSEL}
|
||||||
|
wert = (wert or "").strip()
|
||||||
|
if not _FORM.fullmatch(wert):
|
||||||
|
return {"ok": False, "detail": "Das sieht nicht wie ein Arcane-API-Schlüssel aus (leer, zu kurz oder mit "
|
||||||
|
"Leerzeichen). Nichts gespeichert."}
|
||||||
|
url = arcane_url()
|
||||||
|
if not url:
|
||||||
|
return {"ok": False, "detail": "Arcane steht nicht im Bericht des Ausführers, die Adresse ist unbekannt. "
|
||||||
|
"Nichts gespeichert."}
|
||||||
|
ergebnis = arcane.schluessel_pruefen(url, wert)
|
||||||
|
if not ergebnis["ok"]:
|
||||||
|
log.info("homelab: Arcane-Schlüssel nicht übernommen (%s, HTTP %s)", ergebnis.get("art"), ergebnis.get("status"))
|
||||||
|
return {"ok": False, "detail": _abgelehnt(ergebnis, url)}
|
||||||
|
try:
|
||||||
|
arcane.schluessel_speichern(wert)
|
||||||
|
except OSError as exc:
|
||||||
|
return {"ok": False, "detail": f"Arcane nimmt den Schlüssel an, aber er ließ sich nicht speichern "
|
||||||
|
f"({exc.__class__.__name__})."}
|
||||||
|
images, neu, anzahl = ergebnis["images"], ergebnis["mit_update"], ergebnis["umgebungen"]
|
||||||
|
log.info("homelab: Arcane-Schlüssel gespeichert (%d Images geprüft)", images)
|
||||||
|
return {"ok": True, "text": (f"Arcane nimmt den Schlüssel an: {images} Images in {anzahl} "
|
||||||
|
f"{'Umgebung' if anzahl == 1 else 'Umgebungen'} geprüft, {neu} davon mit neuerem "
|
||||||
|
"Stand. Die Übersicht zeigt die Docker-Images ab sofort.")}
|
||||||
|
|
||||||
|
|
||||||
|
def arcane_schluessel_loeschen() -> dict:
|
||||||
|
"""DELETE /api/homelab/einstellungen/arcane-schluessel: die Datei weg."""
|
||||||
|
try:
|
||||||
|
da = arcane.schluessel_loeschen()
|
||||||
|
except OSError as exc:
|
||||||
|
return {"ok": False, "detail": f"Der Schlüssel ließ sich nicht löschen ({exc.__class__.__name__})."}
|
||||||
|
text = "Der Arcane-Schlüssel ist gelöscht." if da else "Es war kein Arcane-Schlüssel hinterlegt."
|
||||||
|
if arcane.schluessel_quelle() == "umgebung":
|
||||||
|
text += " Die Umgebung setzt weiter einen (MC_ARCANE_KEY); der gilt."
|
||||||
|
return {"ok": True, "text": text}
|
||||||
|
|
||||||
|
|
||||||
|
def arcane_echt_setzen(an: bool) -> dict:
|
||||||
|
"""POST /api/homelab/einstellungen/arcane-echt: echte Docker-Updates statt Probelauf."""
|
||||||
|
if arcane.echt_quelle() == "umgebung":
|
||||||
|
return {"ok": False, "detail": f"Die Umgebung legt das fest (MC_ARCANE_ECHT in /etc/mc2/homelab.env: Docker-"
|
||||||
|
f"Updates laufen {'echt' if arcane.echt() else 'als Probelauf'}). Sie gewinnt "
|
||||||
|
"vor diesem Schalter."}
|
||||||
|
try:
|
||||||
|
arcane.echt_speichern(an)
|
||||||
|
except OSError as exc:
|
||||||
|
return {"ok": False, "detail": f"Der Schalter ließ sich nicht speichern ({exc.__class__.__name__})."}
|
||||||
|
return {"ok": True, "text": ("Docker-Updates über Arcane laufen ab jetzt echt: Arcane holt die neuen Images und "
|
||||||
|
"startet die Container neu." if an else
|
||||||
|
"Docker-Updates über Arcane laufen wieder nur als Probelauf; geändert wird nichts.")}
|
||||||
@@ -180,8 +180,8 @@ def _arcane_bausteine(basis: str | None, zid: str) -> list[BausteinStand]:
|
|||||||
selbst.zustand = "aktuell"
|
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)
|
||||||
|
|||||||
@@ -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,16 +187,21 @@ 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)
|
||||||
|
geaendert = True
|
||||||
|
if geaendert:
|
||||||
_merken(auftraege)
|
_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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -0,0 +1,517 @@
|
|||||||
|
"""Protokoll des Homelab-Teils (24.09.2026): was wann geschah, aus allen Quellen, neueste zuerst.
|
||||||
|
|
||||||
|
Quellen, alle nur gelesen:
|
||||||
|
auftrag Aufträge an den Ausführer (kanal.py): Aktion, Gerät, Status, Code; die Ausgabe als Einzelheiten.
|
||||||
|
Berichte nur auf Wunsch (berichte=1): die Aufträge „bericht“ der Update-Läufe und die Berichte, die
|
||||||
|
der Ausführer von sich aus schickt (alle zehn Minuten; kanal.py vermerkt ihren Eingang).
|
||||||
|
lauf „Jetzt updaten“-Läufe mit ihren Schritten (updates.py)
|
||||||
|
sammellauf „Alle aktualisieren“ (sammellauf.py)
|
||||||
|
hinweis Hinweise des Wächters: erschienen, erledigt, Selbstreparatur (der Verlauf in mc2-waechter.json;
|
||||||
|
ein Hinweis, der dort schon herausgefallen ist, erscheint mit seinem Beginn)
|
||||||
|
meldung was notify.sh an Telegram gab oder für die Morgenmeldung zurücklegte (Melde-Log des Containers,
|
||||||
|
MC_NOTIFY_LOG). Den Betreff schreibt notify.sh nicht mit; er folgt aus dem Absender: Lauf,
|
||||||
|
Sammellauf, Wächter, Telegram-Test oder Morgenmeldung.
|
||||||
|
pflege das wöchentliche Suchen (pflege.py): angestoßen, gescheitert, wieder ok
|
||||||
|
|
||||||
|
Kein Eintrag enthält Geheimnisse: schwaerzen() nimmt die bekannten Schlüssel dieser Instanz (Arcane, Ausführer) und
|
||||||
|
alles, was wie ein Zugangs-Token aussieht, aus Titeln, Texten und Ausgaben; Steuerzeichen der Konsole fallen weg.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from collections.abc import Callable
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from kern.einstellungen import einstellungen
|
||||||
|
from kern.zeit import LOCAL_TZ
|
||||||
|
|
||||||
|
from services.einstellungen import melde_log
|
||||||
|
from services.homelab import apps, arcane, kanal, pflege, sammellauf, updates
|
||||||
|
|
||||||
|
GRENZE_MAX = 1000
|
||||||
|
DETAILS_MAX = 4000
|
||||||
|
TEXT_MAX = 300
|
||||||
|
MELDELOG_MAX = 512 * 1024 # so viel vom Ende des Melde-Logs
|
||||||
|
STUFE_HINWEIS = {"rot": "fehler", "gelb": "warn"}
|
||||||
|
# Betreffzeilen der Absender im Homelab-Teil (notify.sh schreibt sie nicht ins Melde-Log).
|
||||||
|
BETREFF_WAECHTER = ("[Homelab-Problem]", "[Homelab wieder ok]") # waechter.BETREFF_* in der Rolle homelab
|
||||||
|
BETREFF_TEST = "[Test]" # services/einstellungen.telegram_test
|
||||||
|
BETREFF_MORGEN = "[Morgenmeldung Homelab]" # deploy/homelab/mc2-homelab-morgenmeldung.service
|
||||||
|
LUCY_ZUSATZ = " (Diese Meldung kam auch an Lucy.)" # hängt waechter._telegram an
|
||||||
|
|
||||||
|
# Aktion → (erledigt, gescheitert, offen, kurz). Titel sind „Was · Gerät“.
|
||||||
|
_AKTION = {
|
||||||
|
"bericht": ("Bericht geholt", "Bericht gescheitert", "Bericht angefordert", "Bericht"),
|
||||||
|
"snapshot": ("Snapshot angelegt", "Snapshot gescheitert", "Snapshot wird angelegt", "Snapshot"),
|
||||||
|
"update": ("Update-Skript gelaufen", "Update-Skript gescheitert", "Update-Skript läuft", "Update-Skript"),
|
||||||
|
"os_update": ("Pakete eingespielt", "Pakete einspielen gescheitert", "Pakete werden eingespielt",
|
||||||
|
"Pakete einspielen"),
|
||||||
|
"suchen": ("Paketlisten erneuert", "Paketlisten erneuern gescheitert", "Paketlisten werden erneuert",
|
||||||
|
"Paketlisten erneuern"),
|
||||||
|
"zurueck": ("Auf den Snapshot zurückgesetzt", "Zurücksetzen gescheitert", "Wird auf den Snapshot zurückgesetzt",
|
||||||
|
"Zurücksetzen"),
|
||||||
|
"snapshot_loeschen": ("Alter Snapshot gelöscht", "Snapshot löschen gescheitert", "Alter Snapshot wird gelöscht",
|
||||||
|
"Snapshot löschen"),
|
||||||
|
"sichern": ("Sicherung angelegt", "Sicherung gescheitert", "Sicherung läuft", "Sicherung"),
|
||||||
|
"sicherung_zurueck": ("Sicherung zurückgespielt", "Zurückspielen gescheitert", "Sicherung wird zurückgespielt",
|
||||||
|
"Zurückspielen"),
|
||||||
|
"sicherung_loeschen": ("Alte Sicherung gelöscht", "Sicherung löschen gescheitert", "Alte Sicherung wird gelöscht",
|
||||||
|
"Sicherung löschen"),
|
||||||
|
"host_update": ("Host-Pakete eingespielt", "Host-Pakete gescheitert", "Host-Pakete werden eingespielt",
|
||||||
|
"Host-Pakete"),
|
||||||
|
"host_neustart": ("Neustart ausgelöst", "Neustart gescheitert", "Neustart angefordert", "Neustart"),
|
||||||
|
}
|
||||||
|
_HOST_AKTIONEN = {"bericht", "host_update", "host_neustart"}
|
||||||
|
_LAUF_WAS = {"app": "Update", "os": "Paket-Update", "pakete": "Paket-Update", "docker": "Docker-Update",
|
||||||
|
"neustart": "Neustart"}
|
||||||
|
_SCHRITT_WORT = {"wartet": "wartet", "laeuft": "läuft", "eingespielt": "eingespielt",
|
||||||
|
"zurueckgerollt": "zurückgerollt", "fehler": "gescheitert", "uebersprungen": "übersprungen"}
|
||||||
|
_MELDUNG = re.compile(r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) "
|
||||||
|
r"(OK telegram direkt|OK telegram|QUEUED für Morgen-Digest|FALLBACK \((.*?)\)): ?(.*)$")
|
||||||
|
_WEG = {"OK telegram direkt": ("Meldung raus", "info", "Direkt an die Telegram-Bot-API gesendet."),
|
||||||
|
"OK telegram": ("Meldung raus", "info", "Über Hermes an Telegram gesendet."),
|
||||||
|
"QUEUED für Morgen-Digest": ("Meldung zurückgelegt", "info",
|
||||||
|
"Nachtruhe: Sie kommt mit der Morgenmeldung um 07:00.")}
|
||||||
|
_FEHLERZEILE = ("E:", "ERROR", "Error", "error:", "FATAL", "Fehler", "Abgelehnt", "Zeitlimit", "Traceback")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Geheimnisse und Konsolen-Zeichen -------------------------------------------------------------------
|
||||||
|
|
||||||
|
_ANSI = re.compile(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-Z\\-_])")
|
||||||
|
_STEUER = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
|
||||||
|
_GEHEIM = (
|
||||||
|
# Telegram-Bot-Token, frei oder in Adressen (…/bot<Token>/sendMessage: dort ohne Wortgrenze davor)
|
||||||
|
(re.compile(r"(?<!\d)\d{6,12}:[A-Za-z0-9_-]{30,}"), "***"),
|
||||||
|
# Zugangsdaten in Adressen: https://nutzer:passwort@host
|
||||||
|
(re.compile(r"(?i)\b([a-z][a-z0-9+.-]*://)[^/\s:@]+:[^/\s@]+@"), r"\1***@"),
|
||||||
|
# Kopfzeilen: Authorization: Bearer …, X-API-Key: …, X-MC2-Ausfuehrer: …
|
||||||
|
(re.compile(r"(?i)\b(authorization|proxy-authorization|x-api-key|x-mc2-ausfuehrer|cookie|set-cookie)"
|
||||||
|
r"([\"']?\s*[:=]\s*[\"']?)(?:(?:bearer|basic|token)\s+)?[^\s\"',;]+"), r"\1\2***"),
|
||||||
|
# Name=Wert bzw. "name": "wert", wenn der Name verrät, dass der Wert geheim ist
|
||||||
|
(re.compile(r"(?i)\b([\w.-]*(?:token|passwor[dt]|secret|api[_-]?key|apikey|kennwort|passphrase|"
|
||||||
|
r"private[_-]?key|access[_-]?key)[\w.-]*)([\"']?\s*[:=]\s*[\"']?)[^\s\"',;&]+"), r"\1\2***"),
|
||||||
|
(re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._~+/=-]{8,}"), "Bearer ***"),
|
||||||
|
# bekannte Token-Formen: GitHub, GitLab, Slack, JWT
|
||||||
|
(re.compile(r"\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{20,}|"
|
||||||
|
r"xox[abpr]-[A-Za-z0-9-]{10,})"), "***"),
|
||||||
|
(re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}"), "***"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ausfuehrer_token() -> str:
|
||||||
|
"""Das Geheimnis des Kanals, falls es schon eines gibt — ohne es zu erzeugen (kanal.token() täte das)."""
|
||||||
|
if wert := os.environ.get("MC_AUSFUEHRER_TOKEN", "").strip():
|
||||||
|
return wert
|
||||||
|
try:
|
||||||
|
return (einstellungen().daten_dir / "ausfuehrer.token").read_text(encoding="utf-8").strip()
|
||||||
|
except (OSError, UnicodeDecodeError):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _schwaerzer() -> Callable[[str | None], str | None]:
|
||||||
|
"""Eine Schwärz-Funktion mit den bekannten Schlüsseln dieser Instanz (einmal je Protokoll gelesen)."""
|
||||||
|
bekannt = sorted({w for w in (arcane.schluessel(), _ausfuehrer_token()) if len(w) >= 8}, key=len, reverse=True)
|
||||||
|
|
||||||
|
def schwaerzen(text: str | None) -> str | None:
|
||||||
|
if text is None:
|
||||||
|
return None
|
||||||
|
text = _ANSI.sub("", str(text))
|
||||||
|
# Fortschrittsbalken überschreiben ihre Zeile mit \r: Stehen bleibt, was die Konsole zuletzt zeigte.
|
||||||
|
zeilen = []
|
||||||
|
for zeile in text.split("\n"):
|
||||||
|
teile = [t for t in zeile.split("\r") if t.strip()]
|
||||||
|
zeilen.append(teile[-1] if teile else "")
|
||||||
|
text = _STEUER.sub("", "\n".join(zeilen))
|
||||||
|
for wert in bekannt:
|
||||||
|
text = text.replace(wert, "***")
|
||||||
|
for muster, ersatz in _GEHEIM:
|
||||||
|
text = muster.sub(ersatz, text)
|
||||||
|
return text
|
||||||
|
|
||||||
|
return schwaerzen
|
||||||
|
|
||||||
|
|
||||||
|
def schwaerzen(text: str | None) -> str | None:
|
||||||
|
"""Geheimnisse und Steuerzeichen aus einem Text (für Tests und Einzelfälle; eintraege() liest einmal)."""
|
||||||
|
return _schwaerzer()(text)
|
||||||
|
|
||||||
|
|
||||||
|
def _kuerzen(text: str | None, laenge: int = DETAILS_MAX) -> str | None:
|
||||||
|
"""Das Ende langer Ausgaben (dort stehen Ergebnis und Fehler), wenn möglich ab einem Zeilenanfang."""
|
||||||
|
if not text or not text.strip():
|
||||||
|
return None
|
||||||
|
text = text.strip("\n")
|
||||||
|
if len(text) <= laenge:
|
||||||
|
return text
|
||||||
|
rest = text[-(laenge - 2):]
|
||||||
|
if "\n" in rest[:300]:
|
||||||
|
rest = rest.split("\n", 1)[1]
|
||||||
|
return "…\n" + rest
|
||||||
|
|
||||||
|
|
||||||
|
def _zeile(text: str | None) -> str | None:
|
||||||
|
"""Ein Text für das Feld „text“: eine Zeile, höchstens TEXT_MAX Zeichen."""
|
||||||
|
if not text or not str(text).strip():
|
||||||
|
return None
|
||||||
|
ganz = str(text).strip()
|
||||||
|
erste = ganz.splitlines()[0].strip()
|
||||||
|
if len(erste) > TEXT_MAX:
|
||||||
|
return erste[:TEXT_MAX - 1].rstrip() + "…"
|
||||||
|
return erste + ("…" if "\n" in ganz else "")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Geräte ---------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _geraete() -> dict[int, tuple[str, str]]:
|
||||||
|
"""VMID → (Ziel-ID, Name) aus dem letzten Bericht des Ausführers."""
|
||||||
|
geraete: dict[int, tuple[str, str]] = {}
|
||||||
|
for g in ((kanal.bericht() or {}).get("bericht") or {}).get("gaeste") or []:
|
||||||
|
if not isinstance(g, dict) or not isinstance(g.get("vmid"), int):
|
||||||
|
continue
|
||||||
|
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
|
||||||
|
zid = f"{'ct' if g.get('art') == 'lxc' else 'vm'}-{g['vmid']}"
|
||||||
|
geraete[g["vmid"]] = (zid, app.name if app else str(g.get("name") or zid))
|
||||||
|
return geraete
|
||||||
|
|
||||||
|
|
||||||
|
def _eintrag(zeit: object, art: str, titel: str, text: str | None, stufe: str, ziel: str | None,
|
||||||
|
details: str | None, schw: Callable) -> dict | None:
|
||||||
|
if not isinstance(zeit, (int, float)):
|
||||||
|
return None
|
||||||
|
return {"zeit": float(zeit), "art": art, "titel": schw(titel), "text": schw(text), "stufe": stufe,
|
||||||
|
"ziel": ziel, "details": schw(details)}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Aufträge an den Ausführer --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _dauer(bis: object, von: object) -> str | None:
|
||||||
|
if not (isinstance(bis, (int, float)) and isinstance(von, (int, float))) or bis < von:
|
||||||
|
return None
|
||||||
|
s = round(bis - von)
|
||||||
|
if s < 60:
|
||||||
|
return f"{s} s"
|
||||||
|
if s < 3600:
|
||||||
|
return f"{s // 60} min"
|
||||||
|
return f"{s // 3600} h {s % 3600 // 60} min"
|
||||||
|
|
||||||
|
|
||||||
|
def _gegenstand(aktion: str, p: dict, ausgabe: str) -> str | None:
|
||||||
|
"""Worum es ging: der Snapshot bzw. die Sicherung."""
|
||||||
|
if aktion in ("zurueck", "snapshot_loeschen"):
|
||||||
|
return str(p.get("snapshot") or "") or None
|
||||||
|
if aktion in ("sicherung_zurueck", "sicherung_loeschen"):
|
||||||
|
return str(p.get("sicherung") or "").rsplit("/", 1)[-1] or None
|
||||||
|
if aktion == "snapshot" and (m := re.search(r"snapshot=(\S+)", ausgabe)):
|
||||||
|
return m.group(1)
|
||||||
|
if aktion == "sichern" and (m := re.search(r"^sicherung=(\S+)$", ausgabe, re.MULTILINE)):
|
||||||
|
return m.group(1).rsplit("/", 1)[-1]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _fehlerzeile(ausgabe: str) -> str | None:
|
||||||
|
zeilen = [z.strip() for z in ausgabe.splitlines() if z.strip()]
|
||||||
|
wichtig = [z for z in zeilen if z.startswith(_FEHLERZEILE)]
|
||||||
|
zeile = (wichtig or zeilen or [None])[-1]
|
||||||
|
return zeile[:200] if zeile else None
|
||||||
|
|
||||||
|
|
||||||
|
def _bericht_satz(version: object, gaeste: object, laufend: object, host_updates: object) -> str:
|
||||||
|
teile = [f"Proxmox VE {version}" if version else None, f"{gaeste} Gäste, davon {laufend} laufend",
|
||||||
|
f"{host_updates} Paket-Updates auf dem Host" if isinstance(host_updates, int) else None]
|
||||||
|
return ", ".join(t for t in teile if t) + "."
|
||||||
|
|
||||||
|
|
||||||
|
def _bericht_kurz(ausgabe: str) -> str | None:
|
||||||
|
"""Ein Bericht ist JSON; statt seines Endes eine Zusammenfassung."""
|
||||||
|
try:
|
||||||
|
b = json.loads(ausgabe)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if not isinstance(b, dict):
|
||||||
|
return None
|
||||||
|
host = b.get("host") if isinstance(b.get("host"), dict) else {}
|
||||||
|
gaeste = [g for g in b.get("gaeste") or [] if isinstance(g, dict)]
|
||||||
|
return _bericht_satz(host.get("version"), len(gaeste), sum(1 for g in gaeste if g.get("status") == "running"),
|
||||||
|
len(host["updates"]) if isinstance(host.get("updates"), list) else None)
|
||||||
|
|
||||||
|
|
||||||
|
def _bericht_eingang(e: dict, schw: Callable) -> dict | None:
|
||||||
|
"""Ein Bericht, den der Ausführer von sich aus geschickt hat (kein Auftrag)."""
|
||||||
|
return _eintrag(e.get("empfangen"), "auftrag", "Bericht empfangen · Proxmox-Host",
|
||||||
|
_bericht_satz(e.get("host_version"), e.get("gaeste"), e.get("laufend"), e.get("host_updates")),
|
||||||
|
"info", "pve", None, schw)
|
||||||
|
|
||||||
|
|
||||||
|
def _auftrag(a: dict, geraete: dict, schw: Callable) -> dict | None:
|
||||||
|
aktion = str(a.get("aktion") or "?")
|
||||||
|
erledigt, gescheitert, offen, kurz = _AKTION.get(aktion, (aktion, f"{aktion} gescheitert", f"{aktion} läuft",
|
||||||
|
aktion))
|
||||||
|
p = a.get("parameter") if isinstance(a.get("parameter"), dict) else {}
|
||||||
|
ziel, name = None, None
|
||||||
|
if aktion in _HOST_AKTIONEN:
|
||||||
|
ziel, name = "pve", "Proxmox-Host"
|
||||||
|
elif isinstance(p.get("vmid"), int):
|
||||||
|
ziel, name = geraete.get(p["vmid"], (None, f"Gast {p['vmid']}"))
|
||||||
|
status, code = a.get("status"), a.get("code")
|
||||||
|
ausgabe = str(a.get("text") or "")
|
||||||
|
gegenstand = _gegenstand(aktion, p, ausgabe)
|
||||||
|
dauer = _dauer(a.get("fertig"), a.get("abgeholt"))
|
||||||
|
kopf = ", ".join(t for t in (gegenstand, f"nach {dauer}" if dauer else None,
|
||||||
|
f"Code {code}" if code is not None else None) if t)
|
||||||
|
if status == "fertig":
|
||||||
|
titel, stufe, text = erledigt, "ok", kopf or None
|
||||||
|
elif status == "fehler" and code == 2 and ausgabe.startswith("Abgelehnt"):
|
||||||
|
titel, stufe, text = f"{kurz} abgelehnt", "warn", _fehlerzeile(ausgabe)
|
||||||
|
elif status == "fehler":
|
||||||
|
grund = _fehlerzeile(ausgabe)
|
||||||
|
titel, stufe, text = gescheitert, "fehler", f"{kopf}: {grund}" if grund else kopf or None
|
||||||
|
elif status == "verloren":
|
||||||
|
titel, stufe, text = f"{kurz}: keine Antwort", "fehler", "Der Ausführer hat nie geantwortet."
|
||||||
|
elif status == "laeuft":
|
||||||
|
titel, stufe, text = offen, "info", "Der Ausführer ist dran."
|
||||||
|
else:
|
||||||
|
titel, stufe, text = offen, "info", "Wartet auf den Ausführer."
|
||||||
|
details = _bericht_kurz(ausgabe) if aktion == "bericht" else None
|
||||||
|
return _eintrag(a.get("fertig") or a.get("abgeholt") or a.get("erstellt"), "auftrag",
|
||||||
|
f"{titel} · {name}" if name else titel, text, stufe, ziel,
|
||||||
|
details or _kuerzen(schw(ausgabe)), schw)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Läufe und Sammelläufe ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _anzahl_pakete(n: object, wort: str) -> str:
|
||||||
|
return f"{n} {wort}{'' if n == 1 else 'e'} eingespielt" if isinstance(n, int) and n > 0 else f"{wort}e eingespielt"
|
||||||
|
|
||||||
|
|
||||||
|
def _lauf(x: dict, schw: Callable) -> dict | None:
|
||||||
|
name = str(x.get("name") or x.get("ziel") or "?")
|
||||||
|
baustein, ergebnis, status, text = x.get("baustein"), x.get("ergebnis"), x.get("status"), x.get("text")
|
||||||
|
was = _LAUF_WAS.get(str(baustein), "Update")
|
||||||
|
if status == "laeuft":
|
||||||
|
titel, stufe = f"{was} läuft · {name}", "info"
|
||||||
|
elif status == "unterbrochen":
|
||||||
|
titel, stufe = f"{was} unterbrochen · {name}", "fehler"
|
||||||
|
text = text or "Der Homelab-Teil wurde während des Laufs neu gestartet; ob das Update durchlief, zeigt der " \
|
||||||
|
"nächste Bericht."
|
||||||
|
elif ergebnis == "eingespielt":
|
||||||
|
stufe = "ok"
|
||||||
|
if baustein == "app":
|
||||||
|
titel = f"Update eingespielt · {name}" + (f" {x['version_neu']}" if x.get("version_neu") else "")
|
||||||
|
elif baustein == "docker":
|
||||||
|
titel = f"Docker-Images aktualisiert · {name}"
|
||||||
|
elif baustein == "pakete":
|
||||||
|
titel = f"{_anzahl_pakete(x.get('pakete'), 'Host-Paket')} · {name}"
|
||||||
|
else:
|
||||||
|
titel = f"{_anzahl_pakete(x.get('pakete'), 'Paket')} · {name}"
|
||||||
|
elif ergebnis == "zurueckgerollt":
|
||||||
|
titel, stufe = f"{was} zurückgerollt · {name}", "warn"
|
||||||
|
elif ergebnis == "fehler" and "Update nicht begonnen" in str(text or ""):
|
||||||
|
titel, stufe = f"{was} nicht begonnen · {name}", "warn"
|
||||||
|
elif ergebnis == "fehler":
|
||||||
|
titel, stufe = f"{was} gescheitert · {name}", "fehler"
|
||||||
|
elif ergebnis == "neustart":
|
||||||
|
titel, stufe = f"Neustart ausgelöst · {name}", "info"
|
||||||
|
elif ergebnis == "offen":
|
||||||
|
titel, stufe = f"Docker-Probelauf · {name}", "info"
|
||||||
|
else:
|
||||||
|
titel, stufe = f"{was} beendet · {name}", "info"
|
||||||
|
zeilen = [str(s) for s in x.get("schritte") or []]
|
||||||
|
if x.get("sammellauf"):
|
||||||
|
zeilen.append("Teil von „Alle aktualisieren“.")
|
||||||
|
if x.get("gemeldet") is False:
|
||||||
|
zeilen.append("Keine eigene Meldung: Die Sammelmeldung am Ende sagt es.")
|
||||||
|
return _eintrag(x.get("ende") or x.get("start"), "lauf", titel, text, stufe, x.get("ziel"),
|
||||||
|
"\n".join(zeilen) or None, schw)
|
||||||
|
|
||||||
|
|
||||||
|
def _sammellauf(sl: dict, schw: Callable) -> dict | None:
|
||||||
|
schritte = [s for s in sl.get("schritte") or [] if isinstance(s, dict)]
|
||||||
|
anzahl = len(schritte)
|
||||||
|
eingespielt = sum(1 for s in schritte if s.get("status") == "eingespielt")
|
||||||
|
status = sl.get("status")
|
||||||
|
if status == "laeuft":
|
||||||
|
i = sl.get("aktuell")
|
||||||
|
titel = "Alle aktualisieren läuft" + (f" · Schritt {i + 1} von {anzahl}" if isinstance(i, int) else "")
|
||||||
|
stufe = "info"
|
||||||
|
elif status == "abgebrochen":
|
||||||
|
bei = next((s.get("name") for s in schritte if s.get("status") in ("fehler", "zurueckgerollt")), None)
|
||||||
|
titel, stufe = "Alle aktualisieren abgebrochen" + (f" · {bei}" if bei else ""), "fehler"
|
||||||
|
else:
|
||||||
|
titel = f"Alle aktualisieren fertig · {eingespielt} von {anzahl} eingespielt"
|
||||||
|
stufe = "ok" if eingespielt == anzahl else "info"
|
||||||
|
details = "\n".join(f"{s.get('name')} ({s.get('was')}): {_SCHRITT_WORT.get(str(s.get('status')), s.get('status'))}"
|
||||||
|
+ (f" – {s['text']}" if s.get("text") else "") for s in schritte)
|
||||||
|
return _eintrag(sl.get("ende") or sl.get("start"), "sammellauf", titel, sl.get("text"), stufe, None,
|
||||||
|
details or None, schw)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Hinweise des Wächters ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _waechter_pfad() -> Path:
|
||||||
|
"""Wie waechter.STORE_PATH — hier gelesen, ohne den Wächter zu laden (der zieht Module der KI-Box nach)."""
|
||||||
|
return Path(os.environ.get("MC_WAECHTER_STORE", str(einstellungen().daten_dir / "mc2-waechter.json")))
|
||||||
|
|
||||||
|
|
||||||
|
def _ziel_aus(hinweis_id: str) -> str | None:
|
||||||
|
if m := re.search(r"\b(ct|vm)-(\d+)\b", hinweis_id):
|
||||||
|
return f"{m.group(1)}-{m.group(2)}"
|
||||||
|
if m := re.fullmatch(r"gast-platte:(\d+)", hinweis_id):
|
||||||
|
return f"ct-{m.group(1)}"
|
||||||
|
return "pve" if hinweis_id == "ausfuehrer" else None
|
||||||
|
|
||||||
|
|
||||||
|
def _hinweise(schw: Callable) -> list[dict]:
|
||||||
|
try:
|
||||||
|
daten = json.loads(_waechter_pfad().read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return []
|
||||||
|
daten = daten if isinstance(daten, dict) else {}
|
||||||
|
aktuell = daten.get("hinweise") if isinstance(daten.get("hinweise"), dict) else {}
|
||||||
|
eintraege, erschienen = [], set()
|
||||||
|
for v in daten.get("verlauf") or []:
|
||||||
|
if not isinstance(v, dict):
|
||||||
|
continue
|
||||||
|
art, hid = v.get("art"), str(v.get("id") or "")
|
||||||
|
titel, h = str(v.get("text") or hid), aktuell.get(hid) if isinstance(aktuell.get(hid), dict) else {}
|
||||||
|
if art == "neu":
|
||||||
|
erschienen.add(hid)
|
||||||
|
e = _eintrag(v.get("ts"), "hinweis", f"Hinweis: {titel}", h.get("text"),
|
||||||
|
STUFE_HINWEIS.get(str(v.get("stufe") or h.get("stufe")), "warn"), _ziel_aus(hid), None, schw)
|
||||||
|
elif art == "erledigt":
|
||||||
|
e = _eintrag(v.get("ts"), "hinweis", f"Erledigt: {titel}", None, "ok", _ziel_aus(hid), None, schw)
|
||||||
|
elif art == "auto":
|
||||||
|
e = _eintrag(v.get("ts"), "hinweis", titel, "Selbstreparatur des Wächters.", "info", _ziel_aus(hid),
|
||||||
|
None, schw)
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
eintraege.append(e)
|
||||||
|
for hid, h in aktuell.items():
|
||||||
|
if hid not in erschienen and isinstance(h, dict):
|
||||||
|
eintraege.append(_eintrag(h.get("seit"), "hinweis", f"Hinweis: {h.get('titel') or hid}", h.get("text"),
|
||||||
|
STUFE_HINWEIS.get(str(h.get("stufe")), "warn"), _ziel_aus(hid), None, schw))
|
||||||
|
return eintraege
|
||||||
|
|
||||||
|
|
||||||
|
# --- Meldungen ------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _meldelog() -> list[list]:
|
||||||
|
"""[Zeit, Weg, Grund, Text] je Eintrag des Melde-Logs; Zeilen ohne Zeitstempel gehören zur Meldung davor."""
|
||||||
|
try:
|
||||||
|
with melde_log().open("rb") as f:
|
||||||
|
f.seek(0, os.SEEK_END)
|
||||||
|
groesse = f.tell()
|
||||||
|
f.seek(max(0, groesse - MELDELOG_MAX))
|
||||||
|
roh = f.read().decode("utf-8", "replace")
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
if groesse > MELDELOG_MAX:
|
||||||
|
roh = roh.split("\n", 1)[-1] # angeschnittene erste Zeile
|
||||||
|
eintraege: list[list] = []
|
||||||
|
for zeile in roh.splitlines():
|
||||||
|
if m := _MELDUNG.match(zeile):
|
||||||
|
try:
|
||||||
|
zeit = datetime.strptime(m.group(1), "%Y-%m-%d %H:%M:%S").replace(tzinfo=LOCAL_TZ).timestamp()
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
weg = "FALLBACK" if m.group(2).startswith("FALLBACK") else m.group(2)
|
||||||
|
eintraege.append([zeit, weg, m.group(3), m.group(4)])
|
||||||
|
elif eintraege:
|
||||||
|
eintraege[-1][3] += "\n" + zeile
|
||||||
|
return eintraege
|
||||||
|
|
||||||
|
|
||||||
|
def _betreffe(laeufe: list[dict], sammellaeufe: list[dict]) -> dict[str, str]:
|
||||||
|
"""Meldungstext → Betreff für das, was Läufe und Sammelläufe gemeldet haben."""
|
||||||
|
bekannt = {}
|
||||||
|
for x in laeufe:
|
||||||
|
if x.get("text") and x.get("gemeldet") is not False:
|
||||||
|
dringend = x.get("dringend")
|
||||||
|
if dringend is None: # Läufe von vor dem 24.09.2026 abends: wie updates._ende es entschied
|
||||||
|
dringend = x.get("ergebnis") in ("zurueckgerollt", "fehler") and "nicht begonnen" not in x["text"]
|
||||||
|
bekannt[x["text"]] = updates.BETREFF_ALARM if dringend else updates.BETREFF
|
||||||
|
for sl in sammellaeufe:
|
||||||
|
if sl.get("meldung"):
|
||||||
|
bekannt[sl["meldung"]] = updates.BETREFF_ALARM if sl.get("dringend") else updates.BETREFF
|
||||||
|
return bekannt
|
||||||
|
|
||||||
|
|
||||||
|
def _betreff(text: str, bekannt: dict[str, str]) -> str | None:
|
||||||
|
if text in bekannt:
|
||||||
|
return bekannt[text]
|
||||||
|
if text.endswith(LUCY_ZUSATZ):
|
||||||
|
return BETREFF_WAECHTER[1] if text.startswith("Erledigt:") else BETREFF_WAECHTER[0]
|
||||||
|
if text.startswith("Testmeldung aus den Einstellungen"):
|
||||||
|
return BETREFF_TEST
|
||||||
|
if text.startswith("Guten Morgen, Commander."):
|
||||||
|
return BETREFF_MORGEN
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _meldungen(bekannt: dict[str, str], schw: Callable) -> list[dict]:
|
||||||
|
eintraege = []
|
||||||
|
for zeit, weg, grund, text in _meldelog():
|
||||||
|
betreff = _betreff(text, bekannt)
|
||||||
|
if weg == "FALLBACK":
|
||||||
|
titel, stufe, wie = "Meldung ging nicht raus", "fehler", f"Telegram ging nicht: {grund or 'ohne Angabe'}"
|
||||||
|
else:
|
||||||
|
titel, stufe, wie = _WEG[weg]
|
||||||
|
kurz = _zeile(text)
|
||||||
|
details = wie if kurz == text.strip() else f"{wie}\n\n{text.strip()}"
|
||||||
|
eintraege.append(_eintrag(zeit, "meldung", f"{titel} · {betreff}" if betreff else titel, kurz, stufe, None,
|
||||||
|
details, schw))
|
||||||
|
return eintraege
|
||||||
|
|
||||||
|
|
||||||
|
# --- Pflege ---------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_PFLEGE = {"angestossen": ("Wöchentliche Suche angestoßen", "info"),
|
||||||
|
"gescheitert": ("Wöchentliche Suche gescheitert", "warn"),
|
||||||
|
"wieder_ok": ("Wöchentliche Suche klappt wieder", "ok")}
|
||||||
|
|
||||||
|
|
||||||
|
def _pflege(geraete: dict, schw: Callable) -> list[dict]:
|
||||||
|
daten = pflege.zustand()
|
||||||
|
ereignisse = [e for e in daten.get("ereignisse") or [] if isinstance(e, dict)]
|
||||||
|
eintraege = []
|
||||||
|
for e in ereignisse:
|
||||||
|
if e.get("art") not in _PFLEGE or not isinstance(e.get("vmid"), int):
|
||||||
|
continue
|
||||||
|
titel, stufe = _PFLEGE[e["art"]]
|
||||||
|
name = e.get("name") or geraete.get(e["vmid"], (None, f"Gast {e['vmid']}"))[1]
|
||||||
|
eintraege.append(_eintrag(e.get("ts"), "pflege", f"{titel} · {name}", e.get("text"), stufe,
|
||||||
|
f"ct-{e['vmid']}", None, schw))
|
||||||
|
# Stände von vor dem 24.09.2026 abends haben keine Ereignisliste: dann wenigstens der letzte Anstoß je Gast.
|
||||||
|
schon = {(e.get("vmid"), e.get("ts")) for e in ereignisse if e.get("art") == "angestossen"}
|
||||||
|
for vmid, g in (daten.get("gaeste") or {}).items():
|
||||||
|
if not (isinstance(g, dict) and isinstance(g.get("angestossen"), (int, float)) and str(vmid).isdigit()):
|
||||||
|
continue
|
||||||
|
if (int(vmid), g["angestossen"]) not in schon:
|
||||||
|
name = g.get("name") or geraete.get(int(vmid), (None, f"Gast {vmid}"))[1]
|
||||||
|
eintraege.append(_eintrag(g["angestossen"], "pflege", f"{_PFLEGE['angestossen'][0]} · {name}",
|
||||||
|
"Die Paketlisten waren älter als 7 Tage.", "info", f"ct-{vmid}", None, schw))
|
||||||
|
return eintraege
|
||||||
|
|
||||||
|
|
||||||
|
# --- Zusammen -------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def eintraege(grenze: int = 200, berichte: bool = False) -> list[dict]:
|
||||||
|
"""Alle Einträge, neueste zuerst, höchstens `grenze` (1 bis GRENZE_MAX)."""
|
||||||
|
grenze = max(1, min(int(grenze), GRENZE_MAX))
|
||||||
|
schw = _schwaerzer()
|
||||||
|
geraete = _geraete()
|
||||||
|
laeufe = updates.laeufe(100)
|
||||||
|
sammellaeufe = sammellauf.liste()
|
||||||
|
alle: list[dict | None] = []
|
||||||
|
alle += [_auftrag(a, geraete, schw) for a in kanal.liste() if berichte or a.get("aktion") != "bericht"]
|
||||||
|
if berichte:
|
||||||
|
alle += [_bericht_eingang(e, schw) for e in kanal.berichte()]
|
||||||
|
alle += [_lauf(x, schw) for x in laeufe if isinstance(x, dict)]
|
||||||
|
alle += [_sammellauf(sl, schw) for sl in sammellaeufe]
|
||||||
|
alle += _hinweise(schw)
|
||||||
|
alle += _meldungen(_betreffe(laeufe, sammellaeufe), schw)
|
||||||
|
alle += _pflege(geraete, schw)
|
||||||
|
liste = [e for e in alle if e is not None]
|
||||||
|
liste.sort(key=lambda e: e["zeit"], reverse=True)
|
||||||
|
return liste[:grenze]
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
"""„Alle aktualisieren“ im Homelab (24.09.2026): alle Updates mit Knopf nacheinander.
|
||||||
|
|
||||||
|
Jeder Schritt ist ein gewöhnlicher Lauf von „Jetzt updaten“ (updates.starten) und wartet, bis dieser fertig ist —
|
||||||
|
mit Rückweg, Prüfung und Zurückrollen wie beim einzelnen Knopf. Reihenfolge (User-Entscheid 24.09.2026):
|
||||||
|
1. die Gäste nach VMID, außer NPMplus und AdGuard Home
|
||||||
|
2. NPMplus, dann AdGuard Home: Nadelöhre (Proxy, DNS) — fällt einer aus, hängt anderes mit
|
||||||
|
3. ganz zuletzt die Pakete des Proxmox-Hosts; neu gestartet wird der Host dabei nie
|
||||||
|
Dabei ist nur, was „neu“ ist und einen Knopf hat. Was in der Wartezeit nach einer Skriptänderung steht (karenz.py)
|
||||||
|
oder sich über die eigene Oberfläche aktualisiert, hat keinen Knopf und fehlt deshalb. Docker über Arcane ist nur
|
||||||
|
dabei, wenn die Updates echt laufen: Ein Probelauf ändert nichts.
|
||||||
|
|
||||||
|
Endet ein Schritt mit „zurueckgerollt“ oder „fehler“, hört der Sammellauf auf: Die übrigen Schritte sind
|
||||||
|
„uebersprungen“, er selbst ist „abgebrochen“, und es geht eine dringende Meldung raus. Läuft alles durch, kommt eine
|
||||||
|
Sammelmeldung; die Erfolgsmeldungen der einzelnen Schritte hält updates._ende() so lange zurück, Fehlermeldungen
|
||||||
|
nicht. Lehnt updates.starten einen Schritt ab (am Gerät ändert sich dann nichts, etwa weil die Wartezeit inzwischen
|
||||||
|
greift), wird er übersprungen, und es geht weiter.
|
||||||
|
|
||||||
|
Es läuft höchstens ein Sammellauf, und solange er läuft, lehnt der einzelne Knopf ab (updates.SAMMELLAUF_SPERRE).
|
||||||
|
Stand in <Datenordner>/homelab-sammellauf.json (die letzten BEHALTEN). Startet der Homelab-Teil mitten im Lauf neu,
|
||||||
|
gilt er als abgebrochen, „unterbrochen (Neustart)“ (über updates.unterbrochene_abschliessen beim Start).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from kern.einstellungen import einstellungen
|
||||||
|
|
||||||
|
from services import announce
|
||||||
|
from services.homelab import apps, arcane, inventar, kanal, updates
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
NADELOEHRE = ("npmplus", "adguard") # nach allen anderen Gästen, in dieser Reihenfolge
|
||||||
|
GAST_BAUSTEINE = ("app", "os", "docker")
|
||||||
|
TAKT_S = 2.0
|
||||||
|
WARTEN_MAX_S = 4 * 3600 # Sicherheitsnetz; ein Lauf hat je Schritt eigene Zeitlimits (höchstens 45 min)
|
||||||
|
BEHALTEN = 20
|
||||||
|
UNTERBROCHEN = "unterbrochen (Neustart)"
|
||||||
|
HOST_HINWEIS = "Der Proxmox-Host kommt zuletzt und hat keinen Rückweg; neu gestartet wird er nicht."
|
||||||
|
# Was ein Lauf als Ergebnis hat → Stand des Schritts. „offen“ ist ein Arcane-Probelauf: geändert wurde nichts.
|
||||||
|
STATUS = {"eingespielt": "eingespielt", "zurueckgerollt": "zurueckgerollt", "fehler": "fehler",
|
||||||
|
"offen": "uebersprungen"}
|
||||||
|
# Die Felder, die die Oberfläche bekommt (Vertrag GET /api/homelab/sammellauf); der Rest ist für das Protokoll.
|
||||||
|
FELDER = ("id", "status", "start", "ende", "aktuell", "schritte", "text")
|
||||||
|
SCHRITT_FELDER = ("ziel", "name", "baustein", "was", "status", "lauf", "text")
|
||||||
|
|
||||||
|
_lock = threading.RLock() # _merken liest und schreibt unter derselben Sperre
|
||||||
|
|
||||||
|
|
||||||
|
# --- Stand ------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _pfad() -> Path:
|
||||||
|
return einstellungen().daten_dir / "homelab-sammellauf.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _alle() -> list[dict]:
|
||||||
|
with _lock:
|
||||||
|
try:
|
||||||
|
daten = json.loads(_pfad().read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return []
|
||||||
|
return [x for x in daten if isinstance(x, dict) and x.get("id")] if isinstance(daten, list) else []
|
||||||
|
|
||||||
|
|
||||||
|
def _merken(sl: dict) -> None:
|
||||||
|
with _lock:
|
||||||
|
alle = [x for x in _alle() if x["id"] != sl["id"]] + [sl]
|
||||||
|
_pfad().parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = _pfad().with_suffix(".tmp")
|
||||||
|
tmp.write_text(json.dumps(alle[-BEHALTEN:], ensure_ascii=False), encoding="utf-8")
|
||||||
|
tmp.replace(_pfad())
|
||||||
|
|
||||||
|
|
||||||
|
def oeffentlich(sl: dict | None) -> dict | None:
|
||||||
|
"""Ein Sammellauf, wie ihn die Oberfläche bekommt (nur die Felder des Vertrags, als Kopie)."""
|
||||||
|
if sl is None:
|
||||||
|
return None
|
||||||
|
return {**{k: sl.get(k) for k in FELDER},
|
||||||
|
"schritte": [{k: s.get(k) for k in SCHRITT_FELDER} for s in sl.get("schritte") or []]}
|
||||||
|
|
||||||
|
|
||||||
|
def liste() -> list[dict]:
|
||||||
|
"""Alle aufbewahrten Sammelläufe, neueste zuerst, mit allen Feldern (für das Protokoll)."""
|
||||||
|
return list(reversed(_alle()))
|
||||||
|
|
||||||
|
|
||||||
|
def aktueller() -> dict | None:
|
||||||
|
"""Der laufende oder, wenn keiner läuft, der letzte Sammellauf."""
|
||||||
|
alle = _alle()
|
||||||
|
return oeffentlich(next((x for x in reversed(alle) if x.get("status") == "laeuft"), alle[-1] if alle else None))
|
||||||
|
|
||||||
|
|
||||||
|
def laeuft() -> bool:
|
||||||
|
return any(x.get("status") == "laeuft" for x in _alle())
|
||||||
|
|
||||||
|
|
||||||
|
def _aufzaehlung(namen: list[str]) -> str:
|
||||||
|
"""„A“, „A und B“, „A, B und C“."""
|
||||||
|
namen = list(dict.fromkeys(namen))
|
||||||
|
return namen[0] if len(namen) == 1 else ", ".join(namen[:-1]) + " und " + namen[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def _melden(text: str, dringend: bool = False) -> None:
|
||||||
|
"""Derselbe Meldeweg und Betreff wie beim einzelnen Knopf (updates.py)."""
|
||||||
|
try:
|
||||||
|
announce.notify_telegram(updates.BETREFF_ALARM if dringend else updates.BETREFF, text)
|
||||||
|
except Exception:
|
||||||
|
log.warning("homelab: Meldung zu „Alle aktualisieren“ ging nicht raus", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Plan -------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _kennung(ziel_id: str) -> str | None:
|
||||||
|
gast = inventar.gast(ziel_id) or {}
|
||||||
|
app = apps.app_fuer((gast.get("app") or {}).get("kennung"), gast.get("name"))
|
||||||
|
return app.kennung if app else None
|
||||||
|
|
||||||
|
|
||||||
|
def _rang(schritt: dict) -> tuple[int, int]:
|
||||||
|
"""Erst alle anderen Gäste nach VMID, dann die Nadelöhre in ihrer Reihenfolge."""
|
||||||
|
kennung = _kennung(schritt["ziel"])
|
||||||
|
nadel = NADELOEHRE.index(kennung) + 1 if kennung in NADELOEHRE else 0
|
||||||
|
return nadel, int(schritt["ziel"].split("-", 1)[1])
|
||||||
|
|
||||||
|
|
||||||
|
def plan() -> dict:
|
||||||
|
"""Was „Alle aktualisieren“ jetzt täte, in dieser Reihenfolge, samt Hinweis auf das, was keinen Rückweg hat.
|
||||||
|
{"schritte": [{"ziel", "name", "baustein", "was", "rueckweg_art"}], "hinweis": str | None}"""
|
||||||
|
gaeste: list[dict] = []
|
||||||
|
host: list[dict] = []
|
||||||
|
docker_probe = False
|
||||||
|
for z in inventar.ziele()["ziele"]:
|
||||||
|
for b in z.get("bausteine") or []:
|
||||||
|
if b.get("zustand") != "neu" or not b.get("aktion"):
|
||||||
|
continue
|
||||||
|
schritt = {"ziel": z["id"], "name": z["name"], "baustein": b["id"], "was": b.get("kurz") or b["name"],
|
||||||
|
"rueckweg_art": z.get("rueckweg_art") or "keiner"}
|
||||||
|
if z["id"] == "pve":
|
||||||
|
if b["id"] == "pakete": # der Neustart ist nie dabei
|
||||||
|
host.append({**schritt, "rueckweg_art": "keiner"})
|
||||||
|
elif b["id"] == "docker":
|
||||||
|
if not arcane.echt():
|
||||||
|
docker_probe = True # ein Probelauf ist kein Update
|
||||||
|
else:
|
||||||
|
gaeste.append({**schritt, "rueckweg_art": "keiner"}) # Arcane legt keinen Snapshot an
|
||||||
|
elif b["id"] in GAST_BAUSTEINE:
|
||||||
|
gaeste.append(schritt)
|
||||||
|
gaeste.sort(key=_rang) # sort ist stabil: je Gast bleibt die Reihenfolge seiner Bausteine
|
||||||
|
schritte = gaeste + host
|
||||||
|
teile = []
|
||||||
|
ohne = [s["name"] for s in gaeste if s["rueckweg_art"] == "keiner"]
|
||||||
|
if ohne:
|
||||||
|
teile.append(f"Ohne Rückweg: {_aufzaehlung(ohne)} – scheitert dort das Update, geht es nicht von selbst "
|
||||||
|
"zurück.")
|
||||||
|
if host:
|
||||||
|
teile.append(HOST_HINWEIS)
|
||||||
|
if docker_probe:
|
||||||
|
teile.append("Die Docker-Images (Arcane) sind nicht dabei, solange Docker-Updates nur als Probelauf laufen.")
|
||||||
|
return {"schritte": schritte, "hinweis": " ".join(teile) or None}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Starten ----------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _ausfuehrer_grund() -> str | None:
|
||||||
|
"""Warum der Ausführer gerade keine Updates annimmt — oder None."""
|
||||||
|
zuletzt = kanal.zuletzt()
|
||||||
|
if not (zuletzt and time.time() - zuletzt < inventar.BERICHT_ALT_S):
|
||||||
|
return "Der Ausführer auf dem Proxmox-Host ist nicht verbunden. Ohne ihn geht kein Update."
|
||||||
|
host = ((kanal.bericht() or {}).get("bericht") or {}).get("host") or {}
|
||||||
|
if host.get("nur_lesen"):
|
||||||
|
return "Der Ausführer auf dem Proxmox-Host läuft im Nur-Lesen-Modus und lehnt Updates ab."
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def starten() -> dict:
|
||||||
|
"""Knopf „Alle aktualisieren“. {"ok": True, "sammellauf": {…}} oder {"ok": False, "detail": …}."""
|
||||||
|
if laeuft():
|
||||||
|
return {"ok": False, "detail": updates.SAMMELLAUF_SPERRE}
|
||||||
|
schritte = plan()["schritte"]
|
||||||
|
if not schritte:
|
||||||
|
return {"ok": False, "detail": "Gerade gibt es kein Update mit Knopf, also nichts zu tun."}
|
||||||
|
if any(s["baustein"] != "docker" for s in schritte) and (grund := _ausfuehrer_grund()):
|
||||||
|
return {"ok": False, "detail": grund}
|
||||||
|
with updates.START_SPERRE:
|
||||||
|
if laeuft():
|
||||||
|
return {"ok": False, "detail": updates.SAMMELLAUF_SPERRE}
|
||||||
|
if laufende := [x.get("name") or x.get("ziel") for x in updates.laeufe(100) if x.get("status") == "laeuft"]:
|
||||||
|
return {"ok": False, "detail": f"Gerade läuft ein einzelnes Update ({_aufzaehlung(laufende)}). "
|
||||||
|
"„Alle aktualisieren“ geht, sobald es fertig ist."}
|
||||||
|
sl = {"id": uuid.uuid4().hex[:12], "status": "laeuft", "start": time.time(), "ende": None, "aktuell": None,
|
||||||
|
"schritte": [{"ziel": s["ziel"], "name": s["name"], "baustein": s["baustein"], "was": s["was"],
|
||||||
|
"status": "wartet", "lauf": None, "text": None} for s in schritte],
|
||||||
|
"text": None, "meldung": None, "dringend": False}
|
||||||
|
_merken(sl)
|
||||||
|
antwort = oeffentlich(sl) # eine Kopie: Der Faden ändert sl gleich weiter
|
||||||
|
threading.Thread(target=_ablauf, args=(sl,), name="homelab-alle", daemon=True).start()
|
||||||
|
log.info("homelab: „Alle aktualisieren“ %s mit %d Schritten gestartet", sl["id"], len(schritte))
|
||||||
|
return {"ok": True, "sammellauf": antwort}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Ablauf -----------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _warten(lauf_id: str) -> dict | None:
|
||||||
|
"""Bis der Lauf fertig ist; None, wenn er sich in WARTEN_MAX_S nicht zurückmeldet."""
|
||||||
|
ende = time.time() + WARTEN_MAX_S
|
||||||
|
while time.time() < ende:
|
||||||
|
lauf = updates.lauf_lesen(lauf_id)
|
||||||
|
if lauf is not None and lauf.get("status") != "laeuft":
|
||||||
|
return lauf
|
||||||
|
time.sleep(TAKT_S)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _schritt(sl: dict, schritt: dict) -> None:
|
||||||
|
"""Einen Schritt gehen: den Lauf anstoßen, auf ihn warten, sein Ergebnis übernehmen."""
|
||||||
|
if schritt["baustein"] == "docker" and not arcane.echt():
|
||||||
|
schritt.update(status="uebersprungen", text="Docker-Updates über Arcane stehen inzwischen auf Probelauf.")
|
||||||
|
return
|
||||||
|
antwort = updates.starten(schritt["ziel"], schritt["baustein"], sammellauf={"id": sl["id"], "start": sl["start"]})
|
||||||
|
if not antwort.get("ok"):
|
||||||
|
schritt.update(status="uebersprungen", text=str(antwort.get("detail") or "abgelehnt"))
|
||||||
|
return
|
||||||
|
schritt["lauf"] = antwort["lauf"]
|
||||||
|
_merken(sl)
|
||||||
|
lauf = _warten(antwort["lauf"])
|
||||||
|
if lauf is None:
|
||||||
|
schritt.update(status="fehler", text=f"Der Lauf hat sich in {WARTEN_MAX_S // 3600} Stunden nicht "
|
||||||
|
"zurückgemeldet.")
|
||||||
|
return
|
||||||
|
schritt.update(status=STATUS.get(str(lauf.get("ergebnis")), "fehler"), text=lauf.get("text"))
|
||||||
|
|
||||||
|
|
||||||
|
def _ablauf(sl: dict) -> None:
|
||||||
|
try:
|
||||||
|
for i, schritt in enumerate(sl["schritte"]):
|
||||||
|
sl["aktuell"] = i
|
||||||
|
schritt["status"] = "laeuft"
|
||||||
|
_merken(sl)
|
||||||
|
_schritt(sl, schritt)
|
||||||
|
_merken(sl)
|
||||||
|
if schritt["status"] in ("zurueckgerollt", "fehler"):
|
||||||
|
_abbrechen(sl, i)
|
||||||
|
return
|
||||||
|
_abschliessen(sl)
|
||||||
|
except Exception as exc:
|
||||||
|
# Sonst hieße der Sammellauf bis zum nächsten Neustart „läuft“, und jeder einzelne Knopf wäre gesperrt.
|
||||||
|
log.exception("homelab: „Alle aktualisieren“ %s ist abgestürzt", sl.get("id"))
|
||||||
|
_beenden(sl, f"Interner Fehler: {exc.__class__.__name__}.",
|
||||||
|
f"„Alle aktualisieren“ ist mit einem internen Fehler stehen geblieben ({exc.__class__.__name__}).")
|
||||||
|
|
||||||
|
|
||||||
|
def _abschliessen(sl: dict) -> None:
|
||||||
|
eingespielt = [s for s in sl["schritte"] if s["status"] == "eingespielt"]
|
||||||
|
uebersprungen = [s for s in sl["schritte"] if s["status"] == "uebersprungen"]
|
||||||
|
anzahl = len(eingespielt)
|
||||||
|
if anzahl:
|
||||||
|
text = f"{anzahl} {'Update' if anzahl == 1 else 'Updates'} eingespielt, alle Prüfungen grün."
|
||||||
|
else:
|
||||||
|
text = "Kein Update eingespielt."
|
||||||
|
if uebersprungen:
|
||||||
|
text += " Nicht gelaufen: " + "; ".join(f"{s['name']} ({str(s.get('text') or 'abgelehnt').rstrip('.')})"
|
||||||
|
for s in uebersprungen) + "."
|
||||||
|
sl.update(status="fertig", ende=time.time(), aktuell=None, text=text, meldung=f"Homelab: {text}", dringend=False)
|
||||||
|
_merken(sl)
|
||||||
|
log.info("homelab: „Alle aktualisieren“ %s fertig: %s", sl["id"], text)
|
||||||
|
_melden(sl["meldung"])
|
||||||
|
|
||||||
|
|
||||||
|
def _beenden(sl: dict, text: str, anlass: str) -> None:
|
||||||
|
"""Abbrechen: offene Schritte „uebersprungen“, Status „abgebrochen“, dringende Meldung."""
|
||||||
|
rest = [s for s in sl["schritte"] if s["status"] in ("wartet", "laeuft")]
|
||||||
|
for s in rest:
|
||||||
|
s.update(status="uebersprungen", text="Nicht mehr gelaufen: „Alle aktualisieren“ wurde abgebrochen.")
|
||||||
|
eingespielt = [s["name"] for s in sl["schritte"] if s["status"] == "eingespielt"]
|
||||||
|
meldung = f"Homelab: {anlass}"
|
||||||
|
if eingespielt:
|
||||||
|
meldung += f" Vorher eingespielt: {_aufzaehlung(eingespielt)}."
|
||||||
|
if rest:
|
||||||
|
meldung += f" Nicht mehr gelaufen: {_aufzaehlung([s['name'] for s in rest])}."
|
||||||
|
sl.update(status="abgebrochen", ende=time.time(), aktuell=None, text=text, meldung=meldung, dringend=True)
|
||||||
|
_merken(sl)
|
||||||
|
log.warning("homelab: „Alle aktualisieren“ %s abgebrochen: %s", sl["id"], text)
|
||||||
|
_melden(meldung, dringend=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _abbrechen(sl: dict, i: int) -> None:
|
||||||
|
schritt = sl["schritte"][i]
|
||||||
|
wie = "zurückgerollt" if schritt["status"] == "zurueckgerollt" else "gescheitert"
|
||||||
|
_beenden(sl, f"Abgebrochen bei {schritt['name']}: Update {wie}.",
|
||||||
|
f"„Alle aktualisieren“ abgebrochen bei {schritt['name']} (Update {wie}).")
|
||||||
|
|
||||||
|
|
||||||
|
def unterbrochene_abschliessen() -> None:
|
||||||
|
"""Beim Start (über updates.unterbrochene_abschliessen): Ein Sammellauf, der noch „läuft“, gehörte zum vorigen
|
||||||
|
Prozess. Er gilt als abgebrochen; die Meldung geht nebenher raus, damit der Start nicht auf Telegram wartet."""
|
||||||
|
for sl in _alle():
|
||||||
|
if sl.get("status") != "laeuft":
|
||||||
|
continue
|
||||||
|
bei = None
|
||||||
|
for s in sl["schritte"]:
|
||||||
|
if s.get("status") != "laeuft":
|
||||||
|
continue
|
||||||
|
bei = s["name"]
|
||||||
|
# War sein Lauf noch fertig geworden, zählt dessen Ergebnis; sonst ist offen, was am Gerät geschah.
|
||||||
|
lauf = updates.lauf_lesen(s["lauf"]) if s.get("lauf") else None
|
||||||
|
if lauf is not None and lauf.get("status") == "fertig" and lauf.get("ergebnis") in STATUS:
|
||||||
|
s.update(status=STATUS[lauf["ergebnis"]], text=lauf.get("text"))
|
||||||
|
else:
|
||||||
|
s.update(status="fehler", text=UNTERBROCHEN)
|
||||||
|
rest = [s for s in sl["schritte"] if s.get("status") == "wartet"]
|
||||||
|
for s in rest:
|
||||||
|
s.update(status="uebersprungen", text="Nicht mehr gelaufen: „Alle aktualisieren“ wurde abgebrochen.")
|
||||||
|
eingespielt = [s["name"] for s in sl["schritte"] if s.get("status") == "eingespielt"]
|
||||||
|
meldung = ("Homelab: „Alle aktualisieren“ wurde unterbrochen: Der Homelab-Teil ist mitten im Lauf neu "
|
||||||
|
"gestartet" + (f" (bei {bei})" if bei else "") + ".")
|
||||||
|
if eingespielt:
|
||||||
|
meldung += f" Vorher eingespielt: {_aufzaehlung(eingespielt)}."
|
||||||
|
if rest:
|
||||||
|
meldung += f" Nicht mehr gelaufen: {_aufzaehlung([s['name'] for s in rest])}."
|
||||||
|
meldung += " Bitte den Stand in der Übersicht prüfen."
|
||||||
|
sl.update(status="abgebrochen", ende=time.time(), aktuell=None, text=UNTERBROCHEN, meldung=meldung,
|
||||||
|
dringend=True)
|
||||||
|
_merken(sl)
|
||||||
|
log.warning("homelab: „Alle aktualisieren“ %s war beim Start noch offen → abgebrochen", sl["id"])
|
||||||
|
threading.Thread(target=_melden, args=(meldung, True), name="homelab-alle-meldung", daemon=True).start()
|
||||||
@@ -15,6 +15,10 @@ Ablauf für einen Gast:
|
|||||||
Grün → ältere Snapshots bzw. Sicherungen des Orchestrators für diesen Gast weg → Meldung „eingespielt“.
|
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,31 +281,47 @@ 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)
|
||||||
|
if not still:
|
||||||
_melden(text, dringend=dringend)
|
_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."}
|
||||||
|
with START_SPERRE:
|
||||||
|
if sammellauf is None and _sammellauf_laeuft():
|
||||||
|
return {"ok": False, "detail": SAMMELLAUF_SPERRE}
|
||||||
if laeuft(ziel_id):
|
if laeuft(ziel_id):
|
||||||
return {"ok": False, "detail": "Für dieses Gerät läuft schon ein Update."}
|
return {"ok": False, "detail": "Für dieses Gerät läuft schon ein Update."}
|
||||||
if ziel_id == "pve":
|
if ziel_id == "pve":
|
||||||
name, gast = "Proxmox-Host", None
|
name, gast = "Proxmox-Host", None
|
||||||
|
host = ((kanal.bericht() or {}).get("bericht") or {}).get("host") or {}
|
||||||
|
mehr = {"pakete": len(host.get("updates") or [])} if baustein == "pakete" else {}
|
||||||
else:
|
else:
|
||||||
gast = inventar.gast(ziel_id)
|
gast = inventar.gast(ziel_id)
|
||||||
if not gast:
|
if not gast:
|
||||||
@@ -291,8 +331,9 @@ def starten(ziel_id: str, baustein: str) -> dict:
|
|||||||
basis = (apps.adresse(app, gast.get("ip")) or "").rstrip("/")
|
basis = (apps.adresse(app, gast.get("ip")) or "").rstrip("/")
|
||||||
if not (app and app.kennung == "arcane" and basis and arcane.schluessel()):
|
if not (app and app.kennung == "arcane" and basis and arcane.schluessel()):
|
||||||
return {"ok": False, "detail": "Docker-Updates gehen nur über Arcane mit API-Schlüssel."}
|
return {"ok": False, "detail": "Docker-Updates gehen nur über Arcane mit API-Schlüssel."}
|
||||||
lauf = _neuer_lauf(ziel_id, baustein, app.name)
|
lauf = _neuer_lauf(ziel_id, baustein, app.name, sammellauf)
|
||||||
threading.Thread(target=_docker_lauf, args=(lauf, basis), name=f"homelab-{ziel_id}", daemon=True).start()
|
threading.Thread(target=_docker_lauf, args=(lauf, basis), name=f"homelab-{ziel_id}",
|
||||||
|
daemon=True).start()
|
||||||
return {"ok": True, "lauf": lauf["id"]}
|
return {"ok": True, "lauf": lauf["id"]}
|
||||||
if not gast.get("erlaubt"):
|
if not gast.get("erlaubt"):
|
||||||
return {"ok": False, "detail": "Dieses Gerät ist nicht freigegeben (Etikett watcher fehlt)."}
|
return {"ok": False, "detail": "Dieses Gerät ist nicht freigegeben (Etikett watcher fehlt)."}
|
||||||
@@ -303,7 +344,10 @@ def starten(ziel_id: str, baustein: str) -> dict:
|
|||||||
"(eigene Oberfläche oder Pakete)."}
|
"(eigene Oberfläche oder Pakete)."}
|
||||||
if baustein == "app" and (sperre := karenz.pruefen(app.kennung)["gesperrt"]):
|
if baustein == "app" and (sperre := karenz.pruefen(app.kennung)["gesperrt"]):
|
||||||
return {"ok": False, "detail": sperre}
|
return {"ok": False, "detail": sperre}
|
||||||
lauf = _neuer_lauf(ziel_id, baustein, name)
|
# 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"]}
|
||||||
|
|||||||
@@ -628,8 +628,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]
|
||||||
|
|
||||||
|
|
||||||
@@ -700,7 +703,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}")
|
||||||
@@ -714,7 +717,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):
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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})
|
||||||
@@ -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
|
||||||
+44
-2
@@ -281,10 +281,52 @@ flowchart LR
|
|||||||
gerade berichtet, bevorzugt nachts 02:00–05:00 (war die Instanz letzte Nacht aus, eben gleich). Keine Meldungen;
|
gerade berichtet, bevorzugt nachts 02:00–05:00 (war die Instanz letzte Nacht aus, eben gleich). Keine Meldungen;
|
||||||
scheitert die Suche für einen Gast zweimal hintereinander, wird es ein gelber Hinweis des Wächters. Zustand in
|
scheitert die Suche für einen Gast zweimal hintereinander, wird es ein gelber Hinweis des Wächters. Zustand in
|
||||||
`/var/lib/mc2/homelab-pflege.json`. Weil damit zwei Prozesse Aufträge anlegen, schreibt `kanal.py` unter `flock`.
|
`/var/lib/mc2/homelab-pflege.json`. Weil damit zwei Prozesse Aufträge anlegen, schreibt `kanal.py` unter `flock`.
|
||||||
|
- **„Alle aktualisieren“** (`sammellauf.py`, seit 24.09.): alle Updates mit Knopf nacheinander, jeder Schritt ein
|
||||||
|
gewöhnlicher Lauf von „Jetzt updaten“ (`updates.starten`), auf dessen Ende der nächste wartet. Dabei ist nur, was
|
||||||
|
„neu“ ist und eine Aktion hat; was in der Wartezeit steht, fehlt. Docker über Arcane nur, wenn es echt läuft (ein
|
||||||
|
Probelauf ändert nichts). Reihenfolge: die Gäste nach VMID außer NPMplus und AdGuard, dann NPMplus, dann AdGuard
|
||||||
|
(Nadelöhre für Proxy und DNS), ganz zuletzt die Pakete des Proxmox-Hosts; der Neustart des Hosts ist nie dabei.
|
||||||
|
`GET /api/homelab/alle/plan` zeigt diese Reihenfolge mit dem Rückweg je Schritt und einem Hinweis (was keinen
|
||||||
|
Rückweg hat, dass der Host zuletzt kommt). `POST /api/homelab/alle` startet, wenn es etwas zu tun gibt, der
|
||||||
|
Ausführer verbunden ist (und nicht im Nur-Lesen-Modus) und gerade kein einzelnes Update läuft.
|
||||||
|
- Endet ein Schritt mit `zurueckgerollt` oder `fehler` (auch „Update nicht begonnen“), hört der Sammellauf auf: die
|
||||||
|
übrigen Schritte `uebersprungen`, Status `abgebrochen`, dringende Meldung. Lehnt `updates.starten` einen Schritt ab
|
||||||
|
(etwa weil die Wartezeit inzwischen greift), wird nur dieser übersprungen. Läuft alles durch, kommt eine
|
||||||
|
Sammelmeldung („Homelab: 5 Updates eingespielt, alle Prüfungen grün.“); die Erfolgsmeldungen der Schritte hält
|
||||||
|
`updates._ende` so lange zurück (`gemeldet: false` am Lauf), Fehlermeldungen nicht. Im Update-Verlauf stehen die
|
||||||
|
Schritte als ein Lauf mit dem Anlass „Alle aktualisieren“.
|
||||||
|
- Höchstens ein Sammellauf; solange er läuft, lehnt „Jetzt updaten“ ab („Es läuft gerade ‚Alle aktualisieren‘.“).
|
||||||
|
Prüfen und Anlegen geschehen unter `updates.START_SPERRE`. Stand in `/var/lib/mc2/homelab-sammellauf.json` (die
|
||||||
|
letzten 20; `GET /api/homelab/sammellauf` liefert den laufenden oder den letzten). Startet der Homelab-Teil mitten
|
||||||
|
im Lauf neu, macht `updates.unterbrochene_abschliessen()` beim Start daraus `abgebrochen` mit dem Text
|
||||||
|
„unterbrochen (Neustart)“ und meldet es dringend.
|
||||||
|
- **Protokoll** (`protokoll.py`, seit 24.09.): `GET /api/homelab/protokoll?grenze=200&berichte=0` führt zusammen, was
|
||||||
|
geschah, neueste zuerst: Aufträge an den Ausführer (`kanal.py` hebt jetzt die letzten 500 erledigten auf; die
|
||||||
|
jüngsten 60 mit ganzer Ausgabe, ältere mit den letzten 4000 Zeichen), Update-Läufe mit ihren Schritten,
|
||||||
|
Sammelläufe, den Verlauf der Hinweise des Wächters (der Verlauf in `mc2-waechter.json` trägt seit 24.09. die Stufe
|
||||||
|
mit), die Zeilen des Melde-Logs (`MC_NOTIFY_LOG`, im Container `/var/lib/mc2/notify.log`) und die Ereignisse des
|
||||||
|
wöchentlichen Suchens (`homelab-pflege.json`, Liste `ereignisse`). Berichte nur mit `berichte=1`: die Aufträge
|
||||||
|
`bericht` der Läufe und die Berichte, die der Ausführer von sich aus schickt (ihr Eingang steht in
|
||||||
|
`/var/lib/mc2/ausfuehrer-berichte.json`, die letzten 150). Den Betreff einer Meldung schreibt `notify.sh` nicht ins
|
||||||
|
Log; er folgt aus dem Absender (Lauf, Sammellauf, Wächter, Telegram-Test, Morgenmeldung). Nichts im Protokoll
|
||||||
|
enthält Geheimnisse: Die Schlüssel dieser Instanz (Arcane, Ausführer) und alles, was wie ein Token aussieht
|
||||||
|
(Bot-Token, Bearer, `name=wert` mit verräterischem Namen, Zugangsdaten in Adressen), wird zu `***`; Steuerzeichen der
|
||||||
|
Konsole fallen weg.
|
||||||
|
- **Einstellungen des Homelab-Teils** (`einstellungen.py`, seit 24.09.): `GET /api/homelab/einstellungen` (Arcane:
|
||||||
|
Adresse aus dem Bericht, Herkunft des Schlüssels `hinterlegt`/`fehlt`/`umgebung`, echt und woher; Wartezeit in
|
||||||
|
Stunden; Ausführer). `POST …/arcane-schluessel` prüft den Schlüssel zuerst bei Arcane mit denselben Aufrufen, die die
|
||||||
|
Übersicht braucht (`/api/environments`, dann deren Images); nur wenn Arcane ihn annimmt, landet er in
|
||||||
|
`/var/lib/mc2/arcane.key` (0600). Er steht nie in einer Antwort, einem Protokoll oder einer Fehlermeldung; den Body
|
||||||
|
liest der Dienst selbst, damit keine 422-Antwort ihn wiederholt. `DELETE …/arcane-schluessel` löscht die Datei,
|
||||||
|
`POST …/arcane-echt` (`{"an": bool}`) schaltet echte Docker-Updates in `/var/lib/mc2/homelab-einstellungen.json`.
|
||||||
|
Was nicht geht, beantworten diese Endpunkte wie „Alle aktualisieren“ mit `{"ok": false, "detail": …}` und HTTP 200.
|
||||||
- **Arcane und Docker** (`services/homelab/arcane.py`): Arcanes eigene Version kommt öffentlich über
|
- **Arcane und Docker** (`services/homelab/arcane.py`): Arcanes eigene Version kommt öffentlich über
|
||||||
`/api/app-version`; die Docker-Images mit neuerem Stand und der Updater brauchen einen Arcane-API-Schlüssel
|
`/api/app-version`; die Docker-Images mit neuerem Stand und der Updater brauchen einen Arcane-API-Schlüssel
|
||||||
(`MC_ARCANE_KEY`, Kopfzeile `X-API-Key`). Docker-Updates laufen zuerst nur als Probelauf (`dryRun`); echt erst
|
(Kopfzeile `X-API-Key`): `MC_ARCANE_KEY` in `/etc/mc2/homelab.env`, sonst der Schlüssel aus den Einstellungen
|
||||||
mit `MC_ARCANE_ECHT=1`. Die Arcane-VM braucht dafür kein Etikett, weil der Ausführer nicht beteiligt ist.
|
(`arcane.key`); beides wird bei jedem Zugriff gelesen, ein neuer Schlüssel gilt ohne Neustart. Docker-Updates laufen
|
||||||
|
zuerst nur als Probelauf (`dryRun`); echt, wenn der Schalter in den Einstellungen an ist. Setzt die Umgebung
|
||||||
|
`MC_ARCANE_ECHT`, gewinnt sie (`1` = echt). Die Arcane-VM braucht dafür kein Etikett, weil der Ausführer nicht
|
||||||
|
beteiligt ist.
|
||||||
- **Wächter** in der Rolle `homelab`: Platte, Partner (die KI-Box), Ausführer (kein Bericht seit 30 min = rot),
|
- **Wächter** in der Rolle `homelab`: Platte, Partner (die KI-Box), Ausführer (kein Bericht seit 30 min = rot),
|
||||||
jede Weboberfläche der freigegebenen Gäste (außer mitten in ihrem Update-Lauf; das Ergebnis meldet der Lauf), die
|
jede Weboberfläche der freigegebenen Gäste (außer mitten in ihrem Update-Lauf; das Ergebnis meldet der Lauf), die
|
||||||
Platten der laufenden Container (ab 80 % gelb, ab 90 % rot — ext4 hält 5 % für root zurück; der Ausführer schickt `platte` mit), das
|
Platten der laufenden Container (ab 80 % gelb, ab 90 % rot — ext4 hält 5 % für root zurück; der Ausführer schickt `platte` mit), das
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ sudo journalctl -u llama-swap -n 100 # Motor (System-Dienst)
|
|||||||
| `[Box-Problem]` | Wächter | neuer roter Hinweis; nach 6 h erneut mit „Immer noch:" |
|
| `[Box-Problem]` | Wächter | neuer roter Hinweis; nach 6 h erneut mit „Immer noch:" |
|
||||||
| `[Box wieder ok]` | Wächter | ein gemeldeter roter Hinweis ist erledigt |
|
| `[Box wieder ok]` | Wächter | ein gemeldeter roter Hinweis ist erledigt |
|
||||||
| `[Homelab-Problem]` / `[Homelab wieder ok]` | Wächter der Homelab-Instanz | wie oben, sobald diese Instanz läuft |
|
| `[Homelab-Problem]` / `[Homelab wieder ok]` | Wächter der Homelab-Instanz | wie oben, sobald diese Instanz läuft |
|
||||||
|
| `[Homelab-Update]` / `[Alarm] Homelab-Update` | „Jetzt updaten“ und „Alle aktualisieren“ im Homelab | Ergebnis je Lauf, dringend bei Rot; bei „Alle aktualisieren“ (seit 24.09.) nur Fehler einzeln, am Ende eine Sammelmeldung, dringend bei Abbruch |
|
||||||
| `[Modell-Radar]` | `backend/radar_lauf.py` | ein Kandidat hat den Nachttest bestanden |
|
| `[Modell-Radar]` | `backend/radar_lauf.py` | ein Kandidat hat den Nachttest bestanden |
|
||||||
| `[Sicherung]` | `backend/services/probe_wiederherstellung.py` | die monatliche Probe-Wiederherstellung war rot (normale Dringlichkeit) |
|
| `[Sicherung]` | `backend/services/probe_wiederherstellung.py` | die monatliche Probe-Wiederherstellung war rot (normale Dringlichkeit) |
|
||||||
| `[Stack-Radar]` | `jobs/stack-radar.sh` | Samstagsbericht |
|
| `[Stack-Radar]` | `jobs/stack-radar.sh` | Samstagsbericht |
|
||||||
@@ -253,6 +254,28 @@ laufen lassen. Achtung: `ausfuehrer-einrichten.sh` schreibt `/etc/mc2-ausfuehrer
|
|||||||
- **Wartezeit nach Skriptänderung:** Ist `ct/<app>.sh` bei community-scripts jünger als `MC_HOMELAB_KARENZ_H` Stunden
|
- **Wartezeit nach Skriptänderung:** Ist `ct/<app>.sh` bei community-scripts jünger als `MC_HOMELAB_KARENZ_H` Stunden
|
||||||
(Standard 48), zeigt die Karte „Update bereit“ ohne Knopf und nennt, ab wann es geht. Ändern oder abschalten (`0`):
|
(Standard 48), zeigt die Karte „Update bereit“ ohne Knopf und nennt, ab wann es geht. Ändern oder abschalten (`0`):
|
||||||
`MC_HOMELAB_KARENZ_H=…` in `/etc/mc2/homelab.env` im Container, dann `systemctl restart mc2-homelab`.
|
`MC_HOMELAB_KARENZ_H=…` in `/etc/mc2/homelab.env` im Container, dann `systemctl restart mc2-homelab`.
|
||||||
|
- **Arcane-Schlüssel (seit 24.09. über die Oberfläche):** in Arcane unter Einstellungen → API-Schlüssel anlegen, dann in
|
||||||
|
der Oberfläche unter Einstellungen (Karte Homelab) eintragen. Der Homelab-Teil fragt Arcane erst, ob der Schlüssel
|
||||||
|
gilt (Umgebungen und Images lesen); nur dann speichert er ihn in `/var/lib/mc2/arcane.key` (0600, Eigentümer `mc2`),
|
||||||
|
und die Übersicht sieht die Docker-Images ohne Neustart. Lehnt Arcane ab (etwa „HTTP 401“) oder antwortet nicht,
|
||||||
|
wird nichts gespeichert. Der Schlüssel erscheint danach nirgends mehr (Antwort, Protokoll, Journal). Löschen:
|
||||||
|
derselbe Knopf in der Oberfläche oder `rm /var/lib/mc2/arcane.key`. `MC_ARCANE_KEY` in `/etc/mc2/homelab.env` geht
|
||||||
|
weiter vor; solange es gesetzt ist, lehnt die Oberfläche das Eintragen ab und sagt warum.
|
||||||
|
- **Docker-Updates echt statt Probelauf:** Schalter in den Einstellungen, gespeichert in
|
||||||
|
`/var/lib/mc2/homelab-einstellungen.json` (`{"arcane": {"echt": true}}`). Setzt `/etc/mc2/homelab.env`
|
||||||
|
`MC_ARCANE_ECHT`, gewinnt die Umgebung (`1` = echt, sonst Probelauf), und der Schalter sagt es.
|
||||||
|
- **„Alle aktualisieren“ (seit 24.09.):** spielt alle Updates mit Knopf nacheinander ein — die Gäste nach VMID, dann
|
||||||
|
NPMplus, dann AdGuard, zuletzt die Pakete des Proxmox-Hosts (ohne Neustart). Ist ein Schritt rot (zurückgerollt oder
|
||||||
|
gescheitert), bleibt der Rest stehen, und es kommt eine dringende Meldung; sonst am Ende eine Sammelmeldung.
|
||||||
|
Solange er läuft, lehnen die einzelnen Knöpfe „Jetzt updaten“ ab. Stand:
|
||||||
|
`curl -s http://192.168.178.31:9001/api/homelab/sammellauf` bzw. `/var/lib/mc2/homelab-sammellauf.json`. Startet
|
||||||
|
`mc2-homelab` mitten im Lauf neu (auch durch einen Deploy), gilt er als abgebrochen („unterbrochen (Neustart)“, mit
|
||||||
|
dringender Meldung); der Schritt, der gerade lief, steht auf „fehler“ — den Stand des Geräts in der Übersicht prüfen.
|
||||||
|
- **Protokoll (seit 24.09.):** `curl -s 'http://192.168.178.31:9001/api/homelab/protokoll?grenze=50' | jq` zeigt
|
||||||
|
Aufträge, Läufe, Sammelläufe, Hinweise, Meldungen und das wöchentliche Suchen, neueste zuerst; `&berichte=1` auch die
|
||||||
|
Berichte des Ausführers. Die Quellen bleiben die Dateien in `/var/lib/mc2` (`ausfuehrer-auftraege.json` hebt jetzt
|
||||||
|
die letzten 500 erledigten Aufträge auf, `ausfuehrer-berichte.json` die Eingänge der letzten 150 Berichte) und das
|
||||||
|
Melde-Log `/var/lib/mc2/notify.log`.
|
||||||
- **Wöchentliches Suchen:** Der Steward legt für jeden freigegebenen, laufenden Container, dessen Paketlisten älter als
|
- **Wöchentliches Suchen:** Der Steward legt für jeden freigegebenen, laufenden Container, dessen Paketlisten älter als
|
||||||
7 Tage sind, den Auftrag `suchen` an (nur `apt-get update` bzw. `apk update` im Gast, es wird nichts installiert):
|
7 Tage sind, den Auftrag `suchen` an (nur `apt-get update` bzw. `apk update` im Gast, es wird nichts installiert):
|
||||||
nachts 02:00–05:00, höchstens einmal je Gast und Tag, nicht während eines Updates; war die Instanz nachts aus, gleich
|
nachts 02:00–05:00, höchstens einmal je Gast und Tag, nicht während eines Updates; war die Instanz nachts aus, gleich
|
||||||
|
|||||||
@@ -31,9 +31,10 @@ Technik: [ARCHITEKTUR.md](../ARCHITEKTUR.md), Abschnitt „Der Homelab-Teil“;
|
|||||||
Proxmox-Host gelegt (`/root/mc2-archiv/adguard-querylog-bis-2026-08-09.json.zst`, 44 MB) und aus dem Container
|
Proxmox-Host gelegt (`/root/mc2-archiv/adguard-querylog-bis-2026-08-09.json.zst`, 44 MB) und aus dem Container
|
||||||
genommen; jetzt 71 % belegt. Damit es nicht wieder vollläuft: in AdGuard unter Einstellungen → Allgemein →
|
genommen; jetzt 71 % belegt. Damit es nicht wieder vollläuft: in AdGuard unter Einstellungen → Allgemein →
|
||||||
Abfrageprotokoll die Aufbewahrung z. B. auf 30 Tage stellen. Volle Gastplatten meldet seit 24.09. der Wächter.
|
Abfrageprotokoll die Aufbewahrung z. B. auf 30 Tage stellen. Volle Gastplatten meldet seit 24.09. der Wächter.
|
||||||
1. **Arcane-API-Schlüssel fehlt.** Ohne ihn zeigt die Arcane-Karte die Docker-Images als „unklar“. Anlegen in Arcane
|
1. **Arcane-API-Schlüssel fehlt (User).** Ohne ihn zeigt die Arcane-Karte die Docker-Images als „unklar“. Seit 24.09.
|
||||||
(Einstellungen → API-Schlüssel), dann `MC_ARCANE_KEY=…` in `/etc/mc2/homelab.env` im Container 107 und
|
in der Oberfläche eintragbar: in Arcane unter Einstellungen → API-Schlüssel anlegen, dann in der Oberfläche unter
|
||||||
`systemctl restart mc2-homelab mc2-homelab-steward`. Docker-Updates bleiben Probelauf bis `MC_ARCANE_ECHT=1`.
|
Einstellungen (Karte Homelab) einfügen. Arcane prüft ihn vor dem Speichern; ein Neustart ist nicht nötig.
|
||||||
|
Docker-Updates bleiben Probelauf, bis der Schalter „Docker-Updates echt“ dort an ist.
|
||||||
2. **Feste IP für Container 107.** Er hat seine Adresse per DHCP (192.168.178.31); Partner-Adresse der Box,
|
2. **Feste IP für Container 107.** Er hat seine Adresse per DHCP (192.168.178.31); Partner-Adresse der Box,
|
||||||
Ausführer-Konfiguration und der Deploy-Schritt 8 hängen daran. In der Fritzbox die Adresse fest zuordnen.
|
Ausführer-Konfiguration und der Deploy-Schritt 8 hängen daran. In der Fritzbox die Adresse fest zuordnen.
|
||||||
3. **Die Arcane-VM (106) trägt kein Etikett.** Für Arcane und Docker braucht es keins (eigene Schnittstelle); für
|
3. **Die Arcane-VM (106) trägt kein Etikett.** Für Arcane und Docker braucht es keins (eigene Schnittstelle); für
|
||||||
|
|||||||
Reference in New Issue
Block a user