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
+8
View File
@@ -10,6 +10,7 @@
GET /api/homelab/laeufe Die letzten Läufe (Updates, Snapshot-Aktionen) mit ihren Schritten
GET /api/homelab/snapshots Snapshots und Sicherungen je Gerät (Aktionen: siehe unten)
POST /api/homelab/snapshots/rueckwege-loeschen Alle Rückwege von Updates löschen (nur per Klick)
POST /api/homelab/sicherungen/probe Probe-Wiederherstellung jetzt (nur lesend, sonst einmal die Woche)
GET /api/homelab/hinweise Hinweise des Wächters dieser Instanz
GET /api/homelab/ausfuehrer Lebenszeichen des Ausführers
GET /api/homelab/ausfuehrer/auftrag ┐
@@ -155,6 +156,13 @@ def speicher_lage() -> dict:
return speicher.lage()
@router.post("/sicherungen/probe")
def sicherungen_probe() -> dict:
"""Knopf „Jetzt prüfen“: die jüngste Sicherung jedes Geräts probeweise öffnen (seit 25.09.2026)."""
from services.homelab import speicher
return _antwort(speicher.probe_starten())
# --- Snapshots und Sicherungen verwalten (Seite Homelab → Snapshots, seit 25.09.2026) ------------------------------
# GET /api/homelab/snapshots je Gerät Snapshots, Sicherungen, ob gerade etwas läuft
# POST /api/homelab/snapshots/{id}/anlegen Snapshot jetzt anlegen (freigegeben, Snapshot möglich)
+1 -1
View File
@@ -34,7 +34,7 @@ except ImportError: # Windows (Entwicklung): nur die Thread-Sperre
AKTIONEN = {"bericht", "snapshot", "update", "os_update", "suchen", "zurueck", "snapshot_loeschen",
"host_update", "host_neustart", "sichern", "sicherung_zurueck", "sicherung_loeschen",
"kernel_aufraeumen"}
"kernel_aufraeumen", "sicherung_probe"}
VERLOREN_S = 3 * 3600 # abgeholt, aber nie beantwortet (Ausführer abgestürzt, Host neu gestartet)
BEHALTEN = 500 # so viele erledigte Aufträge bleiben sichtbar (Protokoll; bis 24.09.2026: 60)
VOLLTEXT = 60 # die jüngsten erledigten behalten ihre ganze Ausgabe …
+3 -1
View File
@@ -67,8 +67,10 @@ _AKTION = {
"host_neustart": ("Neustart ausgelöst", "Neustart gescheitert", "Neustart angefordert", "Neustart"),
"kernel_aufraeumen": ("Alte Kernel entfernt", "Kernel aufräumen gescheitert", "Alte Kernel werden entfernt",
"Kernel aufräumen"),
"sicherung_probe": ("Probe-Wiederherstellung gelungen", "Probe-Wiederherstellung gescheitert",
"Probe-Wiederherstellung läuft", "Probe-Wiederherstellung"),
}
_HOST_AKTIONEN = {"bericht", "host_update", "host_neustart", "kernel_aufraeumen"}
_HOST_AKTIONEN = {"bericht", "host_update", "host_neustart", "kernel_aufraeumen", "sicherung_probe"}
_LAUF_WAS = {"app": "Update", "os": "Paket-Update", "pakete": "Paket-Update", "docker": "Docker-Update",
"neustart": "Neustart", "kernel": "Kernel-Aufräumen", "probe": "Rückweg-Probe"}
_SCHRITT_WORT = {"wartet": "wartet", "laeuft": "läuft", "eingespielt": "eingespielt",
+147 -7
View File
@@ -9,6 +9,11 @@ Alles aus dem Bericht des Ausführers (alle 10 Minuten), dazu eine eigene Prüfu
ist eingebunden, hängt nicht, hat Platz (80/90 % wie oben).
• Sicherungen: Für jeden Gast, den ein Sicherungsauftrag erfasst, liegt auf dessen Ziel eine frische Sicherung
(täglicher Auftrag: höchstens 26 Stunden alt, sonst 8 Tage), und das Ziel ist erreichbar.
• Abdeckung (seit 25.09.2026): Gäste ohne Sicherungsauftrag (wie Proxmox sie unter Rechenzentrum → Backup meldet)
sofort gelb; je Gerät die jüngste Prüfung durch den PBS (beschädigt = rot) und die Probe-Wiederherstellung des
Ausführers (jede Woche die jüngste Sicherung probeweise öffnen; gescheitert = gelb). Prüft der PBS seit über
15 Tagen nichts mehr, obwohl es ältere Sicherungen gibt: gelb. Beim Sicherungsziel zählt die Schätzung des PBS,
wann es voll ist.
• Systemplatte des Proxmox-Hosts (Stufen wie oben): Dort liegen die Kernel, die Sicherungen vor Updates (local) und
alles vom Host selbst. Alte Kernel lassen sich per Knopf entfernen (Ausführer: kernel_aufraeumen).
• Tempo (seit 25.09.2026, kern/prognose.py): Läuft etwas in weniger als 14 Tagen voll, ist es gelb, unter 3 Tagen
@@ -28,7 +33,7 @@ from kern import messreihen, prognose
from kern.zeit import LOCAL_TZ
from services.homelab import apps, kanal
from services.homelab.inventar import EIGENES_ETIKETT
from services.homelab.inventar import BERICHT_ALT_S, EIGENES_ETIKETT
log = logging.getLogger(__name__)
@@ -321,10 +326,87 @@ def sicherungen(bericht: dict, jetzt: float) -> dict:
stufe = "rot" if len(fehlend) == erfasst else "gelb"
if probleme:
stufe = "rot"
frische = stufe
ohne = _nicht_erfasst(bericht)
probe = host.get("sicherung_probe") if isinstance(host.get("sicherung_probe"), dict) else None
geraete = _je_geraet(bericht, ziele, {f["ziel"] for f in fehlend}, ohne, probe)
pbs = [d for z in ziele.values() if z.get("art") == "pbs" for d in (z.get("details") or {}).values()]
aelteste = min((d["aelteste"] for d in pbs if d.get("aelteste")), default=None)
pruefung = {"zuletzt": max((g["geprueft"]["zeit"] for g in geraete if g["geprueft"]), default=None),
"noetig": bool(aelteste and jetzt - aelteste >= PRUEFUNG_AB_S)}
if stufe != "rot" and (any(g["defekt"] and g["geprueft"] and g["geprueft"]["zustand"] == "failed" for g in geraete)):
stufe = "rot"
elif stufe in ("ok", "keine") and (ohne & {g["vmid"] for g in bericht.get("gaeste") or []}
or any(g["probe"] and not g["probe"]["ok"] for g in geraete)):
stufe = "gelb"
return {"jobs": [{"zeitplan": j.get("zeitplan"), "speicher": j.get("speicher"), "alle": j.get("alle")} for j in jobs],
"ziele": [{"speicher": n, "art": z.get("art"), "aktiv": z.get("aktiv"), "belegt": z.get("belegt"),
"gesamt": z.get("gesamt")} for n, z in sorted(ziele.items())],
"erfasst": erfasst, "fehlend": fehlend, "zuletzt": zuletzt, "probleme": probleme, "stufe": stufe}
"gesamt": z.get("gesamt"), "schaetzung": z.get("schaetzung")} for n, z in sorted(ziele.items())],
"erfasst": erfasst, "fehlend": fehlend, "zuletzt": zuletzt, "probleme": probleme, "stufe": stufe,
"stufe_frische": frische, "ohne_auftrag": [{"ziel": g["ziel"], "name": g["name"]} for g in geraete if not g["auftrag"]],
"geraete": geraete, "pruefung": pruefung, "probe": {"zeit": probe.get("zeit")} if probe else None}
# --- Abdeckung je Gerät (seit 25.09.2026) --------------------------------------------------------------------------
PRUEFUNG_AB_S = 8 * 86400 # gibt es Sicherungen, die so alt sind, hätte ein wöchentlicher Prüfauftrag sie gesehen …
PRUEFUNG_ALT_S = 15 * 86400 # … und so lange darf die jüngste Prüfung dann höchstens zurückliegen
def _nicht_erfasst(bericht: dict) -> set[int]:
"""Gäste ohne Sicherungsauftrag, wie Proxmox sie meldet (Rechenzentrum → Backup; im Bericht seit 25.09.2026). Ein
älterer Ausführer liefert das nicht: dann selbst ausgerechnet, ausgeschaltete Aufträge zählen mit."""
host = bericht.get("host") or {}
if isinstance(host.get("nicht_gesichert"), list):
return {int(v) for v in host["nicht_gesichert"] if str(v).isdigit()}
if not isinstance(host.get("sicherung_jobs"), list):
return set()
jobs = host["sicherung_jobs"]
return {g["vmid"] for g in bericht.get("gaeste") or [] if not any(_erfasst(j, g["vmid"]) for j in jobs)}
def _je_geraet(bericht: dict, ziele: dict, fehlend: set[str], ohne: set[int], probe: dict | None) -> list[dict]:
"""Je Gerät: ob ein Auftrag es erfasst, die jüngste Sicherung (über alle Ziele), ob sie frisch ist, die jüngste
Prüfung durch den PBS, beschädigte Sicherungen und das Ergebnis der Probe-Wiederherstellung."""
zeilen = []
for g in sorted(bericht.get("gaeste") or [], key=lambda g: g["vmid"]):
vmid = str(g["vmid"])
details = [d for z in ziele.values() if (d := (z.get("details") or {}).get(vmid))]
zuletzt = max([t for z in ziele.values() if (t := (z.get("neueste") or {}).get(vmid))], default=None)
pruefungen = [d["geprueft"] for d in details if d.get("geprueft")]
eigene_probe = ((probe or {}).get("gaeste") or {}).get(vmid)
zeilen.append({
"ziel": _zid(g), "name": _name(g), "vmid": g["vmid"], "auftrag": g["vmid"] not in ohne,
"zuletzt": zuletzt, "frisch": bool(zuletzt) and g["vmid"] not in ohne and _zid(g) not in fehlend,
"geprueft": max(pruefungen, key=lambda p: (p["zeit"], p["sicherung"]), default=None),
"defekt": sorted({t for d in details for t in d.get("defekt") or []}),
"probe": {**eigene_probe, "zeit": probe.get("zeit")} if eigene_probe else None})
return zeilen
def probe_starten() -> dict:
"""Knopf „Jetzt prüfen“: die Probe-Wiederherstellung als Auftrag an den Ausführer (nur lesend). Das Ergebnis
bringt der Bericht gleich danach."""
zuletzt = kanal.zuletzt()
if not zuletzt or time.time() - zuletzt > BERICHT_ALT_S:
return {"ok": False, "detail": "Der Ausführer auf dem Proxmox-Host meldet sich gerade nicht."}
if any(a.get("aktion") == "sicherung_probe" and a.get("status") in ("wartet", "laeuft") for a in kanal.liste()):
return {"ok": False, "detail": "Die Probe läuft schon."}
return {"ok": True, "auftrag": kanal.anlegen("sicherung_probe")}
def _aus_schaetzung(z: dict, jetzt: float) -> dict | None:
"""Die Schätzung des PBS als Vorhersage (wie kern/prognose.vorhersage, mit „quelle“: „pbs“)."""
s = z.get("schaetzung")
if not isinstance(s, dict):
return None
anteil = _prozent(s.get("belegt") or z.get("belegt"), s.get("gesamt") or z.get("gesamt"))
voll_am = s.get("voll_am")
if not messreihen.ist_zahl(voll_am) or voll_am <= jetzt:
return {"tage": None, "rate": 0.0, "anteil": anteil, "grund": "gleich", "quelle": "pbs"}
tage = (voll_am - jetzt) / 86400
rate = round((100 - anteil) / tage, 2) if anteil is not None and tage > 0 else None
return {"tage": round(tage, 1), "rate": rate, "anteil": anteil, "grund": None, "quelle": "pbs"}
# --- Vorhersage „voll in etwa N Tagen“ (seit 25.09.2026) --------------------------------------------------------
@@ -409,7 +491,7 @@ def lage(jetzt: float | None = None) -> dict:
for n in ergebnis["nas"]:
n["vorhersage"] = None if n["problem"] else _vorhersage(f"nas:{n['ziel']}", jetzt)
for z in ergebnis["sicherungen"]["ziele"]:
z["vorhersage"] = _vorhersage(f"ziel:{z['speicher']}", jetzt)
z["vorhersage"] = _aus_schaetzung(z, zeit) or _vorhersage(f"ziel:{z['speicher']}", jetzt)
return ergebnis
@@ -476,16 +558,74 @@ def pruefe_speicher(jetzt: float | None = None) -> list:
text = f"Noch {frei} frei.{prognose.dazu(v)} {platz}"
befunde.append(Befund(id=f"nas:{n['ziel']}", stufe=stufe, titel=titel, text=text, quelle="nas"))
s = sicherungen(b, zeit)
if s["stufe"] in ("gelb", "rot"):
if s["stufe_frische"] in ("gelb", "rot"):
teile = list(s["probleme"])
if s["fehlend"]:
namen = ", ".join(f"{f['name']} ({'zuletzt ' + _datum(f['zuletzt']) if f['zuletzt'] else 'noch nie'})"
for f in s["fehlend"][:6])
teile.append(f"Keine frische Sicherung: {namen}.")
befunde.append(Befund(
id="sicherungen", stufe=s["stufe"],
titel=("Sicherungen scheitern" if s["stufe"] == "rot" else f"Sicherung fehlt bei {len(s['fehlend'])} Gerät"
id="sicherungen", stufe=s["stufe_frische"],
titel=("Sicherungen scheitern" if s["stufe_frische"] == "rot" else f"Sicherung fehlt bei {len(s['fehlend'])} Gerät"
+ ("en" if len(s["fehlend"]) != 1 else "")),
text=" ".join(teile) + " In Proxmox: Rechenzentrum → Backup und die Aufgaben-Liste unten zeigen, woran es lag.",
quelle="pve"))
befunde += _abdeckung_befunde(s, zeit)
for z in s["ziele"]:
v = _aus_schaetzung(z, zeit)
if prognose.stufe(v) != "ok":
befunde.append(Befund(
id=f"ziel:{z['speicher']}", stufe=prognose.stufe(v),
titel=f"Sicherungsziel {z['speicher']} {prognose.in_tagen(v['tage'])} voll",
text=(f"So schätzt es der PBS selbst. Noch {_groesse((z['gesamt'] or 0) - (z['belegt'] or 0))} frei. Im PBS "
"die Aufbewahrung kürzen (Datastore → Prune) oder Platz auf dem NAS schaffen."),
quelle="pve"))
return befunde
def _tag(ts: float) -> str:
return datetime.fromtimestamp(ts, LOCAL_TZ).strftime("%d.%m.")
def _abdeckung_befunde(s: dict, jetzt: float) -> list:
"""Die Hinweise zur Abdeckung: ohne Auftrag, beschädigt, lange nicht geprüft, Probe gescheitert."""
from services.waechter import Befund
befunde = []
if ohne := s["ohne_auftrag"]:
namen = ", ".join(g["name"] for g in ohne)
befunde.append(Befund(
id="sicherung:ohne-auftrag", stufe="gelb", sofort=True,
titel=(f"{namen} ist in keinem Sicherungsauftrag" if len(ohne) == 1
else f"{len(ohne)} Geräte sind in keinem Sicherungsauftrag"),
text=(("Proxmox sichert es nie. " if len(ohne) == 1 else f"Proxmox sichert sie nie: {namen}. ")
+ "In Proxmox: Rechenzentrum → Backup → den Auftrag bearbeiten und die Geräte anhaken (oder „Alle“)."),
quelle="pve"))
if defekt := [g for g in s["geraete"] if g["defekt"]]:
akut = any(g["geprueft"] and g["geprueft"]["zustand"] == "failed" for g in defekt)
teile = ", ".join(f"{g['name']} (Sicherung vom {_tag(g['defekt'][-1])})" for g in defekt[:6])
befunde.append(Befund(
id="sicherung:defekt", stufe="rot" if akut else "gelb",
titel=("Der PBS fand eine beschädigte Sicherung" if len(defekt) == 1
else f"Der PBS fand beschädigte Sicherungen bei {len(defekt)} Geräten"),
text=(f"Beim Prüfen stimmten Daten nicht: {teile}. Die nächste nächtliche Sicherung sollte wieder "
"vollständig sein — danach die beschädigte im PBS löschen (Datastore → Inhalt) und am NAS nach "
"Plattenfehlern sehen."),
quelle="pve"))
pruefung = s["pruefung"]
if pruefung["noetig"] and (not pruefung["zuletzt"] or jetzt - pruefung["zuletzt"] > PRUEFUNG_ALT_S):
befunde.append(Befund(
id="sicherung:pruefung", stufe="gelb",
titel=("Der PBS prüft die Sicherungen nicht" if not pruefung["zuletzt"]
else f"Der PBS hat seit {int((jetzt - pruefung['zuletzt']) // 86400)} Tagen keine Sicherung geprüft"),
text=("Ob eine Sicherung noch lesbar ist, zeigt erst die Prüfung. Im PBS: Datastore → Verify Jobs — läuft "
"dort ein Auftrag? Wenn keiner da ist: „Add“, etwa einmal die Woche."),
quelle="pve"))
if gescheitert := [g for g in s["geraete"] if g["probe"] and not g["probe"]["ok"]]:
befunde.append(Befund(
id="sicherung:probe", stufe="gelb",
titel=(f"Probe-Wiederherstellung gescheitert: {gescheitert[0]['name']}" if len(gescheitert) == 1
else f"Probe-Wiederherstellung bei {len(gescheitert)} Geräten gescheitert"),
text=(" ".join(f"{g['name']}: {g['probe']['fehler']}." for g in gescheitert[:4])
+ " Die Probe öffnet die jüngste Sicherung nur lesend und läuft in einer Stunde wieder."),
quelle="pve"))
return befunde
+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()