Welle 1 · Sicherungs-Abdeckung: Gäste ohne Auftrag, Prüfung durch den PBS, Probe-Wiederherstellung je Gerät

Der Ausführer meldet jetzt, welche Gäste kein Sicherungsauftrag erfasst (wie Proxmox unter Rechenzentrum → Backup),
je Gast die jüngste Prüfung durch den PBS samt beschädigter Sicherungen, die Schätzung des PBS, wann sein Datastore
voll ist, und öffnet einmal die Woche die jüngste Sicherung jedes Gasts probeweise (nur lesend: Konfiguration, bei
Containern der Dateibaum). Der Wächter meldet: ohne Auftrag gelb (sofort), beschädigt rot, seit 15 Tagen nicht
geprüft gelb, Probe gescheitert gelb, Sicherungsziel laut PBS bald voll. Die Speicherkarte zeigt „Sicherungen je
Gerät“ mit Knopf „Jetzt prüfen“. Der Ausführer braucht ausfuehrer-einrichten.sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-25 16:37:21 +02:00
co-authored by Claude Opus 5.5
parent f7a9b8ad50
commit afa3a476b5
13 changed files with 765 additions and 17 deletions
+3 -1
View File
@@ -236,7 +236,9 @@ def test_sicherungen_frisch_fehlend_und_ziel_weg():
b["host"]["sicherung_jobs"][0]["ausgenommen"] = [102, 104, 106]
assert speicher.sicherungen(b, JETZT)["erfasst"] == 0
b["host"]["sicherung_jobs"] = []
assert speicher.sicherungen(b, JETZT)["stufe"] == "keine"
s = speicher.sicherungen(b, JETZT)
# Seit 25.09.2026: Ohne Auftrag sichert Proxmox niemanden — das ist eine Lücke, kein neutrales „kein Auftrag“.
assert (s["stufe_frische"], s["stufe"], len(s["ohne_auftrag"])) == ("keine", "gelb", 3)
def test_waechter_befunde(monkeypatch):
+219
View File
@@ -0,0 +1,219 @@
"""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],
"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"}]}
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
# 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"] # 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()