Files
proxmox-snapshot-service/pvesnap/snapfs.py
T
duffyduckandClaude Opus 5 963463a1a1 Web-Oberflaeche als Dienst, Anmeldung mit den Proxmox-Benutzern
install.sh --with-webexplorer --port <nummer> richtet pvesnap-web.service
ein. Fehlt der Port, bricht das Skript mit einer Erklaerung ab; --port
ohne --with-webexplorer, ein Port ausserhalb 1-65535, etwas anderes als
eine Zahl und Port 8006 (Proxmox selbst) werden ebenfalls abgefangen -
und zwar vor der root-Pruefung, damit ein Tippfehler sofort auffaellt.
--help beschreibt jetzt alle Optionen mit Beispielen.

Port, Adresse und Anmeldeart stehen in /etc/pvesnap/web.conf und lassen
sich dort aendern, ohne die Unit anzufassen.

Angemeldet wird mit den Benutzern von Proxmox VE: dieselbe Maske aus
Benutzer, Passwort und Realm-Auswahl, geprueft ueber POST /access/ticket
auf der lokalen API - also derselbe Weg wie im Proxmox-Webinterface. Alle
Realms funktionieren damit automatisch; Zwei-Faktor-Anmeldungen werden
abgewiesen statt halb durchgewinkt.

Ein gueltiges Passwort allein reicht nicht: zusaetzlich braucht der
Benutzer auf dem Gast das Recht VM.Snapshot (geprueft auf /, /vms,
/vms/<id> und dem Pool). Sonst koennte jeder Proxmox-Benutzer saemtliche
Dateien aller Gaeste lesen. In der Gastliste erscheinen nur erlaubte
Gaeste, root@pam sieht wie in Proxmox alles. Anpassbar ueber
--require-privilege und --allow-user.

Ausserdem: SIGTERM haengt einen offenen Snapshot wieder aus (ohne das
bliebe er bei "systemctl stop" eingebunden), und beim Start werden Reste
von Prozessen abgeraeumt, die es nicht mehr gibt - parallel laufende
Sitzungen bleiben dabei unangetastet.

Auf pvetest01 geprueft: Dienststart ueber systemd, Anmeldemaske mit den
Realms des Hosts, Abweisung ohne und mit falschen Zugangsdaten (401,
protokolliert mit Absender-IP), Zugriff ohne Recht (403 auf Snapshot-
Liste und Oeffnen), vollstaendiger Ablauf als Berechtigter bis zum
Datei-Download, Abmelden macht die Sitzung ungueltig, und SIGTERM
hinterlaesst weder rbd-Maps noch Mountpunkte.

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

776 lines
29 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 _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/<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
@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):
"""Snapshots eines Gastes, neueste zuerst."""
snapshots = proxmox.list_snapshots(guest)
return sorted(snapshots, key=lambda s: s.snaptime, reverse=True)