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) <noreply@anthropic.com>
This commit is contained in:
duffyduck
2026-07-31 10:21:59 +02:00
co-authored by Claude Opus 5
parent 963463a1a1
commit 3acbebce6f
6 changed files with 85 additions and 11 deletions
+5 -1
View File
@@ -278,7 +278,7 @@ def cmd_list(args):
rows.append([ rows.append([
guest.vmid, guest.vmid,
truncate(guest.name, 20), truncate(guest.name, 20),
snap.name, snap.name if snap.complete else "! " + snap.name,
(parsed or {}).get("slug", "-"), (parsed or {}).get("slug", "-"),
created.strftime("%d.%m.%y %H:%M") if created else "?", created.strftime("%d.%m.%y %H:%M") if created else "?",
_age(created, now) 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", print(_table(rows, ["VMID", "Name", "Snapshot", "Gruppe", "Erstellt",
"Alter", "Beschreibung"])) "Alter", "Beschreibung"]))
print("\n%d Snapshot(s)%s" % (len(rows), "" if args.all else " von pvesnap")) 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 <vmid> <name> --force")
else: else:
print("Keine passenden Snapshots gefunden.") print("Keine passenden Snapshots gefunden.")
return 0 return 0
+8 -1
View File
@@ -80,7 +80,14 @@ def managed_snapshots(prefix, group, snapshots):
def plan_prune(prefix, group, snapshots, now): def plan_prune(prefix, group, snapshots, now):
"""Welche Snapshots sollen weg? Gibt eine Liste von (Snapshot, Grund) zurueck.""" """Welche Snapshots sollen weg? Gibt eine Liste von (Snapshot, Grund) zurueck."""
doomed = [] 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)): for index, (snap, created) in enumerate(managed_snapshots(prefix, group, snapshots)):
if snap.snapstate:
continue # schon oben eingeplant
if index < group.keep_min: if index < group.keep_min:
continue continue
age = (now - created).total_seconds() age = (now - created).total_seconds()
@@ -180,7 +187,7 @@ def _prune_guest(proxmox, config, group, guest, now, result):
snapshots = proxmox.list_snapshots(guest) snapshots = proxmox.list_snapshots(guest)
for snap, reason in plan_prune(prefix, group, snapshots, now): for snap, reason in plan_prune(prefix, group, snapshots, now):
try: 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)) result.deleted.append("%s:%s" % (guest.vmid, snap.name))
log.info("Gruppe '%s': Snapshot '%s' von %s geloescht (%s)", log.info("Gruppe '%s': Snapshot '%s' von %s geloescht (%s)",
group.name, snap.name, guest.label, reason) group.name, snap.name, guest.label, reason)
+26 -3
View File
@@ -57,6 +57,18 @@ COPY_BUFFER = 1024 * 1024
# Eintraege und Fenster # 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 @dataclass
class Entry: class Entry:
name: str name: str
@@ -154,8 +166,7 @@ class Pane:
if not entry.is_dir: if not entry.is_dir:
return False return False
target = os.path.realpath(entry.path) target = os.path.realpath(entry.path)
root = os.path.realpath(self.root) if not within(self.root, target):
if not (target == root or target.startswith(root + os.sep)):
return False # nicht aus dem Snapshot herauslaufen return False # nicht aus dem Snapshot herauslaufen
self.path = target self.path = target
self.marks.clear() self.marks.clear()
@@ -772,6 +783,8 @@ def _pick_snapshot(win, snapshots):
for snap in snapshots: for snap in snapshots:
when = (datetime.fromtimestamp(snap.snaptime).strftime("%d.%m.%Y %H:%M") when = (datetime.fromtimestamp(snap.snaptime).strftime("%d.%m.%Y %H:%M")
if snap.snaptime else "?") if snap.snaptime else "?")
if not getattr(snap, "complete", True):
continue
entries.append((snap.name, "%-34s %s %s" entries.append((snap.name, "%-34s %s %s"
% (truncate(snap.name, 34), when, % (truncate(snap.name, 34), when,
truncate(snap.description.replace("\n", " "), 40)))) truncate(snap.description.replace("\n", " "), 40))))
@@ -852,6 +865,9 @@ def _main(stdscr, args):
message(stdscr, str(exc), error=True) message(stdscr, str(exc), error=True)
break break
broken = [s for s in snapshots if not s.complete]
snapshots = [s for s in snapshots if s.complete]
if wanted_snapshot: if wanted_snapshot:
name = next((s.name for s in snapshots if s.name == wanted_snapshot), None) name = next((s.name for s in snapshots if s.name == wanted_snapshot), None)
if name is None: if name is None:
@@ -861,7 +877,14 @@ def _main(stdscr, args):
if name is None: if name is None:
continue continue
elif not snapshots: 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 <name> --force"
% (len(broken), "pct" if guest.type == "lxc" else "qm",
guest.vmid))
message(stdscr, text, error=True)
break break
else: else:
name = _pick_snapshot(stdscr, snapshots) name = _pick_snapshot(stdscr, snapshots)
+13 -1
View File
@@ -48,6 +48,13 @@ class Snapshot:
description: str = "" description: str = ""
snaptime: int = 0 snaptime: int = 0
parent: str = "" 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(): def pvesh_available():
@@ -155,6 +162,7 @@ class Proxmox:
description=(entry.get("description") or "").strip(), description=(entry.get("description") or "").strip(),
snaptime=int(entry.get("snaptime") or 0), snaptime=int(entry.get("snaptime") or 0),
parent=entry.get("parent") or "", parent=entry.get("parent") or "",
snapstate=(entry.get("snapstate") or "").strip(),
)) ))
result.sort(key=lambda s: s.snaptime) result.sort(key=lambda s: s.snaptime)
return result return result
@@ -178,11 +186,15 @@ class Proxmox:
return self._retry(what, lambda: self._task(guest, args, what), skip_retry=exists) 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: if self.dry_run:
log.info("[TESTLAUF] wuerde Snapshot loeschen: %s -> %s", guest.label, name) log.info("[TESTLAUF] wuerde Snapshot loeschen: %s -> %s", guest.label, name)
return None return None
args = ["delete", "%s/%s" % (self._base_path(guest), name)] 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) what = "Loeschen von %s bei %s" % (name, guest.label)
return self._retry(what, lambda: self._task(guest, args, what)) return self._retry(what, lambda: self._task(guest, args, what))
+10 -2
View File
@@ -769,7 +769,15 @@ class Session:
return False return False
def list_snapshots(proxmox, guest): def list_snapshots(proxmox, guest, complete_only=False):
"""Snapshots eines Gastes, neueste zuerst.""" """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) 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) return sorted(snapshots, key=lambda s: s.snaptime, reverse=True)
+23 -3
View File
@@ -31,6 +31,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from .. import __version__ from .. import __version__
from ..proxmox import Proxmox, ProxmoxError, pvesh_available from ..proxmox import Proxmox, ProxmoxError, pvesh_available
from ..explorer import within
from ..snapfs import (Session, SnapfsError, cleanup_leftovers, human_bytes, from ..snapfs import (Session, SnapfsError, cleanup_leftovers, human_bytes,
list_snapshots) list_snapshots)
from .assets import LOGIN_FORM, PAGE, SCRIPT, STYLE from .assets import LOGIN_FORM, PAGE, SCRIPT, STYLE
@@ -173,8 +174,7 @@ class AppState:
if not root: if not root:
raise ValueError("Es ist kein Snapshot geoeffnet.") raise ValueError("Es ist kein Snapshot geoeffnet.")
candidate = os.path.realpath(os.path.join(root, (relative or "").lstrip("/"))) candidate = os.path.realpath(os.path.join(root, (relative or "").lstrip("/")))
root_real = os.path.realpath(root) if not within(root, candidate):
if candidate != root_real and not candidate.startswith(root_real + os.sep):
raise ValueError("Pfad liegt ausserhalb des Snapshots.") raise ValueError("Pfad liegt ausserhalb des Snapshots.")
return candidate return candidate
@@ -464,9 +464,20 @@ class Handler(BaseHTTPRequestHandler):
return return
rows = [] rows = []
broken = 0
for snap in list_snapshots(state.proxmox, guest): for snap in list_snapshots(state.proxmox, guest):
when = (datetime.fromtimestamp(snap.snaptime).strftime("%d.%m.%Y %H:%M") when = (datetime.fromtimestamp(snap.snaptime).strftime("%d.%m.%Y %H:%M")
if snap.snaptime else "?") 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(
"<tr class='muted'><td class='name'><span class='icon'>⚠</span>%s</td>"
"<td class='when'>%s</td><td class='muted'>unvollstaendig (%s)</td>"
"<td class='muted'>nicht zu oeffnen</td></tr>"
% (esc(snap.name), esc(when), esc(snap.snapstate)))
continue
rows.append( rows.append(
"<tr><td class='name'><span class='icon'>🕘</span>%s</td>" "<tr><td class='name'><span class='icon'>🕘</span>%s</td>"
"<td class='when'>%s</td><td class='muted'>%s</td>" "<td class='when'>%s</td><td class='muted'>%s</td>"
@@ -479,15 +490,24 @@ class Handler(BaseHTTPRequestHandler):
esc(snap.description.replace("\n", " ")[:120]), esc(snap.description.replace("\n", " ")[:120]),
guest.vmid, esc(snap.name))) guest.vmid, esc(snap.name)))
warning = ""
if broken:
warning = ("<div class='note warn'>%d Eintrag/Eintraege sind "
"unvollstaendig - sie stammen aus abgebrochenen Laeufen und "
"enthalten keine Daten. Aufraeumen auf dem Host mit: "
"<code>%s delsnapshot %d &lt;name&gt; --force</code></div>"
% (broken, "pct" if guest.type == "lxc" else "qm", guest.vmid))
body = ( body = (
"<p><a href='/'>&larr; Gaeste</a></p>" "<p><a href='/'>&larr; Gaeste</a></p>"
"<h2>Snapshots von %s</h2>" "<h2>Snapshots von %s</h2>"
"<div class='note'>Das Oeffnen bindet den Snapshot schreibgeschuetzt " "<div class='note'>Das Oeffnen bindet den Snapshot schreibgeschuetzt "
"ein - das dauert ein paar Sekunden.</div>" "ein - das dauert ein paar Sekunden.</div>"
"%s"
"<div class='card'><table><thead><tr><th>Snapshot</th><th class='when'>" "<div class='card'><table><thead><tr><th>Snapshot</th><th class='when'>"
"Erstellt</th><th>Beschreibung</th><th></th></tr></thead><tbody>%s</tbody>" "Erstellt</th><th>Beschreibung</th><th></th></tr></thead><tbody>%s</tbody>"
"</table></div>" "</table></div>"
% (esc(guest.label), % (esc(guest.label), warning,
"".join(rows) or "<tr><td colspan='4' class='empty'>" "".join(rows) or "<tr><td colspan='4' class='empty'>"
"Dieser Gast hat keine Snapshots.</td></tr>")) "Dieser Gast hat keine Snapshots.</td></tr>"))
self._send(page("Snapshots %s" % guest.label, state, body, self._send(page("Snapshots %s" % guest.label, state, body,