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,