"""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/) 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 _pid_alive(pid): if not pid: return False try: os.kill(int(pid), 0) return True except ProcessLookupError: return False except OSError: return True # existiert, gehoert uns nur nicht def cleanup_leftovers(verbose=False, only_dead=False): """Raeumt auf, was ein abgestuerzter Lauf hinterlassen hat. only_dead=True fasst nur an, was von einem nicht mehr laufenden Prozess stammt - so stoert der Start eines Dienstes keine parallel offene Sitzung. """ removed = [] for entry in reversed(_registry_load()): if only_dead and _pid_alive(entry.get("pid")): continue 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/ 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 @staticmethod def _probe(path): """Dateisystem eines Geraets direkt ermitteln: (Typ, Bezeichnung). Noetig, weil `lsblk` den Typ aus der udev-Datenbank nimmt - und die ist bei frisch eingebundenen rbd-Geraeten leer. lsblk meldet dann ueberall "kein Dateisystem", obwohl ext4 und vfat da sind. `blkid -p` umgeht Datenbank und Zwischenspeicher und schaut nach. """ output = run(["blkid", "-p", "-o", "export", path], check=False, timeout=30) values = {} for line in output.splitlines(): key, _, value = line.partition("=") values[key.strip()] = value.strip() return values.get("TYPE", ""), values.get("LABEL", "") 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 and not device.fstype and not children: device.fstype, probed_label = self._probe(device.path) device.label = device.label or probed_label 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)) # -- Uebersicht fuer die Oberflaechen --------------------------------- def mount_entries(self): """[(Mountpunkt, Beschriftung)] - wahrscheinlichste Wurzel zuerst. Sonst landet man beim Oeffnen leicht auf der 500-MB-EFI-Partition statt im eigentlichen System. """ by_mount = {} for device in self.devices: mountpoint = self.mounts.get(device.path) if mountpoint: by_mount[mountpoint] = device for mountpoint in self.mounts.values(): by_mount.setdefault(mountpoint, None) scored = [(self._score(mountpoint, device), mountpoint, device) for mountpoint, device in by_mount.items()] scored.sort(key=lambda item: -item[0]) return [(mountpoint, self._describe_mount(mountpoint, device)) for _score, mountpoint, device in scored] @staticmethod def _score(mountpoint, device): try: names = set(os.listdir(mountpoint)) except OSError: names = set() score = 0 if {"etc", "usr"} <= names: score += 1000 # Linux-Wurzel elif "etc" in names or "Windows" in names: score += 500 elif {"EFI"} & names: score -= 200 # reine Startpartition score += int((device.size if device else 0) / (1024 ** 3)) return score @staticmethod def _describe_mount(mountpoint, device): try: names = set(os.listdir(mountpoint)) except OSError: names = set() if {"etc", "usr"} <= names: hint = "Linux-Wurzelverzeichnis" elif "Windows" in names: hint = "Windows" elif "EFI" in names or "bootmgr" in names: hint = "Startpartition" elif "vmlinuz" in names or "grub" in names: hint = "Boot" else: hint = ", ".join(sorted(names)[:3]) or "leer" if device is None: return "%s (%s)" % (os.path.basename(mountpoint), hint) return "%-12s %-6s %8s %s%s" % ( os.path.basename(device.path), device.fstype or "?", device.human_size, ('"%s" ' % device.label) if device.label else "", hint) # -- 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, 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)