Files
mission-control-v2/backend/tests/test_sicherungs_abdeckung.py
T
HitonabiandClaude Opus 5.5 f2bffc7203 Welle 1 · Nachschliff: Proxmox-Kachel zählt die Gäste wie die Homelab-Karte, der eigene Container heißt lesbar
„8 von 8 Gästen an“ neben „7 Gäste“: Die Kachel zählte den eigenen Container (Etikett mc2) mit, die Karte nicht.
In „Sicherungen je Gerät“ heißt er „Homelab Orchestrator (dieser Container)“ statt homelab-orchestrator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 17:16:22 +02:00

222 lines
13 KiB
Python

"""Sicherungs-Abdeckung (Ausbauplan Welle 1, 25.09.2026): Gäste ohne Auftrag, die Prüfung durch den PBS, seine
Schätzung, wann er voll ist, und die Probe-Wiederherstellung — im Ausführer (reine Auswertungen mit echten Zeilen vom
Proxmox-PC, gelesen am 25.09.) und im Homelab-Teil (Karte, Hinweise, Knopf)."""
import importlib.util
import json
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from services.homelab import kanal, speicher
AUSFUEHRER = Path(__file__).resolve().parents[2] / "deploy" / "homelab" / "ausfuehrer.py"
JETZT = 1_790_340_000.0 # 25.09.2026 ~14:40
UPID_PBS = "UPID:proxmox-backup-server:0000620D:02F88CBE:00000126:6AAF5A40:verificationjob:qnap\\x3avj\\x2dqnap:root@pam:"
GEPRUEFT = 0x6AAF5A40 # 20.09.2026, der wöchentliche Prüfauftrag „vj-qnap“ (So 06:00)
@pytest.fixture(scope="module")
def a():
spec = importlib.util.spec_from_file_location("ausfuehrer_abdeckung", AUSFUEHRER)
modul = importlib.util.module_from_spec(spec)
spec.loader.exec_module(modul)
return modul
# Echte Einträge von pvesh get /nodes/pve/storage/pbs-qnap/content --content backup (gekürzt)
INHALT = [
{"vmid": 102, "ctime": 1789864223, "format": "pbs-ct", "size": 843_000_000, "volid": "pbs-qnap:backup/ct/102/2026-09-20T00:30:23Z",
"verification": {"state": "ok", "upid": UPID_PBS}},
{"vmid": 102, "ctime": 1790296227, "format": "pbs-ct", "size": 843_394_679, "volid": "pbs-qnap:backup/ct/102/2026-09-25T00:30:27Z"},
{"vmid": 106, "ctime": 1789864278, "format": "pbs-vm", "size": 161_061_274_375, "volid": "pbs-qnap:backup/vm/106/2026-09-20T00:31:18Z",
"verification": {"state": "failed", "upid": UPID_PBS}},
{"vmid": 106, "ctime": 1790296281, "format": "pbs-vm", "size": 161_061_274_375, "volid": "pbs-qnap:backup/vm/106/2026-09-25T00:31:21Z"},
{"vmid": "kaputt", "ctime": 1},
]
def test_pruefzeit_aus_der_upid(a):
assert a.pruefzeit(UPID_PBS) == GEPRUEFT
assert a.pruefzeit("UPID:pve:0000A1B2:0012C3D4:6AB5E4DC:vzdump::root@pam:") == 0x6AB5E4DC # Proxmox VE
assert a.pruefzeit("kaputt") is None and a.pruefzeit(None) is None
def test_details_je_gast(a):
d = a.sicherungs_details(INHALT)
assert set(d) == {"102", "106"}
assert d["102"] == {"zeit": 1790296227, "aelteste": 1789864223, "volid": "pbs-qnap:backup/ct/102/2026-09-25T00:30:27Z",
"format": "pbs-ct", "groesse": 843_394_679,
"geprueft": {"zustand": "ok", "zeit": GEPRUEFT, "sicherung": 1789864223}, "defekt": []}
assert d["106"]["geprueft"]["zustand"] == "failed" and d["106"]["defekt"] == [1789864278]
def test_schaetzung_des_pbs(a):
nutzung = [{"store": "qnap", "total": 3847503609856, "used": 1517685571584, "estimated-full-date": 1887231717},
{"store": "anderer", "estimated-full-date": 5}]
assert a.pbs_schaetzung(nutzung, "qnap") == {"voll_am": 1887231717, "belegt": 1517685571584, "gesamt": 3847503609856}
assert a.pbs_schaetzung([{"store": "qnap", "estimated-full-date": 0}], "qnap")["voll_am"] is None # wächst nicht
assert a.pbs_schaetzung([{"store": "qnap", "error": "nicht erreichbar"}], "qnap") is None
assert a.pbs_schaetzung("kaputt", "qnap") is None
def test_probe_oeffnet_nur_lesend(a, monkeypatch):
befehle: list[list[str]] = []
antworten = {"extractconfig": (0, "#Kommentar\narch: amd64\ncores: 2\n"),
"file-restore/list": (0, json.dumps([{"text": "bin"}, {"text": "etc"}]))}
def laufen(befehl, zeitlimit=60, sanft=False):
befehle.append(befehl)
return next(v for k, v in antworten.items() if any(k in teil for teil in befehl))
monkeypatch.setattr(a, "_laufen", laufen)
monkeypatch.setattr(a, "NODE", "pve")
assert a.probe_oeffnen("pbs-qnap:backup/ct/102/2026-09-25T00:30:27Z", "pbs-ct") is None
assert befehle[0] == ["pvesm", "extractconfig", "pbs-qnap:backup/ct/102/2026-09-25T00:30:27Z"]
assert befehle[1][:3] == ["pvesh", "get", "/nodes/pve/storage/pbs-qnap/file-restore/list"]
assert "L3Jvb3QucHhhci5kaWR4" in befehle[1] # „/root.pxar.didx“
assert a.probe_oeffnen("pbs-qnap:backup/vm/106/2026-09-25T00:31:21Z", "pbs-vm") is None and len(befehle) == 3
antworten["file-restore/list"] = (0, json.dumps([{"text": "bin"}]))
assert a.probe_oeffnen("x:backup/ct/1", "pbs-ct").startswith("Die Dateien der Sicherung ließen sich nicht auflisten")
antworten["extractconfig"] = (255, "Error: unable to open archive\n")
assert a.probe_oeffnen("x:backup/ct/1", "pbs-ct") == (
"Die Konfiguration ließ sich nicht aus der Sicherung lesen: Error: unable to open archive")
def test_probe_nimmt_die_juengste_sicherung_je_gast(a):
details = a.sicherungs_details(INHALT)
ziele = {"pbs-qnap": {"aktiv": True, "details": details},
"weg": {"aktiv": False, "details": {"102": {**details["102"], "zeit": 1_800_000_000, "volid": "weg:x"}}}}
geoeffnet: list[str] = []
stand = a.sicherungs_probe(ziele, oeffnen=lambda volid, art: geoeffnet.append(volid) or (
None if art == "pbs-ct" else "Die Konfiguration ließ sich nicht aus der Sicherung lesen"))
assert geoeffnet == [details["102"]["volid"], details["106"]["volid"]] # das unerreichbare Ziel nicht
assert stand["gaeste"]["102"] == {"speicher": "pbs-qnap", "sicherung": 1790296227, "ok": True, "fehler": None}
assert stand["gaeste"]["106"]["ok"] is False
def test_jetzt_pruefen_auch_im_nur_lesen_modus(a, monkeypatch):
monkeypatch.setattr(a, "NUR_LESEN", True)
monkeypatch.setattr(a, "bericht", lambda: {"host": {"sicherung_probe": {"zeit": 1, "gaeste": {
"102": {"speicher": "pbs-qnap", "sicherung": 1, "ok": True, "fehler": None}}}}})
antwort = a.ausfuehren({"aktion": "sicherung_probe", "parameter": {}})
assert antwort["code"] == 0 and json.loads(antwort["text"])["gaeste"]["102"]["ok"] is True
assert a._probe["naechste"] == 0.0 # der Bericht rechnete die Probe sofort (hier nachgestellt)
# --- Homelab-Teil ------------------------------------------------------------------------------------------------
def _bericht(**host) -> dict:
"""Wie der echte Bericht vom 25.09.2026, drei Gäste; die Sicherungen kommen aus INHALT über den Ausführer."""
spec = importlib.util.spec_from_file_location("ausfuehrer_abdeckung_b", AUSFUEHRER)
modul = importlib.util.module_from_spec(spec)
spec.loader.exec_module(modul)
details = modul.sicherungs_details(INHALT)
basis = {
"sicherung_jobs": [{"id": "j1", "zeitplan": "02:30", "speicher": "pbs-qnap", "an": True, "alle": False,
"vmids": [102, 106], "ausgenommen": []}],
"sicherungsziele": {"pbs-qnap": {"art": "pbs", "aktiv": True, "belegt": 1_517_685_571_584,
"gesamt": 3_847_503_609_856, "details": details,
"neueste": {k: v["zeit"] for k, v in details.items()},
"schaetzung": {"voll_am": 1887231717, "belegt": 1_517_685_571_584,
"gesamt": 3_847_503_609_856}}},
"nicht_gesichert": [104], # 107 (der eigene) ist gesichert
"sicherung_probe": {"zeit": 1790330000, "gaeste": {
"102": {"speicher": "pbs-qnap", "sicherung": 1790296227, "ok": True, "fehler": None},
"106": {"speicher": "pbs-qnap", "sicherung": 1790296281, "ok": False,
"fehler": "Die Konfiguration ließ sich nicht aus der Sicherung lesen: Error: timeout"}}},
}
return {"host": {**basis, **host}, "gaeste": [
{"vmid": 102, "art": "lxc", "name": "netbird", "app": {"kennung": "netbird"}},
{"vmid": 104, "art": "lxc", "name": "gitea", "app": {"kennung": "gitea"}},
{"vmid": 106, "art": "qemu", "name": "arcane"},
{"vmid": 107, "art": "lxc", "name": "homelab-orchestrator", "etiketten": ["mc2"]}]}
def test_abdeckung_je_geraet():
s = speicher.sicherungen(_bericht(), JETZT)
zeilen = {g["ziel"]: g for g in s["geraete"]}
assert [g["ziel"] for g in s["ohne_auftrag"]] == ["ct-104"]
assert zeilen["ct-104"]["auftrag"] is False and zeilen["ct-104"]["zuletzt"] is None
assert zeilen["ct-102"] == {"ziel": "ct-102", "name": zeilen["ct-102"]["name"], "vmid": 102, "auftrag": True,
"zuletzt": 1790296227, "frisch": True,
"geprueft": {"zustand": "ok", "zeit": GEPRUEFT, "sicherung": 1789864223}, "defekt": [],
"probe": {"speicher": "pbs-qnap", "sicherung": 1790296227, "ok": True, "fehler": None,
"zeit": 1790330000}}
assert zeilen["vm-106"]["defekt"] == [1789864278] and zeilen["vm-106"]["probe"]["ok"] is False
assert zeilen["ct-107"]["name"] == "Homelab Orchestrator (dieser Container)"
# Die letzte Prüfung von Arcane fand Beschädigtes: rot, auch wenn alles frisch gesichert ist.
assert (s["stufe_frische"], s["stufe"]) == ("ok", "rot")
assert s["pruefung"] == {"zuletzt": GEPRUEFT, "noetig": False} # älteste Sicherung erst 5,5 Tage alt
assert s["probe"] == {"zeit": 1790330000}
def test_hinweise_zur_abdeckung(monkeypatch):
monkeypatch.setattr(speicher, "antwortet", lambda s, p: True)
monkeypatch.setattr(speicher, "_bericht", lambda: _bericht())
befunde = {b.id: b for b in speicher.pruefe_speicher(JETZT)}
assert "sicherungen" not in befunde # frisch ist alles
ohne = befunde["sicherung:ohne-auftrag"]
assert (ohne.stufe, ohne.sofort) == ("gelb", True) and ohne.titel == "Gitea ist in keinem Sicherungsauftrag"
defekt = befunde["sicherung:defekt"]
assert defekt.stufe == "rot" and "Arcane (Docker) (Sicherung vom 20.09.)" in defekt.text
probe = befunde["sicherung:probe"]
assert probe.stufe == "gelb" and probe.titel == "Probe-Wiederherstellung gescheitert: Arcane (Docker)"
assert "sicherung:pruefung" not in befunde and "ziel:pbs-qnap" not in befunde
# Drei Wochen später ohne neue Prüfung, und der PBS schätzt: in zehn Tagen voll.
spaeter = JETZT + 21 * 86400
b = _bericht()
b["host"]["sicherungsziele"]["pbs-qnap"]["schaetzung"]["voll_am"] = spaeter + 10 * 86400
monkeypatch.setattr(speicher, "_bericht", lambda: b)
befunde = {x.id: x for x in speicher.pruefe_speicher(spaeter)}
assert befunde["sicherung:pruefung"].titel == "Der PBS hat seit 26 Tagen keine Sicherung geprüft"
ziel = befunde["ziel:pbs-qnap"]
assert (ziel.stufe, ziel.titel) == ("gelb", "Sicherungsziel pbs-qnap in etwa 10 Tagen voll")
def test_schaetzung_des_pbs_ist_die_vorhersage_des_ziels(monkeypatch):
monkeypatch.setattr(speicher, "antwortet", lambda s, p: True)
monkeypatch.setattr(speicher, "_bericht", lambda: _bericht())
(ziel,) = speicher.lage(JETZT)["sicherungen"]["ziele"]
v = ziel["vorhersage"]
assert v["quelle"] == "pbs" and v["tage"] == pytest.approx((1887231717 - JETZT) / 86400, abs=0.1)
assert v["anteil"] == pytest.approx(39.45, abs=0.01) and v["grund"] is None
b = _bericht()
b["host"]["sicherungsziele"]["pbs-qnap"]["schaetzung"]["voll_am"] = None
monkeypatch.setattr(speicher, "_bericht", lambda: b)
assert speicher.lage(JETZT)["sicherungen"]["ziele"][0]["vorhersage"]["grund"] == "gleich"
def test_aelterer_ausfuehrer_ohne_neue_angaben():
"""Bis ausfuehrer-einrichten.sh gelaufen ist, fehlen nicht_gesichert, details und die Probe."""
b = _bericht()
for schluessel in ("nicht_gesichert", "sicherung_probe"):
b["host"].pop(schluessel)
b["host"]["sicherungsziele"]["pbs-qnap"].pop("details")
s = speicher.sicherungen(b, JETZT)
assert [g["ziel"] for g in s["ohne_auftrag"]] == ["ct-104", "ct-107"] # selbst ausgerechnet aus den Aufträgen
assert s["stufe"] == "gelb" and s["probe"] is None
assert all(g["geprueft"] is None and g["probe"] is None for g in s["geraete"])
def test_knopf_jetzt_pruefen(tmp_path, monkeypatch):
from kern import einstellungen as einstellungen_mod
from routers import homelab
monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path))
einstellungen_mod.einstellungen.cache_clear()
try:
app = FastAPI()
app.include_router(homelab.router)
client = TestClient(app)
monkeypatch.setattr(kanal, "_kontakt", {"zuletzt": None})
antwort = client.post("/api/homelab/sicherungen/probe")
assert antwort.status_code == 409 and "meldet sich gerade nicht" in antwort.json()["detail"]
kanal.kontakt()
assert client.post("/api/homelab/sicherungen/probe").json()["ok"] is True
assert [a["aktion"] for a in kanal.liste()] == ["sicherung_probe"]
assert client.post("/api/homelab/sicherungen/probe").json()["detail"] == "Die Probe läuft schon."
finally:
einstellungen_mod.einstellungen.cache_clear()