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:
co-authored by
Claude Opus 5.5
parent
f7a9b8ad50
commit
afa3a476b5
@@ -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 {})
|
||||
|
||||
Reference in New Issue
Block a user