Merge branch 'worktree-agent-ab032f7900d8a5a63' into wartung/snapshots-reiter

This commit is contained in:
Hitonabi
2026-09-25 13:47:33 +02:00
24 changed files with 1835 additions and 60 deletions
+52 -9
View File
@@ -7,7 +7,10 @@ Container des Homelab-Teils braucht keinen Proxmox-Schlüssel.
• Bericht alle 10 Minuten (oder auf Anfrage): Host-Updates, Gäste mit Status, App-Version,
OS-Updates, Snapshots und ob ein Snapshot überhaupt geht; wo nicht, ob eine Sicherung
geht, und die Sicherungen des Orchestrators. Nur lesend.
geht, und die Sicherungen des Orchestrators. Nur lesend. Seit 25.09.2026 je Gast auch
snapshot_details (Zeitpunkt, Beschreibung, ob vom Orchestrator) und sicherung_details
(Zeitpunkt, Größe) für die Seite Homelab → Snapshots; die Namenslisten snapshots und
sicherungen bleiben, wie sie waren.
• Aufträge eine feste Liste von Aktionen (AKTIONEN); alles andere wird abgelehnt. Welche Gäste
angefasst werden dürfen, entscheidet dieser Host selbst anhand der Etiketten:
community-script oder watcher, aber nicht watcher-aus. Dem Server wird dabei nicht
@@ -55,6 +58,10 @@ ABFRAGE_ALLE_S = 5
ERLAUBT = {"community-script", "watcher"}
AUSGENOMMEN = "watcher-aus"
SNAPSHOT_NAME = re.compile(r"^mc2-\d{8}-\d{6}$")
# Beschreibung neuer Snapshots in Proxmox (seit 25.09.2026). Der Server wählt nur den Anlass, den Text legt dieser
# Host fest. „knopf“ = „Snapshot anlegen“ auf der Seite Homelab → Snapshots; solche räumt kein Update weg.
SNAPSHOT_ANLASS = {"update": "Vor einem Update durch den Homelab Orchestrator",
"knopf": "Auf Knopfdruck im Homelab Orchestrator angelegt"}
# Speicherarten, auf denen Proxmox Snapshots kann.
SNAPSHOT_SPEICHER = {"lvmthin", "zfspool", "rbd", "btrfs", "cephfs"}
@@ -340,13 +347,43 @@ def _ip_vm(vmid: int) -> str | None:
return None
def _snapshots(art: str, vmid: int) -> list[str]:
def _snapshots(art: str, vmid: int) -> list[dict]:
"""Die Snapshots eines Gasts, wie pvesh sie liefert (samt „current“, dem laufenden Stand); leer bei Fehlern."""
try:
return [s["name"] for s in _pvesh(f"/nodes/{NODE}/{art}/{vmid}/snapshot") if s.get("name") != "current"]
return [s for s in _pvesh(f"/nodes/{NODE}/{art}/{vmid}/snapshot") or [] if isinstance(s, dict)]
except Exception:
return []
def _ganzzahl(wert: object) -> int | None:
try:
return int(wert)
except (TypeError, ValueError):
return None
def snapshot_details(eintraege: object) -> list[dict]:
"""Die Snapshots für die Seite Homelab → Snapshots, ältester zuerst, ohne „current“: Name, Zeitpunkt (snaptime,
Unix-Sekunden; None, wo Proxmox keinen nennt), Beschreibung und ob er vom Orchestrator ist (nur solche setzt dieser
Host zurück oder löscht er). Eine Belegung steht bewusst nicht dabei: Wie viel ein Snapshot auf LVM-Thin allein
belegt, sagt Proxmox nicht — lvs zählt auch die Blöcke, die er mit dem Original teilt."""
details = []
for s in eintraege if isinstance(eintraege, list) else []:
if not isinstance(s, dict) or not s.get("name") or s["name"] == "current":
continue
name = str(s["name"])
details.append({"name": name, "zeit": _ganzzahl(s.get("snaptime")),
"beschreibung": str(s.get("description") or "").strip() or None,
"eigen": bool(SNAPSHOT_NAME.match(name))})
return sorted(details, key=lambda d: (d["zeit"] or 0, d["name"]))
def sicherung_details(eintraege: list[dict]) -> list[dict]:
"""Die eigenen Sicherungen eines Gasts für die Seite Homelab → Snapshots: Archiv, Zeitpunkt (ctime), Größe in Bytes."""
return [{"volid": e["volid"], "zeit": _ganzzahl(e.get("ctime")), "groesse": _ganzzahl(e.get("size"))}
for e in eintraege]
def _eigene_sicherungen(speicher: str, vmid: int | None = None) -> list[dict]:
parameter = ["--content", "backup"] + (["--vmid", str(vmid)] if vmid is not None else [])
return eigene_sicherungen(_pvesh(f"/nodes/{NODE}/storage/{speicher}/content", *parameter), vmid)
@@ -579,12 +616,16 @@ def _speicherlage(speicher: list[dict], gaeste: list[dict]) -> dict:
return lage
def _gast(eintrag: dict, arten: dict[str, str], sicherung: dict, sicherungen: dict[int, list[str]]) -> dict:
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"))
snapshots = _snapshots(art, vmid)
eigene = sicherungen.get(vmid, [])
gast = {"vmid": vmid, "art": art, "name": eintrag.get("name"), "status": eintrag.get("status"),
"etiketten": etiketten, "erlaubt": erlaubt(etiketten), "uptime": eintrag.get("uptime"),
"snapshots": _snapshots(art, vmid), "sicherungen": sicherungen.get(vmid, [])}
"snapshots": [str(s["name"]) for s in snapshots if s.get("name") and s["name"] != "current"],
"snapshot_details": snapshot_details(snapshots),
"sicherungen": [e["volid"] for e in eigene], "sicherung_details": sicherung_details(eigene)}
if art == "lxc" and gast["status"] == "running" and eintrag.get("maxdisk"):
# Belegung der rootfs (Proxmox liest sie bei laufenden Containern mit). Am 24.09.2026 stand AdGuard bei
# 100 % — das Abfrageprotokoll schrieb nicht mehr, die Paketsuche scheiterte, und niemand sah es.
@@ -719,11 +760,11 @@ def bericht() -> dict:
speicher = _speicher_liste()
arten = {s["storage"]: s.get("type") for s in speicher}
sicherung = sicherung_stand(speicher, SICHERUNG_SPEICHER)
sicherungen: dict[int, list[str]] = {}
sicherungen: dict[int, list[dict]] = {}
if sicherung.get("speicher"):
try:
for e in _eigene_sicherungen(sicherung["speicher"]):
sicherungen.setdefault(int(SICHERUNG_ARCHIV.match(e["volid"]).group(2)), []).append(e["volid"])
sicherungen.setdefault(int(SICHERUNG_ARCHIV.match(e["volid"]).group(2)), []).append(e)
except Exception:
pass
gaeste = []
@@ -758,9 +799,11 @@ def _werkzeug(art: str) -> str:
def a_snapshot(p: dict) -> tuple[int, str]:
art, vmid = _gast_pruefen(p.get("vmid"))
beschreibung = SNAPSHOT_ANLASS.get(str(p.get("anlass") or "update"))
if beschreibung is None:
raise ValueError("unbekannter Anlass für einen Snapshot (update oder knopf)")
name = "mc2-" + datetime.now().strftime("%Y%m%d-%H%M%S")
code, text = _laufen([_werkzeug(art), "snapshot", str(vmid), name, "--description",
"Vor einem Update durch den Homelab Orchestrator"], 600)
code, text = _laufen([_werkzeug(art), "snapshot", str(vmid), name, "--description", beschreibung], 600)
return code, f"snapshot={name}\n{text}" if code == 0 else text