Files
proxmox-snapshot-service/pvesnap/transfer.py
T
duffyduckandClaude Opus 5 6279a20d4a Austauschlaufwerk war fuer den Benutzer im Gast nicht beschreibbar
ext4 speichert Besitzer und Rechte auf dem Datentraeger. Das Wurzelverzeichnis
gehoerte root, weil der Host es angelegt hat - im Gast sitzt davor aber ein
gewoehnlicher Benutzer, der dann nichts hineinschreiben kann. Das sieht aus
wie ein schreibgeschuetzter Datentraeger, ist aber eine Rechtefrage.

Das Wurzelverzeichnis wird beim Anlegen jetzt auf 0777 gesetzt - in beiden
Wegen, dem alten --exchange und den neuen Transfer-Laufwerken. Bei exFAT und
FAT laeuft das ins Leere, weil sie ueberhaupt keine Besitzrechte kennen; dort
entscheidet der Einhaengevorgang, und der Automounter im Gast setzt den
angemeldeten Benutzer als Eigentuemer. Genau deshalb ist exFAT auch fuer
Linux-Gaeste die unkompliziertere Wahl.

  vorher   drwxr-xr-x  root  -> Benutzer im Gast kann nur lesen
  nachher  drwxrwxrwx  root  -> schreiben moeglich

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

645 lines
22 KiB
Python

"""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. Die Partitionstabelle macht in jedem Fall
# parted, das Dateisystem der jeweilige Formatierer.
FILESYSTEMS = {
"ext4": {
"mkfs": ["mkfs.ext4", "-q", "-m", "0", "-L"],
"fuer": "nur Linux, behaelt Rechte und Besitzer",
"paket": "e2fsprogs",
},
"xfs": {
"mkfs": ["mkfs.xfs", "-q", "-L"],
"fuer": "nur Linux",
"paket": "xfsprogs",
},
"exfat": {
"mkfs": ["mkfs.exfat", "-L"],
"fuer": "Windows und Linux, keine Groessengrenze je Datei",
"paket": "exfatprogs",
"hinweis": "apt install exfatprogs (281 KB, entfernt nichts)",
},
"vfat": {
"mkfs": ["mkfs.vfat", "-n"],
"fuer": "ueberall lesbar, aber nur Dateien bis 4 GB",
"paket": "dosfstools",
},
}
# Wie lang die Datentraegerbezeichnung sein darf. exFAT und FAT lassen nur
# 11 Zeichen zu - laenger, und mkfs bricht ab.
LABEL_MAX = {"vfat": 11, "exfat": 11, "xfs": 12, "ext4": 16}
# exFAT als Vorgabe: Windows und Linux koennen beide damit umgehen, und anders
# als FAT32 gibt es keine 4-GB-Grenze je Datei. Fehlt der Formatierer, faellt
# die Wahl auf ext4 zurueck.
DEFAULT_SIZE = "20G"
def default_filesystem():
return "exfat" if have("mkfs.exfat") else "ext4"
DEFAULT_FS = "exfat"
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 ("exfat", "ext4", "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 = "ext4"
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=None, 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)
fs = fs or default_filesystem()
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 = _label_for(name, fs)
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:
# Die Typ-Kennung "msftdata" (Microsoft basic data) ist der
# entscheidende Teil: nur darauf vergibt Windows von selbst einen
# Laufwerksbuchstaben. Ohne sie vergibt parted "Linux filesystem",
# und Windows zeigt die Partition zwar an, haengt sie aber nicht ein.
step("Partitionstabelle anlegen (GPT, eine Partition, msftdata)")
run(["parted", "-s", image, "mklabel", "gpt",
"mkpart", "primary", "1MiB", "100%",
"set", "1", "msftdata", "on"])
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 _label_for(name, fs):
"""Datentraegerbezeichnung - so, dass man sie im Gast wiederfindet.
Kurz gehalten, weil exFAT und FAT nur 11 Zeichen zulassen. Im Gast reicht
damit: mount /dev/disk/by-label/<NAME> /mnt
"""
clean = re.sub(r"[^A-Z0-9_-]", "", name.upper())
return clean[:LABEL_MAX.get(fs, 11)] or "PVESNAP"
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:
# Sonst gehoert das Wurzelverzeichnis root - und im Gast sitzt davor
# ein gewoehnlicher Benutzer, der nichts hineinschreiben kann. Das
# sieht dann aus wie ein schreibgeschuetzter Datentraeger, ist aber
# eine Rechtefrage. Bei exFAT und FAT laeuft chmod ins Leere, weil sie
# ueberhaupt keine Besitzrechte kennen - dort ist es ohnehin egal.
try:
os.chmod(mountpoint, 0o777)
except OSError:
pass
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