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
+167 -5
View File
@@ -25,6 +25,12 @@ Container des Homelab-Teils braucht keinen Proxmox-Schlüssel.
• Speicher im Bericht (seit 25.09.2026): Thin-Pools mit der echten Belegung je Volume, Netzlaufwerke
(NAS; ein hängender NFS-Mount hält den Bericht nicht auf), Sicherungsaufträge und je Gast die
jüngste Sicherung auf deren Ziel; bei VMs die Belegung innen über den Gast-Agenten.
• Abdeckung im Bericht (seit 25.09.2026): Gäste, die kein Sicherungsauftrag erfasst (wie Proxmox sie unter
Rechenzentrum → Backup zeigt), je Gast die jüngste Prüfung durch den PBS, die Schätzung des PBS,
wann sein Datastore voll ist, und die Probe-Wiederherstellung: einmal die Woche (erstmals eine
Viertelstunde nach dem Start) wird die jüngste Sicherung jedes Gasts probeweise geöffnet — die
Konfiguration daraus gelesen, bei Containern auf dem PBS dazu der Dateibaum. Nur lesend; es entsteht
kein Gast und keine Datei.
Nur die Standardbibliothek (Debian-Python des Hosts). Konfiguration: /etc/mc2-ausfuehrer.json
{"server": "http://192.168.178.x:9001", "token": "<gemeinsames Geheimnis>", "node": "pve", "nur_lesen": false}
@@ -38,6 +44,7 @@ Aufruf: ausfuehrer.py Dauerbetrieb (systemd)
"""
import argparse
import base64
import glob
import json
import logging
@@ -527,6 +534,120 @@ def neueste_sicherungen(inhalt: object) -> dict[str, int]:
return neu
def pruefzeit(upid: object) -> int | None:
"""Beginn einer Prüfung aus ihrer UPID. Beim PBS „UPID:<node>:<pid>:<pstart>:<task>:<start>:verificationjob:…“,
bei Proxmox VE fehlt <task> — also das erste der Felder 4 und 5, das als Zeit taugt."""
for feld in str(upid or "").split(":")[4:6]:
try:
zeit = int(feld, 16)
except ValueError:
continue
if 1_500_000_000 < zeit < 4_000_000_000:
return zeit
return None
def sicherungs_details(inhalt: object) -> dict[str, dict]:
"""Je Gast (seit 25.09.2026): die jüngste Sicherung („zeit“, „volid“, „format“, „groesse“), die älteste
(„aelteste“), die jüngste Prüfung durch den PBS („geprueft“: {"zustand": ok|failed, "zeit": Beginn der Prüfung,
"sicherung": Zeit der geprüften Sicherung}) und die Zeiten der Sicherungen, deren Prüfung scheiterte („defekt“)."""
details: dict[str, dict] = {}
for e in inhalt if isinstance(inhalt, list) else []:
try:
vmid, zeit = str(int(e["vmid"])), int(e["ctime"])
except (KeyError, TypeError, ValueError):
continue
d = details.setdefault(vmid, {"zeit": 0, "aelteste": zeit, "volid": None, "format": None, "groesse": None,
"geprueft": None, "defekt": []})
d["aelteste"] = min(d["aelteste"], zeit)
if zeit > d["zeit"]:
d.update(zeit=zeit, volid=e.get("volid"), format=e.get("format"), groesse=_ganzzahl(e.get("size")))
pruefung = e.get("verification") if isinstance(e.get("verification"), dict) else {}
if not pruefung.get("state"):
continue
beginn = pruefzeit(pruefung.get("upid")) or zeit
alt = d["geprueft"]
if not alt or (beginn, zeit) > (alt["zeit"], alt["sicherung"]):
d["geprueft"] = {"zustand": str(pruefung["state"]), "zeit": beginn, "sicherung": zeit}
if pruefung["state"] == "failed":
d["defekt"].append(zeit)
for d in details.values():
d["defekt"].sort()
return details
def pbs_schaetzung(nutzung: object, datastore: str | None) -> dict | None:
"""Aus /status/datastore-usage des PBS: {"voll_am": Unix-Zeit oder None (wächst nicht), "belegt", "gesamt"}."""
for d in nutzung if isinstance(nutzung, list) else []:
if isinstance(d, dict) and d.get("store") == datastore and not d.get("error"):
voll_am = _ganzzahl(d.get("estimated-full-date"))
return {"voll_am": voll_am if voll_am and voll_am > 0 else None,
"belegt": _ganzzahl(d.get("used")), "gesamt": _ganzzahl(d.get("total"))}
return None
# Eine Zeile, wie sie in jeder Gast-Konfiguration steht (Container oder VM).
KONFIG_ZEILE = re.compile(r"^(?:arch|cores|memory|ostype|rootfs|boot|(?:scsi|virtio|sata|ide)\d+)\s*:",
re.MULTILINE)
PROBE_ALLE_S = 7 * 86400
PROBE_NOCHMAL_S = 3600 # nach einer gescheiterten Probe
PROBE_ERSTE_S = 15 * 60 # nach dem Start: erst den PBS und die Gäste hochkommen lassen
_probe: dict = {"naechste": time.time() + PROBE_ERSTE_S, "stand": None}
def _letzte_zeile(text: str) -> str:
zeilen = [z for z in text.strip().splitlines() if z.strip()]
return f": {zeilen[-1][:200]}" if zeilen else ""
def probe_oeffnen(volid: str, art: str | None) -> str | None:
"""Eine Sicherung probeweise öffnen, nur lesend: die Konfiguration daraus lesen, bei Containern auf einem PBS dazu
den Dateibaum (/etc muss darin sein). None = gelungen, sonst was scheiterte."""
code, text = _laufen(["pvesm", "extractconfig", volid], 120)
if code != 0 or not KONFIG_ZEILE.search(text):
return "Die Konfiguration ließ sich nicht aus der Sicherung lesen" + _letzte_zeile(text)
if art == "pbs-ct":
speicher = volid.split(":", 1)[0]
code, text = _laufen(["pvesh", "get", f"/nodes/{NODE}/storage/{speicher}/file-restore/list", "--volume", volid,
"--filepath", base64.b64encode(b"/root.pxar.didx").decode(), "--output-format", "json"],
180)
try:
namen = {str(x.get("text")) for x in json.loads(text)} if code == 0 else set()
except (ValueError, AttributeError):
namen = set()
if "etc" not in namen:
return "Die Dateien der Sicherung ließen sich nicht auflisten" + _letzte_zeile(text)
return None
def sicherungs_probe(ziele: dict, oeffnen=None) -> dict:
"""Probe-Wiederherstellung: die jüngste Sicherung jedes Gasts (über alle erreichbaren Ziele) probeweise öffnen.
{"zeit", "gaeste": {vmid: {"speicher", "sicherung", "ok", "fehler"}}}"""
oeffnen = oeffnen or probe_oeffnen
neueste: dict[str, tuple[str, dict]] = {}
for name, ziel in ziele.items():
if not ziel.get("aktiv"):
continue
for vmid, d in (ziel.get("details") or {}).items():
if d.get("volid") and d["zeit"] > neueste.get(vmid, ("", {"zeit": 0}))[1]["zeit"]:
neueste[vmid] = (name, d)
gaeste = {}
for vmid, (name, d) in sorted(neueste.items(), key=lambda t: int(t[0])):
fehler = oeffnen(d["volid"], d.get("format"))
gaeste[vmid] = {"speicher": name, "sicherung": d["zeit"], "ok": fehler is None, "fehler": fehler}
return {"zeit": int(time.time()), "gaeste": gaeste}
def _probe_stand(ziele: dict) -> dict | None:
"""Die Probe, wenn sie fällig ist (einmal die Woche; nach einem Fehlschlag nach einer Stunde wieder), sonst der
letzte Stand. Er lebt nur im Speicher: Nach einem Neustart des Ausführers kommt die nächste Probe bald."""
if time.time() >= _probe["naechste"]:
stand = sicherungs_probe(ziele)
gelungen = all(g["ok"] for g in stand["gaeste"].values())
_probe.update(stand=stand, naechste=time.time() + (PROBE_ALLE_S if gelungen else PROBE_NOCHMAL_S))
return _probe["stand"]
_haengend: dict[str, subprocess.Popen] = {}
@@ -571,7 +692,11 @@ def _speicherlage(speicher: list[dict], gaeste: list[dict]) -> dict:
"""Pools, Netzlaufwerke und Sicherungen für den Bericht. Jeder Teil für sich: Scheitert einer, fehlt nur er."""
lage: dict = {}
try:
pools, zuteilung = thin_pools(speicher, _pvesh("/storage") or [], _lvs())
konfig = _pvesh("/storage") or []
except Exception:
konfig = []
try:
pools, zuteilung = thin_pools(speicher, konfig, _lvs())
vmids = {g["vmid"] for g in gaeste}
for g in gaeste:
eigene = [v for v in g.get("volumes") or [] if v in zuteilung]
@@ -605,17 +730,44 @@ def _speicherlage(speicher: list[dict], gaeste: list[dict]) -> dict:
ziel = {"art": s.get("type"), "aktiv": bool(s.get("active")), "belegt": s.get("used"),
"gesamt": s.get("total"), "neueste": {}}
try:
ziel["neueste"] = neueste_sicherungen(
_pvesh(f"/nodes/{NODE}/storage/{name}/content", "--content", "backup"))
inhalt = _pvesh(f"/nodes/{NODE}/storage/{name}/content", "--content", "backup")
ziel["neueste"] = neueste_sicherungen(inhalt)
ziel["details"] = sicherungs_details(inhalt)
except Exception as exc:
ziel["fehler"] = str(exc)[:200]
if s.get("type") == "pbs":
ziel["schaetzung"] = _pbs_schaetzung(next((k for k in konfig if k.get("storage") == name), {}), gaeste)
ziele[name] = ziel
lage["sicherungsziele"] = ziele
except Exception as exc:
lage["sicherung_fehler"] = str(exc)[:200]
try:
lage["sicherung_probe"] = _probe_stand(lage.get("sicherungsziele") or {})
except Exception as exc:
lage["sicherung_probe_fehler"] = str(exc)[:200]
try:
lage["nicht_gesichert"] = sorted(int(e["vmid"]) for e in _pvesh("/cluster/backup-info/not-backed-up") or [])
except Exception as exc:
lage["nicht_gesichert_fehler"] = str(exc)[:200]
return lage
def _pbs_schaetzung(konfig: dict, gaeste: list[dict]) -> dict | None:
"""Die Schätzung des PBS, wann sein Datastore voll ist — wenn der PBS ein laufender Container dieses Hosts ist
(seine Adresse die des Speichers). Gefragt wird er von innen, ohne Schlüssel."""
server = konfig.get("server")
pbs = next((g for g in gaeste if g.get("art") == "lxc" and g.get("status") == "running" and server
and g.get("ip") == server), None)
if not pbs:
return None
code, text = _laufen(["pct", "exec", str(pbs["vmid"]), "--", "proxmox-backup-debug", "api", "get",
"/status/datastore-usage", "--output-format", "json"], 60)
try:
return pbs_schaetzung(json.loads(text), konfig.get("datastore")) if code == 0 else None
except ValueError:
return None
def _gast(eintrag: dict, arten: dict[str, str], sicherung: dict, sicherungen: dict[int, list[dict]]) -> dict:
art, vmid = eintrag["type"], int(eintrag["vmid"])
etiketten = _etiketten(eintrag.get("tags"))
@@ -944,6 +1096,14 @@ def a_bericht(p: dict) -> tuple[int, str]:
return 0, json.dumps(bericht(), ensure_ascii=False)
def a_sicherung_probe(p: dict) -> tuple[int, str]:
"""Probe-Wiederherstellung sofort (Knopf „Jetzt prüfen“, seit 25.09.2026), nur lesend. Das Ergebnis steht als JSON
in der Antwort und im nächsten Bericht."""
_probe["naechste"] = 0.0
stand = bericht()["host"].get("sicherung_probe") or {"gaeste": {}}
return (0 if all(g["ok"] for g in stand["gaeste"].values()) else 1), json.dumps(stand, ensure_ascii=False)
def a_kernel_aufraeumen(p: dict) -> tuple[int, str]:
"""Alte Kernel entfernen (seit 25.09.2026, nur auf Knopfdruck). Welche, bestimmt dieser Host selbst, jetzt und wie
im Bericht — der Server kann nur den Anstoß geben. Am Ende: entfernt=<Anzahl>, frei=<Bytes>."""
@@ -970,7 +1130,9 @@ AKTIONEN = {"snapshot": a_snapshot, "update": a_update, "os_update": a_os_update
"snapshot_loeschen": a_snapshot_loeschen, "host_update": a_host_update,
"host_neustart": a_host_neustart, "bericht": a_bericht,
"sichern": a_sichern, "sicherung_zurueck": a_sicherung_zurueck, "sicherung_loeschen": a_sicherung_loeschen,
"kernel_aufraeumen": a_kernel_aufraeumen}
"kernel_aufraeumen": a_kernel_aufraeumen, "sicherung_probe": a_sicherung_probe}
# Was auch im Nur-Lesen-Modus geht: Es liest nur.
NUR_LESEND = {a_bericht, a_sicherung_probe}
def ausfuehren(auftrag: dict) -> dict:
@@ -978,7 +1140,7 @@ def ausfuehren(auftrag: dict) -> dict:
if aktion is None:
return {"code": 2, "text": f"Unbekannte Aktion: {auftrag.get('aktion')!r}"}
# Nur-Lesen-Modus ("nur_lesen": true in der Konfiguration): Bericht ja, Änderungen nein.
if NUR_LESEN and aktion is not a_bericht:
if NUR_LESEN and aktion not in NUR_LESEND:
return {"code": 2, "text": "Abgelehnt: Der Ausführer läuft im Nur-Lesen-Modus."}
try:
code, text = aktion(auftrag.get("parameter") or {})