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

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