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