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
+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