Transfer-Laufwerke: Engine und Werkzeugpruefung im Installer

Benannte, dauerhafte Austauschmedien zwischen Host und Gast - Abbilddateien
unter /var/lib/pvesnap/transfer, die zu keinem Gast gehoeren und jede
Wiederherstellung ueberleben.

Zwei Entwurfsentscheidungen, beide am Host geprueft:

  Datei statt Proxmox-Volume. Ein Volume wuerde beim Entfernen der Maschine
  mitgeloescht, auch aus der Weboberflaeche heraus - die vorbereitete
  Werkzeugsammlung waere weg. Pfade ueberspringt destroy_vm ausdruecklich
  ("return if $volid =~ m|^/|"). Nachgestellt: VM mit --purge entfernt, das
  Abbild lebte samt Inhalt weiter.

  Loop-Geraet statt direktem Pfad. Eine Abbilddatei als scsi3 einzutragen
  lehnt Proxmox ab ("unable to associate path to any storage"), /dev/... wird
  dagegen durchgereicht. Ueber losetup -P bekommt der Gast eine Platte mit
  Partitionstabelle - ohne die zeigt Windows nur "nicht initialisiert".

Verriegelung, am laufenden System geprueft: ein Laufwerk ist entweder am Host
eingehaengt oder an einem Gast, nie beides. Alle drei Wege wurden abgelehnt -
anhaengen waehrend Host-Mount, Host-Mount waehrend angehaengt, loeschen
waehrend in Benutzung. Der Gast-Zustand wird aus den Konfigurationsdateien
gelesen, gilt also auch fuer gestoppte Maschinen.

