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