homelab: eigener Reiter „Snapshots“ mit Verwaltung (anlegen, löschen, zurücksetzen)
Die Seite /homelab/snapshots (in der Seitenleiste nach „Updates“: Snapshots sind der Rückweg der Updates) zeigt je Gerät die Snapshots mit Zeitpunkt, Alter, Herkunft (vor Update / per Knopf / von Hand) und Beschreibung, beim PBS die Sicherungen des Orchestrators mit Größe. Knöpfe „Snapshot anlegen“, „Zurücksetzen“ und „Löschen“ gibt es nur für freigegebene Geräte und nur für mc2-Snapshots bzw. eigene Sicherungen; von Hand angelegte bleiben reine Ansicht. Die Rückfragen kommen vom Homelab-Teil (wie im Ziel-Modell); Zurücksetzen sagt vorher, was verloren geht und dass das Gerät neu startet. Am Handy (320–400 px) untereinander, ab md Knöpfe rechts, ab xl fünf Spalten. Entscheide: - Ausführer: kein neuer Befehl. Der Bericht bringt zusätzlich snapshot_details (snaptime, Beschreibung, eigen) und sicherung_details (ctime, Größe); snapshots und sicherungen bleiben unverändert. Keine Belegung je Snapshot — die kennt Proxmox bei LVM-Thin nicht, die Seite sagt das so. „snapshot“ nimmt einen Anlass (update oder knopf) und wählt damit eine feste Beschreibung, damit ein Snapshot auf Knopfdruck nicht „Vor einem Update“ heißt. Bis der neue Ausführer eingespielt ist, liest der Homelab-Teil den Zeitpunkt aus dem Namen. - Aktionen sind Läufe in homelab-laeufe.json (Mechanik aus updates.py): gleiche Sperren (START_SPERRE, je Gerät einer, SAMMELLAUF_SPERRE), Protokoll-Art „snapshot“ mit eigenen Titeln, der Wächter lässt ein Gerät im Zurücksetzen in Ruhe. Zusätzlich keine Aktion, solange irgendwo ein Update läuft: Der Ausführer arbeitet einen Auftrag nach dem anderen ab — hinter einem Update liefe die Aktion in ihr Zeitlimit und käme danach unbeobachtet doch noch dran. - Zurücksetzen prüft danach wie ein Update und meldet immer ([Homelab-Snapshot], bei Rot als Alarm); anlegen und löschen melden nur ihr Scheitern, das Ergebnis steht beim Gerät. Nicht im Update-Verlauf, nicht unter „Zuletzt eingespielt“. - Per Knopf angelegte Snapshots räumt kein grünes Update weg (updates._aufraeumen fragt snapshots.knopf_namen). - speicher.py: alle Snapshots in einem Vorschlag mit Verweis auf die Seite statt einer je Snapshot. - Nebenbei: Der Update-Verlauf stürzte bei einem Ergebnis ohne Eintrag ab (etwa „offen“ nach einem Docker-Probelauf); die Protokoll-Seite stürzte im Entwicklungsmodus ab (Konstante „Symbol“ gegen das Symbol.for des React Compilers); Auftragstitel „Alter Snapshot gelöscht“ heißt jetzt „Snapshot gelöscht“ (gelöscht wird auch von Hand). Tests: pytest test_homelab_snapshots.py (Ausführer-Details, Liste, Läufe mit nachgespieltem Ausführer, Sperren, Aufräumen, Schnittstelle), test_homelab_speicher.py; Vitest SnapshotListe, lib/snapshots, Speicher. Prüftor grün. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
2784c0ea32
commit
450c782ed3
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user