Installer: parted und exfatprogs werden bei Bedarf nachinstalliert
(--no-install-deps schaltet das ab). ntfs-3g bewusst nicht - apt entfernt
dafuer 'fuse', an dem pve-cluster (/etc/pve) und ceph-fuse haengen. Dazu gibt
es nur einen Hinweis samt Begruendung; fuer Windows ist exFAT ohnehin die
einfachere Wahl.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
duffyduck
2026-07-31 20:24:22 +02:00
co-authored by Claude Opus 5
parent 2c85011a08
commit f44ad0e34a
2 changed files with 680 additions and 1 deletions
+623
View File
@@ -0,0 +1,623 @@
"""Transfer-Laufwerke - benannte Austauschmedien zwischen Host und Gast.
Ein wiederhergestellter Snapshot laeuft ohne Netzwerk. Damit trotzdem etwas
hinein- und wieder herauskommt, gibt es Transfer-Laufwerke: schlichte
Abbilddateien, die auf dem Host ganz normal eingehaengt werden koennen und sich
einem Gast als zusaetzliche Platte anhaengen lassen.
/var/lib/pvesnap/transfer/dumps.img 20G, exfat, "Datenbank-Dumps"
/var/lib/pvesnap/transfer/werkzeuge.img 2G, ext4, "Skripte und Tools"
Sie gehoeren zu keinem Gast und ueberleben jede Wiederherstellung. Man legt sie
einmal an, befuellt sie in Ruhe, und waehlt sie beim naechsten Notfall nur noch
aus einer Liste aus.
Warum eine Datei und kein Proxmox-Volume?
* Ein Proxmox-Volume wuerde beim Entfernen der Maschine mitgeloescht - und
zwar auch dann, wenn jemand sie in der Weboberflaeche wegwirft. Die
muehsam vorbereitete Werkzeugsammlung waere weg.
* Bei Pfaden ist das ausgeschlossen: PVE::QemuServer::destroy_vm ueberspringt
sie ausdruecklich ("return if $volid =~ m|^/|").
Angehaengt wird ueber ein Loop-Geraet, weil Proxmox als Pfad nur /dev/...
akzeptiert - eine Abbilddatei direkt einzutragen lehnt es ab ("unable to
associate path to any storage").
Damit sich Host und Gast nicht gegenseitig das Dateisystem zerlegen, darf ein
Laufwerk immer nur an einer Stelle in Benutzung sein. Darueber wacht diese
Schicht.
"""
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.transfer")
TRANSFER_DIR = "/var/lib/pvesnap/transfer"
INDEX = os.path.join(TRANSFER_DIR, "index.json")
MOUNT_ROOT = "/run/pvesnap/transfer"
CONF_ROOT = "/etc/pve/nodes"
NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,30}$")
# Was sich womit anlegen laesst. Lesen kann der Kernel mehr, als er anlegen
# kann - exfat und ntfs3 sind eingebaut, die Formatierer aber Extrapakete.
FILESYSTEMS = {
"ext4": {
"mkfs": ["mkfs.ext4", "-q", "-m", "0", "-L"],
"parted": "ext4",
"fuer": "Linux-Gaeste",
"paket": "e2fsprogs",
},
"xfs": {
"mkfs": ["mkfs.xfs", "-q", "-L"],
"parted": "xfs",
"fuer": "Linux-Gaeste",
"paket": "xfsprogs",
},
"exfat": {
"mkfs": ["mkfs.exfat", "-L"],
"parted": "ntfs",
"fuer": "Windows und Linux - fuer Windows die erste Wahl",
"paket": "exfatprogs",
"hinweis": "apt install exfatprogs (281 KB, entfernt nichts)",
},
"ntfs": {
"mkfs": ["mkfs.ntfs", "-Q", "-F", "-L"],
"parted": "ntfs",
"fuer": "Windows",
"paket": "ntfs-3g",
# Bewusst als Warnung: apt will dafuer 'fuse' entfernen, und daran
# haengen ceph-fuse und pve-cluster - also /etc/pve.
"hinweis": ("apt install ntfs-3g - ACHTUNG: entfernt dabei 'fuse', an dem "
"pve-cluster (/etc/pve) und ceph-fuse haengen. Nur mit Bedacht "
"und nicht im laufenden Betrieb. Meist ist exfat die bessere Wahl."),
},
"vfat": {
"mkfs": ["mkfs.vfat", "-n"],
"parted": "fat32",
"fuer": "alles, aber nur Dateien bis 4 GB",
"paket": "dosfstools",
},
}
# Ohne Partitionstabelle zeigt Windows den Datentraeger als "nicht
# initialisiert" - erst danach gibt es einen Laufwerksbuchstaben.
DEFAULT_FS = "ext4"
DEFAULT_SIZE = "20G"
README = """Transfer-Laufwerk von pvesnap
Dieses Laufwerk gehoert zu keiner Maschine. Es dient nur dazu, Dateien
zwischen dem Proxmox-Host und einem wiederhergestellten Snapshot hin und her
zu schaufeln - der hat ja kein Netzwerk.
Im Gast : Datentraeger mit der Bezeichnung %s einhaengen
Auf dem Host: pvesnap-recovery -> Taste v -> Enter
Alles, was hier liegt, bleibt erhalten - auch wenn die Wiederherstellung
laengst verworfen ist.
"""
class TransferError(Exception):
"""Ein Transfer-Laufwerk liess sich nicht anlegen oder benutzen."""
# ---------------------------------------------------------------------------
# Kommandos
# ---------------------------------------------------------------------------
def run(args, timeout=300, check=True):
args = [str(a) for a in args]
args[0] = which(args[0]) or args[0]
log.debug("exec: %s", " ".join(args))
try:
proc = subprocess.run(args, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, timeout=timeout)
except FileNotFoundError:
raise TransferError("Kommando nicht gefunden: %s" % args[0])
except subprocess.TimeoutExpired:
raise TransferError("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:
detail = (stderr or stdout or "Fehler").splitlines()
raise TransferError("%s: %s" % (args[0], detail[-1] if detail else "Fehler"))
return stdout
# Die Formatierer liegen in sbin. Wer pvesnap aus einer Umgebung mit magerem
# PATH aufruft - etwa als systemd-Dienst - findet sie sonst nicht, obwohl sie
# da sind.
_SBIN = ("/usr/local/sbin", "/usr/sbin", "/sbin")
def which(command):
found = shutil.which(command)
if found:
return found
for directory in _SBIN:
candidate = os.path.join(directory, command)
if os.access(candidate, os.X_OK):
return candidate
return None
def have(command):
return which(command) is not None
def available_filesystems():
"""[(name, verfuegbar, beschreibung)] - was sich hier anlegen laesst."""
result = []
for name in ("ext4", "exfat", "ntfs", "xfs", "vfat"):
spec = FILESYSTEMS[name]
result.append((name, have(spec["mkfs"][0]), spec))
return result
def human_bytes(count):
value = float(count or 0)
for unit in ("B", "K", "M", "G", "T"):
if value < 1024 or unit == "T":
return "%d%s" % (value, unit) if unit == "B" else "%.1f%s" % (value, unit)
value /= 1024
return "%.1fT" % value
_SIZE = re.compile(r"^\s*(\d+(?:[.,]\d+)?)\s*([KMGT])?i?B?\s*$", re.I)
_FACTOR = {"K": 1024, "M": 1024 ** 2, "G": 1024 ** 3, "T": 1024 ** 4}
def parse_size(text, default_unit="G"):
match = _SIZE.match(str(text or ""))
if not match:
raise TransferError("Groessenangabe nicht verstanden: %r (erwartet z.B. 20G)"
% text)
value = float(match.group(1).replace(",", "."))
return max(1024 ** 2, int(value * _FACTOR[(match.group(2) or default_unit).upper()]))
# ---------------------------------------------------------------------------
# Datenmodell
# ---------------------------------------------------------------------------
@dataclass
class Volume:
name: str
image: str = ""
size: int = 0
fs: str = DEFAULT_FS
label: str = ""
note: str = ""
created: int = 0
partitioned: bool = True
# zur Laufzeit ermittelt, nicht gespeichert
loop: str = ""
mountpoint: str = ""
attached_to: int = 0
@property
def state(self):
if self.mountpoint:
return "host"
if self.attached_to:
return "guest"
return "frei"
@property
def state_text(self):
return {"host": "am Host eingehaengt",
"guest": "an VM %d angehaengt" % self.attached_to,
"frei": "frei"}[self.state]
@property
def free(self):
return self.state == "frei"
@property
def used_bytes(self):
try:
return os.stat(self.image).st_blocks * 512
except OSError:
return 0
def to_dict(self):
return {k: getattr(self, k) for k in
("name", "image", "size", "fs", "label", "note", "created",
"partitioned")}
@classmethod
def from_dict(cls, data):
known = {k: data.get(k) for k in
("name", "image", "size", "fs", "label", "note", "created",
"partitioned") if data.get(k) is not None}
return cls(**known)
# ---------------------------------------------------------------------------
# Verzeichnis und Verzeichnisdatei
# ---------------------------------------------------------------------------
def _index_load():
try:
with open(INDEX, "r", encoding="utf-8") as handle:
data = json.load(handle)
return [Volume.from_dict(e) for e in data] if isinstance(data, list) else []
except (OSError, ValueError, TypeError):
return []
def _index_save(volumes):
try:
os.makedirs(TRANSFER_DIR, exist_ok=True)
tmp = INDEX + ".tmp"
with open(tmp, "w", encoding="utf-8") as handle:
json.dump([v.to_dict() for v in volumes], handle, indent=1)
os.replace(tmp, INDEX)
except OSError as exc:
raise TransferError("Verzeichnisdatei %s nicht schreibbar: %s" % (INDEX, exc))
def _index_put(volume):
volumes = [v for v in _index_load() if v.name != volume.name]
volumes.append(volume)
_index_save(sorted(volumes, key=lambda v: v.name))
def _index_drop(name):
_index_save([v for v in _index_load() if v.name != name])
# ---------------------------------------------------------------------------
# Zustand aus der Wirklichkeit lesen
# ---------------------------------------------------------------------------
def _loop_devices():
"""{Abbildpfad: Loop-Geraet}"""
try:
data = json.loads(run(["losetup", "--json", "--list"], check=False) or "{}")
except ValueError:
return {}
result = {}
for entry in data.get("loopdevices") or []:
backing = entry.get("back-file") or ""
# geloeschte Abbilder haengt losetup ein " (deleted)" an
backing = backing.split(" (deleted)")[0]
if backing:
result[os.path.realpath(backing)] = entry.get("name") or ""
return result
def _mounts():
"""{Geraet: Mountpunkt}"""
result = {}
try:
with open("/proc/mounts", "r", encoding="utf-8") as handle:
for line in handle:
parts = line.split()
if len(parts) >= 2:
result[parts[0]] = parts[1].replace("\\040", " ")
except OSError:
pass
return result
def _guest_usage():
"""{Loop-Geraet: VMID} - wer haelt gerade welches Geraet?
Gelesen aus den Gast-Konfigurationen, weil das die Wahrheit ist: dort
steht der Eintrag auch dann, wenn die Maschine gerade nicht laeuft.
"""
usage = {}
device = re.compile(r"(/dev/loop\d+)")
try:
nodes = os.listdir(CONF_ROOT)
except OSError:
return usage
for node in nodes:
for sub in ("qemu-server", "lxc"):
directory = os.path.join(CONF_ROOT, node, sub)
try:
names = os.listdir(directory)
except OSError:
continue
for name in names:
if not (name.endswith(".conf") and name[:-5].isdigit()):
continue
try:
with open(os.path.join(directory, name), "r",
encoding="utf-8", errors="replace") as handle:
text = handle.read()
except OSError:
continue
for found in device.findall(text):
usage[found] = int(name[:-5])
return usage
def load():
"""Alle Transfer-Laufwerke mit ihrem tatsaechlichen Zustand."""
loops = _loop_devices()
mounts = _mounts()
usage = _guest_usage()
volumes = []
for volume in _index_load():
if not os.path.exists(volume.image):
volume.note = (volume.note + " [Abbilddatei fehlt]").strip()
volumes.append(volume)
continue
volume.loop = loops.get(os.path.realpath(volume.image), "")
if volume.loop:
# Bei partitionierten Abbildern haengt das Dateisystem auf ...p1
for candidate in (volume.loop + "p1", volume.loop):
if candidate in mounts:
volume.mountpoint = mounts[candidate]
break
volume.attached_to = usage.get(volume.loop, 0)
volumes.append(volume)
return volumes
def get(name):
for volume in load():
if volume.name == name:
return volume
raise TransferError("Kein Transfer-Laufwerk mit dem Namen %r. "
"Vorhandene zeigt: pvesnap-recovery transfer list" % name)
# ---------------------------------------------------------------------------
# Anlegen
# ---------------------------------------------------------------------------
def create(name, size=DEFAULT_SIZE, fs=DEFAULT_FS, note="", partitioned=True,
progress=None):
"""Ein neues Transfer-Laufwerk anlegen und formatieren."""
def step(text):
log.info("%s", text)
if progress:
progress(text)
name = str(name or "").strip().lower()
if not NAME_PATTERN.match(name):
raise TransferError("Ungueltiger Name %r - erlaubt sind Kleinbuchstaben, "
"Ziffern, Bindestrich und Unterstrich (max. 31)." % name)
if any(v.name == name for v in _index_load()):
raise TransferError("Ein Transfer-Laufwerk namens %r gibt es schon." % name)
if fs not in FILESYSTEMS:
raise TransferError("Unbekanntes Dateisystem %r (%s)"
% (fs, ", ".join(sorted(FILESYSTEMS))))
spec = FILESYSTEMS[fs]
if not have(spec["mkfs"][0]):
raise TransferError("%s fehlt - %s kann hier nicht angelegt werden.\n%s"
% (spec["mkfs"][0], fs,
spec.get("hinweis") or "apt install %s" % spec["paket"]))
if partitioned and not have("parted"):
raise TransferError("'parted' fehlt (apt install parted) - ohne "
"Partitionstabelle zeigt Windows den Datentraeger nur "
"als 'nicht initialisiert'.")
size_bytes = parse_size(size)
label = ("PVESNAP-%s" % name.upper().replace("_", "-"))[:11 if fs == "vfat" else 32]
image = os.path.join(TRANSFER_DIR, "%s.img" % name)
os.makedirs(TRANSFER_DIR, exist_ok=True)
step("Lege %s an (%s, duenn belegt)" % (image, human_bytes(size_bytes)))
try:
with open(image, "wb") as handle:
handle.truncate(size_bytes)
except OSError as exc:
raise TransferError("%s nicht anlegbar: %s" % (image, exc))
volume = Volume(name=name, image=image, size=size_bytes, fs=fs, label=label,
note=note, created=int(time.time()), partitioned=partitioned)
try:
if partitioned:
step("Partitionstabelle anlegen (GPT, eine Partition)")
run(["parted", "-s", image, "mklabel", "gpt",
"mkpart", "primary", spec["parted"], "1MiB", "100%"])
with _loop(image, partitioned) as device:
step("Formatieren mit %s, Bezeichnung %s" % (fs, label))
run(spec["mkfs"] + [label, device], timeout=1800)
_place_readme(device, fs, label)
except Exception:
try:
os.unlink(image)
except OSError:
pass
raise
_index_put(volume)
step("Fertig: %s" % name)
return volume
class _loop:
"""Ein Abbild voruebergehend als Blockgeraet - bei Bedarf die Partition."""
def __init__(self, image, partitioned=True):
self.image = image
self.partitioned = partitioned
self.device = ""
def __enter__(self):
self.device = attach_loop(self.image, self.partitioned)
return partition_of(self.device) if self.partitioned else self.device
def __exit__(self, *_exc):
detach_loop(self.device)
return False
def attach_loop(image, partitioned=True):
"""Abbild an ein freies Loop-Geraet haengen (oder das vorhandene liefern)."""
existing = _loop_devices().get(os.path.realpath(image))
if existing:
return existing
args = ["losetup", "--find", "--show"]
if partitioned:
args.insert(1, "-P") # Partitionen als /dev/loopNp1 anbieten
device = run(args + [image], timeout=120)
if not device.startswith("/dev/"):
raise TransferError("losetup lieferte keinen Geraetenamen: %s" % device)
run(["udevadm", "settle", "--timeout=10"], check=False)
return device
def detach_loop(device):
if device:
run(["losetup", "-d", device], check=False, timeout=60)
def partition_of(device):
"""/dev/loop0 -> /dev/loop0p1, falls es die Partition gibt."""
for _ in range(30):
candidate = device + "p1"
if os.path.exists(candidate):
return candidate
time.sleep(0.1)
return device
def _place_readme(device, fs, label):
mountpoint = os.path.join(MOUNT_ROOT, ".neu-%d" % os.getpid())
try:
os.makedirs(mountpoint, exist_ok=True)
run(["mount", device, mountpoint], timeout=120)
except (TransferError, OSError) as exc:
log.debug("Anleitung nicht ablegbar: %s", exc)
return
try:
with open(os.path.join(mountpoint, "LIESMICH.txt"), "w",
encoding="utf-8") as handle:
handle.write(README % label)
except OSError as exc:
log.debug("LIESMICH.txt nicht schreibbar: %s", exc)
finally:
run(["umount", mountpoint], check=False, timeout=120)
try:
os.rmdir(mountpoint)
except OSError:
pass
# ---------------------------------------------------------------------------
# Am Host benutzen
# ---------------------------------------------------------------------------
def host_mount(volume):
"""Auf dem Host einhaengen; liefert den Pfad."""
volume = get(volume.name if isinstance(volume, Volume) else volume)
if volume.mountpoint:
return volume.mountpoint
if volume.attached_to:
raise TransferError(
"%r haengt gerade an VM %d. Erst dort abhaengen - Host und Gast "
"duerfen nicht gleichzeitig darauf schreiben, das zerlegt das "
"Dateisystem." % (volume.name, volume.attached_to))
if not os.path.exists(volume.image):
raise TransferError("Abbilddatei fehlt: %s" % volume.image)
device = attach_loop(volume.image, volume.partitioned)
target = partition_of(device) if volume.partitioned else device
mountpoint = os.path.join(MOUNT_ROOT, volume.name)
os.makedirs(mountpoint, exist_ok=True)
try:
run(["mount", target, mountpoint], timeout=120)
except TransferError:
detach_loop(device)
raise
log.info("Transfer-Laufwerk %r eingehaengt unter %s", volume.name, mountpoint)
return mountpoint
def host_umount(volume):
"""Wieder aushaengen und das Loop-Geraet freigeben."""
volume = get(volume.name if isinstance(volume, Volume) else volume)
if volume.mountpoint:
run(["umount", volume.mountpoint], check=False, timeout=120)
try:
os.rmdir(volume.mountpoint)
except OSError:
pass
if volume.loop and not volume.attached_to:
detach_loop(volume.loop)
return True
# ---------------------------------------------------------------------------
# An einen Gast haengen
# ---------------------------------------------------------------------------
def drive_string(volume):
"""Der Wert, der in die Gast-Konfiguration geschrieben wird.
Proxmox nimmt als Pfad nur /dev/... - eine Abbilddatei lehnt es ab. Und
weil es ein Pfad ist, wird er beim Entfernen der Maschine nie freigegeben.
"""
volume = get(volume.name if isinstance(volume, Volume) else volume)
if volume.mountpoint:
raise TransferError(
"%r ist gerade auf dem Host eingehaengt (%s). Erst dort aushaengen."
% (volume.name, volume.mountpoint))
if volume.attached_to:
raise TransferError("%r haengt bereits an VM %d."
% (volume.name, volume.attached_to))
device = attach_loop(volume.image, volume.partitioned)
return "%s,backup=0" % device
def release(volume):
"""Nach dem Verwerfen einer Maschine das Loop-Geraet wieder freigeben."""
try:
volume = get(volume.name if isinstance(volume, Volume) else volume)
except TransferError:
return False
if volume.mountpoint or volume.attached_to:
return False
if volume.loop:
detach_loop(volume.loop)
return True
def remove(name, force=False):
"""Ein Transfer-Laufwerk endgueltig loeschen."""
volume = get(name)
if not volume.free and not force:
raise TransferError("%r ist gerade in Benutzung (%s)."
% (name, volume.state_text))
if volume.mountpoint:
run(["umount", volume.mountpoint], check=False, timeout=120)
if volume.loop:
detach_loop(volume.loop)
try:
if os.path.exists(volume.image):
os.unlink(volume.image)
except OSError as exc:
raise TransferError("%s nicht loeschbar: %s" % (volume.image, exc))
_index_drop(name)
return True
def cleanup_loops():
"""Loop-Geraete freigeben, die niemand mehr braucht."""
freed = []
for volume in load():
if volume.loop and not volume.mountpoint and not volume.attached_to:
detach_loop(volume.loop)
freed.append(volume.name)
return freed