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>
784 lines
30 KiB
Python
784 lines
30 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, 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)
|