Files
proxmox-snapshot-service/pvesnap/snapfs.py
T
duffyduckandClaude Opus 5 da08a81aab Explorer: Absturz beheben und LVM im Gast unterstuetzen
Beim Einbinden stuerzte der Explorer mit IndexError ab. Ursache: prepare()
meldete nur dann einen Fehler, wenn gar kein Geraet gefunden wurde - nicht
aber, wenn zwar Geraete da waren, sich davon aber keins einhaengen liess.
Der Aufrufer griff dann blind auf mountpoints[0] zu.

* prepare() meldet jetzt einen Fehler, sobald kein Dateisystem eingehaengt
  werden konnte, und nennt dabei die gefundenen Geraete samt Dateisystem-
  Typ - sonst raet man beim Suchen nur.
* Der Explorer prueft die Liste zusaetzlich selbst ab.

Der wahrscheinlichste Grund fuer "gefunden, aber nicht mountbar" ist LVM
im Gast: dahinter liegt zunaechst nur ein LVM2_member. Das wird jetzt
unterstuetzt - inklusive der heiklen Stelle: heisst die Volume-Group im
Gast genauso wie eine auf dem Host (typisch 'pve'), waere nicht mehr
eindeutig, welche gemeint ist. Erkannt wird das an gleichem Namen bei
unterschiedlicher UUID; dann wird bewusst nichts aktiviert und der Grund
angezeigt. Beim Aufraeumen wird die Gruppe vor dem Aushaengen des Geraets
wieder deaktiviert.

Ausserdem "udevadm settle" nach dem Einbinden - die Partitionsgeraete
tauchen sonst manchmal erst nach dem Scan auf.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:35:22 +02:00

679 lines
26 KiB
Python

