From 3acbebce6f1a196e59de02602c61755c28af2456 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Fri, 31 Jul 2026 10:21:59 +0200 Subject: [PATCH] Lokales Fenster war nicht navigierbar; halbfertige Snapshots erkennen Zwei gemeldete Fehler. 1. Im rechten Fenster des Explorers liessen sich weder Verzeichnisse oeffnen noch ".." benutzen. Die Pruefung "bleibt der Pfad innerhalb der Wurzel?" haengte an die Wurzel ein "/" an - bei der Wurzel "/" des lokalen Fensters wurde daraus "//", worauf kein Pfad passt. Damit gab open_current() immer False zurueck. Die Pruefung steckt jetzt in within() und behandelt diesen Fall; die Web-Oberflaeche benutzt dieselbe Funktion. 2. "Snapshots vom LXC aufrufen geht nicht": der Container hatte gar keine brauchbaren Snapshots. In seiner Konfiguration standen nur zwei Eintraege mit snapstate "prepare" und "delete" - Reste aus der Zeit, in der jeder Snapshot am cfs-Lock scheiterte. Auf dem Storage liegt dahinter nichts (rbd snap ls ist leer), oeffnen kann man sie also nicht. Solche Eintraege werden jetzt als das behandelt, was sie sind: * Explorer und Web-Oberflaeche bieten sie nicht mehr zum Oeffnen an und nennen den Aufraeumbefehl. * "pvesnap list" markiert sie mit "!". * Beim Aufraeumen entfernt der Dienst sie zuerst, und zwar mit --force, weil sich ein Eintrag ohne Storage-Snapshot sonst nicht loeschen laesst. Vorher waeren sie ewig liegen geblieben und haetten zusaetzlich die Zahl der behaltenen Snapshots verfaelscht. Co-Authored-By: Claude Opus 5 (1M context) --- pvesnap/cli.py | 6 +++++- pvesnap/engine.py | 9 ++++++++- pvesnap/explorer.py | 29 ++++++++++++++++++++++++++--- pvesnap/proxmox.py | 14 +++++++++++++- pvesnap/snapfs.py | 12 ++++++++++-- pvesnap/web/server.py | 26 +++++++++++++++++++++++--- 6 files changed, 85 insertions(+), 11 deletions(-) diff --git a/pvesnap/cli.py b/pvesnap/cli.py index 8dfead7..f6e6c22 100644 --- a/pvesnap/cli.py +++ b/pvesnap/cli.py @@ -278,7 +278,7 @@ def cmd_list(args): rows.append([ guest.vmid, truncate(guest.name, 20), - snap.name, + snap.name if snap.complete else "! " + snap.name, (parsed or {}).get("slug", "-"), created.strftime("%d.%m.%y %H:%M") if created else "?", _age(created, now) if created else "?", @@ -289,6 +289,10 @@ def cmd_list(args): print(_table(rows, ["VMID", "Name", "Snapshot", "Gruppe", "Erstellt", "Alter", "Beschreibung"])) print("\n%d Snapshot(s)%s" % (len(rows), "" if args.all else " von pvesnap")) + if any(str(row[2]).startswith("! ") for row in rows): + print("Mit '!' markierte Eintraege sind unvollstaendig (abgebrochener " + "Lauf) und enthalten keine Daten.\n" + "Aufraeumen: qm|pct delsnapshot --force") else: print("Keine passenden Snapshots gefunden.") return 0 diff --git a/pvesnap/engine.py b/pvesnap/engine.py index fb4dadf..44b0e7f 100644 --- a/pvesnap/engine.py +++ b/pvesnap/engine.py @@ -80,7 +80,14 @@ def managed_snapshots(prefix, group, snapshots): def plan_prune(prefix, group, snapshots, now): """Welche Snapshots sollen weg? Gibt eine Liste von (Snapshot, Grund) zurueck.""" doomed = [] + for snap, _created in managed_snapshots(prefix, group, snapshots): + if snap.snapstate: + # Halbfertiger Eintrag aus einem abgebrochenen Lauf - der belegt + # nur die Konfiguration und laesst sich nicht oeffnen. + doomed.append((snap, "unvollstaendig (%s)" % snap.snapstate)) for index, (snap, created) in enumerate(managed_snapshots(prefix, group, snapshots)): + if snap.snapstate: + continue # schon oben eingeplant if index < group.keep_min: continue age = (now - created).total_seconds() @@ -180,7 +187,7 @@ def _prune_guest(proxmox, config, group, guest, now, result): snapshots = proxmox.list_snapshots(guest) for snap, reason in plan_prune(prefix, group, snapshots, now): try: - proxmox.delete_snapshot(guest, snap.name) + proxmox.delete_snapshot(guest, snap.name, force=bool(snap.snapstate)) result.deleted.append("%s:%s" % (guest.vmid, snap.name)) log.info("Gruppe '%s': Snapshot '%s' von %s geloescht (%s)", group.name, snap.name, guest.label, reason) diff --git a/pvesnap/explorer.py b/pvesnap/explorer.py index fbb0f20..e2835ac 100644 --- a/pvesnap/explorer.py +++ b/pvesnap/explorer.py @@ -57,6 +57,18 @@ COPY_BUFFER = 1024 * 1024 # Eintraege und Fenster # --------------------------------------------------------------------------- +def within(root, path): + """Liegt `path` innerhalb von `root`? + + Wichtig ist der Sonderfall root="/" (das lokale Fenster darf ueberall + hin): dort ist root + "/" gleich "//", worauf kein einziger Pfad passt. + """ + root = os.path.realpath(root) + path = os.path.realpath(path) + prefix = root if root.endswith(os.sep) else root + os.sep + return path == root or path.startswith(prefix) + + @dataclass class Entry: name: str @@ -154,8 +166,7 @@ class Pane: if not entry.is_dir: return False target = os.path.realpath(entry.path) - root = os.path.realpath(self.root) - if not (target == root or target.startswith(root + os.sep)): + if not within(self.root, target): return False # nicht aus dem Snapshot herauslaufen self.path = target self.marks.clear() @@ -772,6 +783,8 @@ def _pick_snapshot(win, snapshots): for snap in snapshots: when = (datetime.fromtimestamp(snap.snaptime).strftime("%d.%m.%Y %H:%M") if snap.snaptime else "?") + if not getattr(snap, "complete", True): + continue entries.append((snap.name, "%-34s %s %s" % (truncate(snap.name, 34), when, truncate(snap.description.replace("\n", " "), 40)))) @@ -852,6 +865,9 @@ def _main(stdscr, args): message(stdscr, str(exc), error=True) break + broken = [s for s in snapshots if not s.complete] + snapshots = [s for s in snapshots if s.complete] + if wanted_snapshot: name = next((s.name for s in snapshots if s.name == wanted_snapshot), None) if name is None: @@ -861,7 +877,14 @@ def _main(stdscr, args): if name is None: continue elif not snapshots: - message(stdscr, "%s hat keine Snapshots." % guest.label, error=True) + text = "%s hat keine Snapshots." % guest.label + if broken: + text += (" %d halbfertige(r) Eintrag/Eintraege aus abgebrochenen " + "Laeufen sind vorhanden - aufraeumen mit: %s delsnapshot " + "%d --force" + % (len(broken), "pct" if guest.type == "lxc" else "qm", + guest.vmid)) + message(stdscr, text, error=True) break else: name = _pick_snapshot(stdscr, snapshots) diff --git a/pvesnap/proxmox.py b/pvesnap/proxmox.py index 84d51cd..1bb4060 100644 --- a/pvesnap/proxmox.py +++ b/pvesnap/proxmox.py @@ -48,6 +48,13 @@ class Snapshot: description: str = "" snaptime: int = 0 parent: str = "" + # "prepare" oder "delete" = halbfertiger Eintrag in der Gast-Konfiguration. + # Dahinter steckt kein brauchbarer Snapshot auf dem Storage. + snapstate: str = "" + + @property + def complete(self): + return not self.snapstate def pvesh_available(): @@ -155,6 +162,7 @@ class Proxmox: description=(entry.get("description") or "").strip(), snaptime=int(entry.get("snaptime") or 0), parent=entry.get("parent") or "", + snapstate=(entry.get("snapstate") or "").strip(), )) result.sort(key=lambda s: s.snaptime) return result @@ -178,11 +186,15 @@ class Proxmox: return self._retry(what, lambda: self._task(guest, args, what), skip_retry=exists) - def delete_snapshot(self, guest, name): + def delete_snapshot(self, guest, name, force=False): if self.dry_run: log.info("[TESTLAUF] wuerde Snapshot loeschen: %s -> %s", guest.label, name) return None args = ["delete", "%s/%s" % (self._base_path(guest), name)] + if force: + # Nur fuer halbfertige Eintraege: den Eintrag auch dann aus der + # Konfiguration nehmen, wenn auf dem Storage nichts (mehr) liegt. + args += ["--force", "1"] what = "Loeschen von %s bei %s" % (name, guest.label) return self._retry(what, lambda: self._task(guest, args, what)) diff --git a/pvesnap/snapfs.py b/pvesnap/snapfs.py index 4fb4ff0..df444fe 100644 --- a/pvesnap/snapfs.py +++ b/pvesnap/snapfs.py @@ -769,7 +769,15 @@ class Session: return False -def list_snapshots(proxmox, guest): - """Snapshots eines Gastes, neueste zuerst.""" +def list_snapshots(proxmox, guest, complete_only=False): + """Snapshots eines Gastes, neueste zuerst. + + complete_only=True laesst halbfertige Eintraege weg: Proxmox markiert sie + mit snapstate "prepare" oder "delete", wenn das Anlegen oder Loeschen + abgebrochen ist. Auf dem Storage liegt dahinter nichts Brauchbares - sie + lassen sich also auch nicht oeffnen. + """ snapshots = proxmox.list_snapshots(guest) + if complete_only: + snapshots = [s for s in snapshots if s.complete] return sorted(snapshots, key=lambda s: s.snaptime, reverse=True) diff --git a/pvesnap/web/server.py b/pvesnap/web/server.py index f735744..3a75034 100644 --- a/pvesnap/web/server.py +++ b/pvesnap/web/server.py @@ -31,6 +31,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from .. import __version__ from ..proxmox import Proxmox, ProxmoxError, pvesh_available +from ..explorer import within from ..snapfs import (Session, SnapfsError, cleanup_leftovers, human_bytes, list_snapshots) from .assets import LOGIN_FORM, PAGE, SCRIPT, STYLE @@ -173,8 +174,7 @@ class AppState: if not root: raise ValueError("Es ist kein Snapshot geoeffnet.") candidate = os.path.realpath(os.path.join(root, (relative or "").lstrip("/"))) - root_real = os.path.realpath(root) - if candidate != root_real and not candidate.startswith(root_real + os.sep): + if not within(root, candidate): raise ValueError("Pfad liegt ausserhalb des Snapshots.") return candidate @@ -464,9 +464,20 @@ class Handler(BaseHTTPRequestHandler): return rows = [] + broken = 0 for snap in list_snapshots(state.proxmox, guest): when = (datetime.fromtimestamp(snap.snaptime).strftime("%d.%m.%Y %H:%M") if snap.snaptime else "?") + if not snap.complete: + # Halbfertiger Eintrag aus einem abgebrochenen Lauf - dahinter + # liegt auf dem Storage nichts, was sich oeffnen liesse. + broken += 1 + rows.append( + "%s" + "%sunvollstaendig (%s)" + "nicht zu oeffnen" + % (esc(snap.name), esc(when), esc(snap.snapstate))) + continue rows.append( "🕘%s" "%s%s" @@ -479,15 +490,24 @@ class Handler(BaseHTTPRequestHandler): esc(snap.description.replace("\n", " ")[:120]), guest.vmid, esc(snap.name))) + warning = "" + if broken: + warning = ("
%d Eintrag/Eintraege sind " + "unvollstaendig - sie stammen aus abgebrochenen Laeufen und " + "enthalten keine Daten. Aufraeumen auf dem Host mit: " + "%s delsnapshot %d <name> --force
" + % (broken, "pct" if guest.type == "lxc" else "qm", guest.vmid)) + body = ( "

← Gaeste

" "

Snapshots von %s

" "
Das Oeffnen bindet den Snapshot schreibgeschuetzt " "ein - das dauert ein paar Sekunden.
" + "%s" "
%s" "
Snapshot" "ErstelltBeschreibung
" - % (esc(guest.label), + % (esc(guest.label), warning, "".join(rows) or "" "Dieser Gast hat keine Snapshots.")) self._send(page("Snapshots %s" % guest.label, state, body,