"""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