"""Snapshots als Dateisystem zugaenglich machen.
Ein Proxmox-Snapshot ist kein Verzeichnis - je nach Storage muss er erst
zugaenglich gemacht werden:
rbd (Ceph) rbd map pool/image@snap -> /dev/rbdN
zfspool zfs clone ds@snap tmp -> /dev/zvol/tmp
(bei Container-Datasets direkt .zfs/snapshot/<snap>)
lvmthin lvchange -ay vg/snap_vol_snap -> /dev/vg/snap_...
dir/nfs qemu-nbd --load-snapshot -> /dev/nbdN
Danach werden die Partitionen ermittelt und **schreibgeschuetzt** gemountet.
Alles, was angelegt wurde, merkt sich diese Schicht und raeumt es wieder ab -
auch nach einem Absturz ueber `cleanup_leftovers()`.
Auf den Inhalt greifen Explorer und Web-Oberflaeche dann nur noch als
gewoehnlichen Pfad zu.
"""
from __future__ import annotations
import json
import logging
import os
import re
import shutil
import subprocess
import time
from dataclasses import dataclass, field
log = logging.getLogger("pvesnap.snapfs")
RUN_DIR = "/run/pvesnap"
MOUNT_ROOT = os.path.join(RUN_DIR, "mnt")
REGISTRY = os.path.join(RUN_DIR, "explorer.json")
# Datentraeger-Schluessel in den Gast-Konfigurationen
QEMU_DISK_KEY = re.compile(r"^(ide|sata|scsi|virtio)\d+$")
LXC_DISK_KEY = re.compile(r"^(rootfs|mp\d+)$")
# Mount-Optionen je Dateisystem. Snapshots laufender Gaeste haben fast immer
# ein unsauberes Journal - ohne diese Optionen wuerde der Kernel versuchen,
# es zurueckzuschreiben.
MOUNT_OPTIONS = {
"ext2": ["ro"],
"ext3": ["ro", "noload"],
"ext4": ["ro", "noload"],
"xfs": ["ro", "norecovery", "nouuid"],
"btrfs": ["ro", "nologreplay"],
"vfat": ["ro"],
"ntfs": ["ro"],
"ntfs3": ["ro"],
"iso9660": ["ro"],
}
DEFAULT_MOUNT_OPTIONS = ["ro"]
MOUNTABLE = set(MOUNT_OPTIONS) | {"ext4", "xfs", "btrfs"}
IGNORED_FSTYPES = {"", "swap", "linux_raid_member", "crypto_LUKS", "LVM2_member"}
class SnapfsError(Exception):
"""Ein Snapshot liess sich nicht zugaenglich machen."""
# ---------------------------------------------------------------------------
# Kommandos
# ---------------------------------------------------------------------------
def run(args, timeout=120, check=True):
"""Externes Kommando ausfuehren; gibt die Standardausgabe zurueck."""
log.debug("exec: %s", " ".join(args))
try:
proc = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
timeout=timeout)
except FileNotFoundError:
raise SnapfsError("Kommando nicht gefunden: %s" % args[0])
except subprocess.TimeoutExpired:
raise SnapfsError("Zeitueberschreitung bei: %s" % " ".join(args))
stdout = proc.stdout.decode("utf-8", "replace").strip()
stderr = proc.stderr.decode("utf-8", "replace").strip()
if check and proc.returncode != 0:
raise SnapfsError("%s: %s" % (args[0], (stderr or stdout or "Fehler").splitlines()[0]))
return stdout
def have(command):
return shutil.which(command) is not None
# ---------------------------------------------------------------------------
# Datenmodell
# ---------------------------------------------------------------------------
@dataclass
class Volume:
"""Ein Datentraeger aus der Gast-Konfiguration."""
key: str # scsi0, rootfs, mp0 ...
volid: str # data:vm-802-disk-0
storage: str # data
volname: str # vm-802-disk-0
size: str = ""
@property
def label(self):
return "%s %s%s" % (self.key.ljust(8), self.volid,
(" (%s)" % self.size) if self.size else "")
@dataclass
class Device:
"""Ein Blockgeraet (ganze Platte oder Partition) des Snapshots."""
path: str
name: str = ""
size: int = 0
fstype: str = ""
label: str = ""
kind: str = "part"
volume: object = None
@property
def mountable(self):
return bool(self.fstype) and self.fstype not in IGNORED_FSTYPES
@property
def human_size(self):
return human_bytes(self.size)
@property
def title(self):
parts = [os.path.basename(self.path)]
if self.label:
parts.append('"%s"' % self.label)
parts.append(self.fstype or "unbekannt")
parts.append(self.human_size)
return " ".join(parts)
def human_bytes(count):
value = float(count or 0)
for unit in ("B", "K", "M", "G", "T", "P"):
if value < 1024 or unit == "P":
return "%d%s" % (value, unit) if unit == "B" else "%.1f%s" % (value, unit)
value /= 1024
return "%.1fP" % value
# ---------------------------------------------------------------------------
# Konfiguration des Snapshots lesen
# ---------------------------------------------------------------------------
def snapshot_config(proxmox, guest, snapname):
path = "/nodes/%s/%s/%d/snapshot/%s/config" % (guest.node, guest.type,
guest.vmid, snapname)
data = proxmox._json(["get", path])
if not isinstance(data, dict):
raise SnapfsError("Konfiguration des Snapshots %r nicht lesbar" % snapname)
return data
def volumes_from_config(config, guest_type):
"""Alle echten Datentraeger aus einer Gast-Konfiguration."""
pattern = LXC_DISK_KEY if guest_type == "lxc" else QEMU_DISK_KEY
volumes = []
for key in sorted(config):
if not pattern.match(key):
continue
value = str(config[key])
if not value or "media=cdrom" in value:
continue
volid = value.split(",", 1)[0].strip()
if volid in ("none", "cdrom") or ":" not in volid:
continue
storage, volname = volid.split(":", 1)
size = ""
for part in value.split(",")[1:]:
if part.strip().startswith("size="):
size = part.strip()[5:]
volumes.append(Volume(key=key, volid=volid, storage=storage,
volname=volname, size=size))
return volumes
def storage_info(proxmox, name):
data = proxmox._json(["get", "/storage/%s" % name])
if not isinstance(data, dict):
raise SnapfsError("Storage %r nicht gefunden" % name)
return data
# ---------------------------------------------------------------------------
# Merkliste, damit nach einem Absturz nichts stehen bleibt
# ---------------------------------------------------------------------------
def _registry_load():
try:
with open(REGISTRY, "r", encoding="utf-8") as handle:
data = json.load(handle)
return data if isinstance(data, list) else []
except (OSError, ValueError):
return []
def _registry_save(entries):
try:
os.makedirs(RUN_DIR, exist_ok=True)
tmp = REGISTRY + ".tmp"
with open(tmp, "w", encoding="utf-8") as handle:
json.dump(entries, handle, indent=1)
os.replace(tmp, REGISTRY)
except OSError as exc:
log.debug("Merkliste nicht schreibbar: %s", exc)
def _registry_add(kind, value, extra=None):
entries = _registry_load()
entries.append({"kind": kind, "value": value, "extra": extra or {},
"pid": os.getpid(), "time": int(time.time())})
_registry_save(entries)
def _registry_remove(kind, value):
entries = [e for e in _registry_load()
if not (e.get("kind") == kind and e.get("value") == value)]
_registry_save(entries)
def cleanup_leftovers(verbose=False):
"""Raeumt auf, was ein abgestuerzter Lauf hinterlassen hat."""
removed = []
for entry in reversed(_registry_load()):
kind, value = entry.get("kind"), entry.get("value")
extra = entry.get("extra") or {}
try:
if kind == "mount":
_umount(value)
elif kind == "rbd":
run(["rbd", "unmap", value], check=False)
elif kind == "rbd-nbd":
run(["rbd-nbd", "unmap", value], check=False)
elif kind == "nbd":
run(["qemu-nbd", "--disconnect", value], check=False)
elif kind == "lvm":
run(["lvchange", "-an", value], check=False)
elif kind == "vgchange":
run(["vgchange", "-an", value], check=False)
elif kind == "zfsclone":
run(["zfs", "destroy", value], check=False)
elif kind == "dir":
if os.path.isdir(value):
os.rmdir(value)
else:
continue
removed.append("%s %s" % (kind, value))
if verbose:
print("aufgeraeumt: %s %s" % (kind, value))
except (SnapfsError, OSError) as exc:
if verbose:
print("konnte %s %s nicht aufraeumen: %s" % (kind, value, exc))
_registry_remove(kind, value)
del extra
return removed
def _umount(mountpoint):
if not os.path.ismount(mountpoint):
return
try:
run(["umount", mountpoint])
except SnapfsError:
run(["umount", "-l", mountpoint], check=False) # notfalls verzoegert
# ---------------------------------------------------------------------------
# Sitzung
# ---------------------------------------------------------------------------
class Session:
"""Ein geoeffneter Snapshot: eingebundene Geraete und Mountpunkte."""
def __init__(self, proxmox, guest, snapname):
self.proxmox = proxmox
self.guest = guest
self.snapname = snapname
self.volumes = []
self.devices = []
self.mounts = {} # Geraetepfad -> Mountpunkt
self.notes = [] # Hinweise fuer die Oberflaeche
self._cleanup = [] # (art, wert) in umgekehrter Reihenfolge
# -- oeffnen ----------------------------------------------------------
def open(self):
config = snapshot_config(self.proxmox, self.guest, self.snapname)
self.volumes = volumes_from_config(config, self.guest.type)
if not self.volumes:
raise SnapfsError("Der Snapshot enthaelt keine Datentraeger.")
return self.volumes
def prepare(self):
"""Alle Datentraeger einbinden und mountbare Dateisysteme mounten."""
for volume in self.volumes:
try:
self._prepare_volume(volume)
except SnapfsError as exc:
self.notes.append("%s: %s" % (volume.key, exc))
log.warning("%s (%s): %s", volume.key, volume.volid, exc)
if not self.mounts:
# Ohne Mountpunkt gibt es nichts zu durchsuchen. Was gefunden wurde,
# gehoert trotzdem in die Meldung - sonst raet man nur.
details = list(self.notes)
if self.devices:
details.append("Gefunden wurden: %s"
% ", ".join("%s (%s, %s)"
% (d.path, d.fstype or "kein Dateisystem",
d.human_size)
for d in self.devices))
raise SnapfsError("Kein Dateisystem des Snapshots liess sich einhaengen."
+ ("\n- " + "\n- ".join(details) if details else ""))
return self.mounts
def _prepare_volume(self, volume):
info = storage_info(self.proxmox, volume.storage)
kind = info.get("type")
if kind == "zfspool" and self.guest.type == "lxc":
# Container liegen auf ZFS als Dateisystem - der Snapshot ist ohne
# Umweg unter .zfs/snapshot/<name> sichtbar.
path = self._zfs_snapshot_dir(info, volume)
if path:
self.mounts[path] = path
self.notes.append("%s: direkt aus %s" % (volume.key, path))
return
device_path = self._map(info, volume)
run(["udevadm", "settle", "--timeout=10"], check=False)
devices = self._scan(device_path, volume)
# Viele Gaeste legen ihre Dateisysteme in LVM ab - dann liegt hinter der
# Partition erst einmal nur ein "LVM2_member".
for device in list(devices):
if device.fstype == "LVM2_member":
devices.extend(self._activate_guest_lvm(device, volume))
for device in devices:
self.devices.append(device)
if device.mountable:
try:
self.mount(device)
except SnapfsError as exc:
self.notes.append("%s: %s" % (device.path, exc))
# -- Einbinden je Storage-Typ -----------------------------------------
def _map(self, info, volume):
kind = info.get("type")
if kind == "rbd":
return self._map_rbd(info, volume)
if kind == "zfspool":
return self._map_zfs(info, volume)
if kind in ("lvmthin", "lvm"):
return self._map_lvm(info, volume)
if kind in ("dir", "nfs", "cifs", "glusterfs"):
return self._map_qcow(info, volume)
raise SnapfsError("Storage-Typ %r wird vom Explorer nicht unterstuetzt" % kind)
def _rbd_args(self, info):
args = []
storage = info.get("storage") or info.get("name") or ""
keyring = "/etc/pve/priv/ceph/%s.keyring" % storage
if info.get("monhost"):
args += ["-m", str(info["monhost"]).replace(" ", ",")]
args += ["--id", info.get("username") or "admin"]
if os.path.exists(keyring):
args += ["--keyring", keyring]
else:
args += ["--id", info.get("username") or "admin"]
return args
def _map_rbd(self, info, volume):
if not have("rbd"):
raise SnapfsError("'rbd' ist nicht installiert")
pool = info.get("pool") or "rbd"
namespace = info.get("namespace")
image = "%s/%s%s@%s" % (pool, (namespace + "/") if namespace else "",
volume.volname, self.snapname)
base = self._rbd_args(info)
try:
output = run(["rbd"] + base + ["map", "--read-only", image])
device = self._first_device(output)
self._remember("rbd", device)
return device
except SnapfsError as exc:
# krbd kann manche Image-Features nicht - rbd-nbd kann alle.
if not have("rbd-nbd"):
raise SnapfsError("%s (rbd-nbd waere die Alternative, ist aber "
"nicht installiert: apt install ceph-common)" % exc)
log.info("rbd map fehlgeschlagen (%s) - versuche rbd-nbd", exc)
output = run(["rbd-nbd"] + base + ["map", "--read-only", image], timeout=180)
device = self._first_device(output)
self._remember("rbd-nbd", device)
return device
def _zfs_dataset(self, info, volume):
pool = info.get("pool") or info.get("zfspool") or ""
return "%s/%s" % (pool.rstrip("/"), volume.volname)
def _zfs_snapshot_dir(self, info, volume):
dataset = self._zfs_dataset(info, volume)
try:
mountpoint = run(["zfs", "get", "-H", "-o", "value", "mountpoint", dataset])
except SnapfsError:
return ""
if not mountpoint or mountpoint in ("none", "-", "legacy"):
return ""
candidate = os.path.join(mountpoint, ".zfs", "snapshot", self.snapname)
return candidate if os.path.isdir(candidate) else ""
def _map_zfs(self, info, volume):
if not have("zfs"):
raise SnapfsError("'zfs' ist nicht installiert")
dataset = self._zfs_dataset(info, volume)
source = "%s@%s" % (dataset, self.snapname)
clone = "%s-pvesnap-%d" % (dataset, os.getpid())
run(["zfs", "clone", "-o", "readonly=on", source, clone])
self._remember("zfsclone", clone)
device = "/dev/zvol/%s" % clone
for _ in range(50): # udev braucht einen Moment
if os.path.exists(device):
return device
time.sleep(0.1)
raise SnapfsError("Klon %s ist nicht als Geraet aufgetaucht" % clone)
def _map_lvm(self, info, volume):
if not have("lvchange"):
raise SnapfsError("LVM-Werkzeuge sind nicht installiert")
vg = info.get("vgname") or ""
# Proxmox benennt Snapshot-LVs immer nach diesem Schema.
lv = "snap_%s_%s" % (volume.volname, self.snapname)
target = "%s/%s" % (vg, lv)
run(["lvchange", "-ay", "-K", "--readonly", target])
self._remember("lvm", target)
device = "/dev/%s/%s" % (vg, lv)
if not os.path.exists(device):
raise SnapfsError("%s ist nach dem Aktivieren nicht vorhanden" % device)
return device
def _map_qcow(self, info, volume):
if not have("qemu-nbd"):
raise SnapfsError("'qemu-nbd' ist nicht installiert (apt install qemu-utils)")
base = info.get("path") or ""
image = os.path.join(base, "images", str(self.guest.vmid), volume.volname)
if not os.path.exists(image):
raise SnapfsError("Abbilddatei nicht gefunden: %s" % image)
if not image.endswith(".qcow2"):
raise SnapfsError("nur qcow2 kann Snapshots enthalten (%s)"
% os.path.basename(image))
run(["modprobe", "nbd", "max_part=16"], check=False)
device = self._free_nbd()
run(["qemu-nbd", "--read-only", "--load-snapshot=%s" % self.snapname,
"--connect=%s" % device, image], timeout=180)
self._remember("nbd", device)
time.sleep(0.5)
return device
@staticmethod
def _free_nbd():
for index in range(16):
device = "/dev/nbd%d" % index
if not os.path.exists(device):
continue
try:
with open("/sys/block/nbd%d/size" % index) as handle:
if handle.read().strip() == "0":
return device
except OSError:
continue
raise SnapfsError("kein freies /dev/nbdN vorhanden")
@staticmethod
def _first_device(output):
for line in (output or "").splitlines():
line = line.strip()
if line.startswith("/dev/"):
return line
raise SnapfsError("Antwort ohne Geraetenamen: %s" % (output or "(leer)"))
# -- Partitionen und Dateisysteme -------------------------------------
def _scan(self, device_path, volume):
"""Partitionen des eingebundenen Geraets ermitteln."""
devices = self._lsblk(device_path, volume)
if len(devices) == 1 and not devices[0].mountable:
# Partitionstabelle vorhanden, aber noch nicht eingelesen
for tool in (["partx", "-a", device_path], ["kpartx", "-a", "-r", device_path]):
if have(tool[0]):
run(tool, check=False)
time.sleep(0.5)
devices = self._lsblk(device_path, volume)
if len(devices) > 1:
break
return devices
def _lsblk(self, device_path, volume):
try:
output = run(["lsblk", "-J", "-b", "-o",
"NAME,PATH,SIZE,FSTYPE,LABEL,TYPE", device_path])
data = json.loads(output)
except (SnapfsError, ValueError) as exc:
raise SnapfsError("Partitionen nicht lesbar: %s" % exc)
found = []
def walk(entries):
for entry in entries:
device = Device(
path=entry.get("path") or "",
name=entry.get("name") or "",
size=int(entry.get("size") or 0),
fstype=entry.get("fstype") or "",
label=entry.get("label") or "",
kind=entry.get("type") or "part",
volume=volume,
)
children = entry.get("children") or []
if device.path:
# Eine Platte mit Partitionen selbst nicht anbieten
if not (children and not device.fstype):
found.append(device)
if device.fstype == "LVM2_member":
self.notes.append(
"%s enthaelt LVM - im Gast angelegte Volume-Groups werden "
"nicht automatisch aktiviert (Namenskonflikte mit dem Host)"
% device.path)
walk(children)
walk(data.get("blockdevices") or [])
return found
# -- LVM im Gast ------------------------------------------------------
def _activate_guest_lvm(self, device, volume):
"""Volume-Group des Gastes aktivieren und ihre Volumes zurueckgeben.
Heikel ist der Namenskonflikt: heisst die Gruppe im Gast genauso wie
eine auf dem Host (typisch 'pve'), waere nicht mehr eindeutig, welche
gemeint ist. In dem Fall wird bewusst nichts aktiviert.
"""
if not have("pvs") or not have("vgchange"):
self.notes.append("%s enthaelt LVM, aber die LVM-Werkzeuge fehlen"
% device.path)
return []
groups = self._physical_volumes()
mine = groups.get(os.path.realpath(device.path)) or groups.get(device.path)
if not mine:
self.notes.append("%s: keine Volume-Group gefunden" % device.path)
return []
name, uuid = mine
# Gleicher Name, andere UUID = Konflikt mit einer Gruppe des Hosts.
for other_path, (other_name, other_uuid) in groups.items():
if other_name == name and other_uuid != uuid:
self.notes.append(
"LVM-Gruppe '%s' aus dem Gast heisst genauso wie eine auf dem "
"Host (%s) - sie wird nicht aktiviert, weil sonst nicht "
"eindeutig waere, welche gemeint ist." % (name, other_path))
return []
try:
run(["vgchange", "-ay", "--readonly", name], timeout=60)
except SnapfsError as exc:
self.notes.append("Volume-Group '%s' nicht aktivierbar: %s" % (name, exc))
return []
self._remember("vgchange", name)
run(["udevadm", "settle", "--timeout=10"], check=False)
log.info("LVM-Gruppe '%s' des Gastes aktiviert", name)
# Nach dem Aktivieren haengen die Logical Volumes unter dem Geraet.
found = [d for d in self._lsblk(device.path, volume)
if d.path != device.path and d.kind == "lvm"]
if not found:
self.notes.append("Volume-Group '%s' enthaelt keine lesbaren Volumes" % name)
return found
@staticmethod
def _physical_volumes():
"""{PV-Pfad: (VG-Name, VG-UUID)} - auch fuer noch nicht aktive Gruppen."""
try:
output = run(["pvs", "--noheadings", "--nosuffix", "-o",
"pv_name,vg_name,vg_uuid", "--separator", "|"], timeout=60)
except SnapfsError:
return {}
result = {}
for line in output.splitlines():
parts = [p.strip() for p in line.strip().split("|")]
if len(parts) >= 3 and parts[0] and parts[1]:
result[parts[0]] = (parts[1], parts[2])
return result
def mount(self, device):
if device.path in self.mounts:
return self.mounts[device.path]
name = "%s-%d-%s-%s" % (self.guest.type, self.guest.vmid,
re.sub(r"[^A-Za-z0-9_.-]", "_", self.snapname),
os.path.basename(device.path))
mountpoint = os.path.join(MOUNT_ROOT, name)
os.makedirs(mountpoint, exist_ok=True)
self._remember("dir", mountpoint)
options = MOUNT_OPTIONS.get(device.fstype, DEFAULT_MOUNT_OPTIONS)
attempts = [options]
if options != DEFAULT_MOUNT_OPTIONS:
attempts.append(DEFAULT_MOUNT_OPTIONS) # notfalls schlicht "ro"
last = None
for option_set in attempts:
try:
run(["mount", "-o", ",".join(option_set), "-t", device.fstype or "auto",
device.path, mountpoint])
self.mounts[device.path] = mountpoint
self._remember("mount", mountpoint)
log.info("%s (%s) eingehaengt unter %s",
device.path, device.fstype, mountpoint)
return mountpoint
except SnapfsError as exc:
last = exc
raise SnapfsError("%s nicht einhaengbar: %s" % (device.path, last))
# -- aufraeumen -------------------------------------------------------
def _remember(self, kind, value):
self._cleanup.append((kind, value))
_registry_add(kind, value)
def close(self):
for kind, value in reversed(self._cleanup):
try:
if kind == "mount":
_umount(value)
elif kind == "rbd":
run(["rbd", "unmap", value], check=False)
elif kind == "rbd-nbd":
run(["rbd-nbd", "unmap", value], check=False)
elif kind == "nbd":
run(["qemu-nbd", "--disconnect", value], check=False)
elif kind == "lvm":
run(["lvchange", "-an", value], check=False)
elif kind == "vgchange":
run(["vgchange", "-an", value], check=False)
elif kind == "zfsclone":
run(["zfs", "destroy", value], check=False)
elif kind == "dir" and os.path.isdir(value):
os.rmdir(value)
except (SnapfsError, OSError) as exc:
log.warning("Aufraeumen von %s %s fehlgeschlagen: %s", kind, value, exc)
_registry_remove(kind, value)
self._cleanup = []
self.mounts = {}
def __enter__(self):
return self
def __exit__(self, *_exc):
self.close()
return False
def list_snapshots(proxmox, guest):
"""Snapshots eines Gastes, neueste zuerst."""
snapshots = proxmox.list_snapshots(guest)
return sorted(snapshots, key=lambda s: s.snaptime, reverse=True)