"""Einen Snapshot als eigenstaendige Maschine starten. Zwei Betriebsarten: live Der Snapshot laeuft abgeschottet - ohne Netzwerk. Gedacht zum Hineinschauen: ueber die noVNC-Konsole arbeiten, einen Datenbank-Dump ziehen und ihn ueber ein Austauschlaufwerk wieder herausholen. recovery Der Snapshot laeuft mit Netzwerk und behaelt MAC-Adressen, SMBIOS-UUID und bei Containern den Hostnamen. Fuer alles drumherum ist er dieselbe Maschine - entsprechend gefaehrlich, solange das Original noch laeuft. Die Datentraeger werden nicht kopiert, sondern geklont. Auf Ceph, ZFS und LVM-thin ist das ein Copy-on-Write-Klon: er dauert Sekunden, egal wie gross die Platte ist, und das Original wird dabei nicht angefasst. Dafuer wird `PVE::Storage::vdisk_clone` verwendet - dieselbe Funktion, die auch Proxmox selbst benutzt, samt Cluster-Sperre auf dem Storage. Enthaelt der Snapshot den Arbeitsspeicher (vmstate, also "mit RAM"), wird der mitgenommen: die Maschine laeuft dann genau dort weiter, wo sie beim Snapshot stand, statt wie nach einem Stromausfall zu booten. Fuer einen sauberen Datenbank-Dump ist das der entscheidende Unterschied. """ from __future__ import annotations import json import logging import os import re import shutil import subprocess import time from dataclasses import dataclass, field from .proxmox import Guest, ProxmoxError log = logging.getLogger("pvesnap.recovery") TAG = "pvesnap-recovery" STATE_DIR = "/var/lib/pvesnap" REGISTRY = os.path.join(STATE_DIR, "recovery.json") EXCHANGE_ROOT = os.path.join(STATE_DIR, "exchange") EXCHANGE_MOUNT = "/run/pvesnap/exchange" CONF_ROOT = "/etc/pve/nodes" QEMU_DISK_KEY = re.compile(r"^(ide|sata|scsi|virtio)\d+$") QEMU_EXTRA_DISKS = ("efidisk0", "tpmstate0") LXC_DISK_KEY = re.compile(r"^(rootfs|mp\d+)$") NET_KEY = re.compile(r"^net\d+$") # Nichts uebernehmen, was zum Snapshot oder zur Einbettung des Originals # gehoert. vmstate/runningmachine/runningcpu werden spaeter gezielt gesetzt. # # vmgenid steht bewusst NICHT hier: die Kennung gehoert zur Identitaet der # Maschine ("als waere es dieselbe") und steckt ausserdem als eigenes Geraet # im gespeicherten Arbeitsspeicher. Fehlt sie, bricht das Laden des RAM-Standes # mit "Unknown savevm section or instance 'vmgenid'" ab. DROP_KEYS = { "parent", "snaptime", "digest", "snapstate", "pending", "lock", "template", "description", "tags", "onboot", "startup", "protection", "hookscript", "replicate", "vmstate", "runningmachine", "runningcpu", } DEFAULT_EXCHANGE_SIZE = "10G" DEFAULT_EXCHANGE_FS = "ext4" EXCHANGE_LABEL = "PVESNAP" EXCHANGE_GUEST_PATH = "/mnt/pvesnap" MKFS = { "ext4": ["mkfs.ext4", "-q", "-m", "0", "-L", EXCHANGE_LABEL], "ext3": ["mkfs.ext3", "-q", "-m", "0", "-L", EXCHANGE_LABEL], "xfs": ["mkfs.xfs", "-q", "-L", EXCHANGE_LABEL], "vfat": ["mkfs.vfat", "-n", EXCHANGE_LABEL], "exfat": ["mkfs.exfat", "-L", EXCHANGE_LABEL], } README = """Austauschlaufwerk von pvesnap-recovery Dieses Laufwerk gehoert nicht zur urspruenglichen Maschine. Es ist leer angelegt worden, damit Daten aus diesem Gast wieder herauskommen - der Gast hat ja kein Netzwerk. 1. Hier hineinschreiben, z.B. mysqldump --all-databases > /mnt/pvesnap/dump.sql 2. Gast herunterfahren 3. Auf dem Proxmox-Host: pvesnap-recovery pull Danach liegen die Dateien auf dem Host und lassen sich mit pvesnap-explorer oder ganz normal per scp abholen. """ class RecoveryError(Exception): """Eine Wiederherstellung liess sich nicht einrichten.""" # --------------------------------------------------------------------------- # Kommandos # --------------------------------------------------------------------------- def run(args, timeout=300, check=True, quiet=False): if not quiet: log.debug("exec: %s", " ".join(str(a) for a in args)) try: proc = subprocess.run([str(a) for a in args], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout) except FileNotFoundError: raise RecoveryError("Kommando nicht gefunden: %s" % args[0]) except subprocess.TimeoutExpired: raise RecoveryError("Zeitueberschreitung bei: %s" % " ".join(str(a) for a in 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").strip().splitlines() raise RecoveryError("%s: %s" % (args[0], detail[-1] if detail else "Fehler")) return stdout def have(command): return shutil.which(command) is not None # --------------------------------------------------------------------------- # Storage-Ebene - Proxmox ihre eigene Arbeit machen lassen # --------------------------------------------------------------------------- # Statt rbd/zfs/lvcreate selbst nachzubauen, wird die Storage-Schicht von # Proxmox aufgerufen. Die kennt jeden Storage-Typ, benennt die Volumes richtig # (vm--disk-N) und nimmt die Cluster-Sperre - ein Nachbau waere in jedem # Punkt schlechter. _PERL = r""" use strict; use warnings; use PVE::Storage; my ($op, @rest) = @ARGV; my $cfg = PVE::Storage::config(); if ($op eq 'clone') { my ($volid, $vmid, $snap) = @rest; print "RESULT ", PVE::Storage::vdisk_clone($cfg, $volid, $vmid, $snap), "\n"; } elsif ($op eq 'alloc') { my ($storeid, $vmid, $fmt, $size) = @rest; print "RESULT ", PVE::Storage::vdisk_alloc($cfg, $storeid, $vmid, $fmt, undef, $size), "\n"; } elsif ($op eq 'free') { PVE::Storage::vdisk_free($cfg, $rest[0]); print "RESULT ok\n"; } elsif ($op eq 'path') { my ($path) = PVE::Storage::path($cfg, $rest[0]); print "RESULT $path\n"; } elsif ($op eq 'activate') { PVE::Storage::activate_volumes($cfg, [@rest]); print "RESULT ok\n"; } elsif ($op eq 'deactivate') { PVE::Storage::deactivate_volumes($cfg, [@rest]); print "RESULT ok\n"; } elsif ($op eq 'size') { my ($size, $format) = PVE::Storage::volume_size_info($cfg, $rest[0], 10); print "RESULT $size $format\n"; } else { die "unbekannte Operation: $op\n"; } """ def storage_op(op, *args, timeout=600): """Ruft die Storage-Schicht von Proxmox auf und liefert deren Ergebnis.""" output = run(["perl", "-e", _PERL, str(op)] + [str(a) for a in args], timeout=timeout) for line in output.splitlines(): if line.startswith("RESULT "): return line[len("RESULT "):].strip() raise RecoveryError("Unerwartete Antwort der Storage-Schicht bei '%s': %s" % (op, output[:200] or "(leer)")) def clone_volume(volid, newid, snapname): """Copy-on-Write-Klon eines Snapshot-Datentraegers.""" return storage_op("clone", volid, newid, snapname) def alloc_volume(storeid, newid, size_kb, fmt="raw"): return storage_op("alloc", storeid, newid, fmt, int(size_kb)) def free_volume(volid): storage_op("free", volid, timeout=900) def volume_path(volid): return storage_op("path", volid, timeout=120) def volume_size(volid): parts = storage_op("size", volid, timeout=120).split() return int(parts[0]) if parts and parts[0].isdigit() else 0 def _rbd_context(proxmox, volid): """(rbd-Basisbefehl, Image-Pfad) fuer ein Volume - None, wenn kein Ceph.""" if ":" not in volid: return None storage, volname = volid.split(":", 1) try: info = proxmox._json(["get", "/storage/%s" % storage]) or {} except ProxmoxError: return None if info.get("type") != "rbd" or not have("rbd"): return None pool = info.get("pool") or "rbd" namespace = info.get("namespace") image = "%s/%s%s" % (pool, (namespace + "/") if namespace else "", volname) args = ["rbd"] if info.get("monhost"): args += ["-m", str(info["monhost"]).replace(" ", ",")] args += ["--id", info.get("username") or "admin"] keyring = "/etc/pve/priv/ceph/%s.keyring" % storage if os.path.exists(keyring): args += ["--keyring", keyring] return args, image def unprotect_snapshot(proxmox, volid, snapname): """Den Schutz wieder loesen, den das Klonen auf Ceph gesetzt hat. RBD verlangt fuer einen Klon, dass der Quell-Snapshot geschuetzt ist - Proxmox setzt das beim Klonen selbst. Bleibt der Schutz stehen, kann die Vorhaltezeit den Snapshot spaeter nicht mehr loeschen. Deshalb wird er beim Verwerfen der Wiederherstellung wieder entfernt. """ context = _rbd_context(proxmox, volid) if context is None: return False args, image = context result = run(args + ["snap", "unprotect", image, "--snap", snapname], check=False, timeout=120) log.debug("unprotect %s@%s: %s", image, snapname, result or "ok") return True def linked_volumes(proxmox, instance): """Welche Datentraeger noch am Quell-Snapshot haengen.""" linked = [] for volid in instance.volumes: context = _rbd_context(proxmox, volid) if context is None: continue args, image = context if "parent:" in run(args + ["info", image], check=False, timeout=60): linked.append(volid) return linked def flatten_hint(proxmox, volid): """Warum ein Datentraeger nicht abgekoppelt wird - je nach Storage. Nur Ceph kennt das Problem ueberhaupt: dort haelt ein Klon seinen Quell-Snapshot fest. Andere Storage-Typen loesen das anders oder gar nicht. """ storage = volid.split(":", 1)[0] if ":" in volid else "" try: kind = (proxmox._json(["get", "/storage/%s" % storage]) or {}).get("type", "") except ProxmoxError: kind = "" if kind == "lvmthin": return ("nicht noetig - Thin-Snapshots sind von sich aus unabhaengig; " "Quell-Snapshot und Original lassen sich jederzeit loeschen") if kind == "zfspool": return ("nicht moeglich - ein ZFS-Klon haengt am Snapshot. Loesen ginge " "nur ueber 'zfs send | zfs recv' in einen neuen Datentraeger") return "kein Ceph-Datentraeger" def flatten_cost(proxmox, instance): """Wieviele Bytes ein flatten kopieren muesste. Wichtig, weil `rbd du` je Snapshot nur den *Zuwachs* zeigt. Kopiert wird aber der gesamte an dieser Stelle sichtbare Inhalt - das ist typisch um Groessenordnungen mehr, als die Zeile des Snapshots vermuten laesst. """ total = 0 for volid in linked_volumes(proxmox, instance): context = _rbd_context(proxmox, volid) if context is None: continue args, image = context parent = "" for line in run(args + ["info", image], check=False, timeout=60).splitlines(): if line.strip().startswith("parent:"): parent = line.split(":", 1)[1].strip() if not parent: continue # "pool/image@snap" -> die Kette des Elternteils vermessen. Der Pool # muss dranbleiben, sonst sucht rbd im Standard-Pool. base = parent.split("@")[0] measured = 0 for line in run(args + ["du", base], check=False, timeout=300).splitlines(): parts = line.split() if len(parts) < 2 or parts[0] in ("NAME",): continue # "... 32 GiB 14 GiB" - die letzten beiden Felder sind USED value = _parse_bytes(" ".join(parts[-2:])) if line.strip().startswith(""): measured = value break measured = max(measured, value) total += measured return total _UNITS = {"B": 1, "KIB": 1024, "MIB": 1024 ** 2, "GIB": 1024 ** 3, "TIB": 1024 ** 4, "K": 1024, "M": 1024 ** 2, "G": 1024 ** 3} def _parse_bytes(text): match = re.match(r"^([\d.]+)\s*([A-Za-z]+)?$", str(text).strip()) if not match: return 0 return int(float(match.group(1)) * _UNITS.get((match.group(2) or "B").upper(), 1)) def run_streamed(args, timeout=6 * 3600): """Wie run(), laesst das Kommando aber direkt ins Terminal schreiben. Fuer `rbd flatten`: das bringt eine eigene Fortschrittsanzeige mit, und bei einer Platte, deren Kopie eine Stunde dauert, ist die mehr wert als eine aufgeraeumte Ausgabe. In der ncurses-Oberflaeche waere sie dagegen toedlich - dort wird weiter run() mit --no-progress verwendet. """ log.debug("exec: %s", " ".join(str(a) for a in args)) try: proc = subprocess.run([str(a) for a in args], timeout=timeout) except FileNotFoundError: raise RecoveryError("Kommando nicht gefunden: %s" % args[0]) except subprocess.TimeoutExpired: raise RecoveryError("Zeitueberschreitung bei: %s" % " ".join(str(a) for a in args)) if proc.returncode != 0: raise RecoveryError("%s ist fehlgeschlagen (Rueckgabewert %d)" % (args[0], proc.returncode)) return "" def _check_space(proxmox, instance, force=False, reserve=1.15): """Vor dem Flatten nachsehen, ob der Platz ueberhaupt reicht. Bitter gelernt: laeuft ein Storage waehrend des Kopierens voll, blockiert Ceph *alle* Schreibvorgaenge im Pool. Dann steht nicht nur das Flatten, sondern jede laufende VM - und selbst Aufraeumen wird schwierig, weil auch Loeschen ein Schreibvorgang ist. """ needed = flatten_cost(proxmox, instance) if not needed: return storages = {v.split(":", 1)[0] for v in instance.volumes if ":" in v} for name in sorted(storages): try: info = proxmox._json(["get", "/nodes/%s/storage/%s/status" % (instance.node, name)]) or {} except ProxmoxError: continue free = int(info.get("avail") or 0) if not free: continue if free < needed * reserve: message = ("Auf '%s' sind nur %s frei, gebraucht werden aber rund %s. " "Laeuft der Storage dabei voll, stehen alle Maschinen " "darauf - auch das Aufraeumen." % (name, human_bytes(free), human_bytes(needed))) if not force: raise RecoveryError(message + " Mit --force trotzdem.") log.warning("%s (--force)", message) def flatten(proxmox, instance, progress=None, stream=False, force=False): """Die Klone vom Quell-Snapshot loesen. Danach steht die Maschine auf eigenen Beinen: Sie belegt ihren Platz vollstaendig selbst, und die Snapshots, aus denen sie entstanden ist, lassen sich wieder loeschen. Fuer eine dauerhafte Wiederherstellung ist das der letzte Schritt - vorher haengt sie fuer immer am Original. Das kostet Zeit und Platz: hier werden die Daten wirklich kopiert. """ def step(text): log.info("%s", text) if progress: progress(text) if not instance.volumes: raise RecoveryError("Zu %s sind keine Datentraeger vermerkt." % instance.label) _check_space(proxmox, instance, force=force) flattened, already, unsupported = [], [], [] for volid in instance.volumes: context = _rbd_context(proxmox, volid) if context is None: unsupported.append(volid) continue args, image = context described = run(args + ["info", image], check=False, timeout=60) if not described or "No such file" in described or "does not exist" in described: # Gibt es nicht mehr - etwa der kopierte Arbeitsspeicher, den # Proxmox nach dem Fortsetzen selbst wieder freigegeben hat. continue if "parent:" not in described: already.append(volid) continue step("Loese %s vom Quell-Snapshot - dabei werden die Daten wirklich " "kopiert" % volid) if stream: run_streamed(args + ["flatten", image]) else: run(args + ["flatten", image, "--no-progress"], timeout=6 * 3600) flattened.append(volid) # Erst wenn nichts mehr am Snapshot haengt, darf der Schutz weg. if not linked_volumes(proxmox, instance): for entry in list(instance.protected): try: step("Hebe Schutz von %s@%s auf" % (entry[0], entry[1])) unprotect_snapshot(proxmox, entry[0], entry[1]) except (RecoveryError, IndexError, TypeError): pass instance.protected = [] _registry_add(instance) return flattened, already, unsupported # --------------------------------------------------------------------------- # Groessenangaben # --------------------------------------------------------------------------- _SIZE = re.compile(r"^\s*(\d+(?:[.,]\d+)?)\s*([KMGT])?i?B?\s*$", re.I) _FACTOR = {"K": 1, "M": 1024, "G": 1024 ** 2, "T": 1024 ** 3} def parse_size_kb(text, default_unit="G"): """'10G' -> Kilobyte. vdisk_alloc rechnet in KiB.""" match = _SIZE.match(str(text or "")) if not match: raise RecoveryError("Groessenangabe nicht verstanden: %r " "(erwartet z.B. 10G, 512M)" % text) value = float(match.group(1).replace(",", ".")) unit = (match.group(2) or default_unit).upper() return max(1, int(value * _FACTOR[unit])) 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 # --------------------------------------------------------------------------- # Datenmodell # --------------------------------------------------------------------------- @dataclass class Spec: """Was gewuenscht ist.""" mode: str = "live" # live | recovery newid: int = 0 # 0 = naechste freie node: str = "" # leer = Node des Originals net: str = "" # leer = Vorgabe des Modus; none|down|on resume: object = None # None = RAM-Zustand nehmen, wenn vorhanden exchange: str = "" # Groesse, z.B. "10G"; leer = keins exchange_storage: str = "" exchange_fs: str = DEFAULT_EXCHANGE_FS exchange_dir: str = "" # nur LXC: vorhandenes Host-Verzeichnis memory: int = 0 # 0 = wie im Snapshot cores: int = 0 name: str = "" keep_binds: bool = False start: bool = True @property def isolated(self): return self.mode != "recovery" @dataclass class Plan: """Was passieren wird - ohne dass schon etwas passiert ist.""" guest: object snapshot: str spec: Spec newid: int = 0 node: str = "" disks: list = field(default_factory=list) # [(key, volid, groesse)] nets: list = field(default_factory=list) # [(key, wert)] net_mode: str = "none" vmstate: str = "" vmstate_bytes: int = 0 resume: bool = False exchange_kind: str = "" # "disk" | "bind" | "" exchange_detail: str = "" warnings: list = field(default_factory=list) # Was nicht nur unschoen, sondern gefaehrlich ist. Hierfuer genuegt ein # beilaeufiges "ja" nicht - siehe cmd_create() und die Oberflaeche. critical: list = field(default_factory=list) notes: list = field(default_factory=list) dropped: list = field(default_factory=list) @property def label(self): return "%s %d" % ("LXC" if self.guest.type == "lxc" else "VM", self.newid) @dataclass class Instance: """Eine eingerichtete Wiederherstellung.""" vmid: int type: str = "qemu" node: str = "" name: str = "" source: int = 0 source_node: str = "" snapshot: str = "" mode: str = "live" created: int = 0 volumes: list = field(default_factory=list) protected: list = field(default_factory=list) # [[volid, snapname]] exchange: dict = field(default_factory=dict) resumed: bool = False status: str = "" @property def guest(self): return Guest(vmid=self.vmid, name=self.name, type=self.type, node=self.node) @property def label(self): return "%s %d" % ("LXC" if self.type == "lxc" else "VM", self.vmid) @property def origin(self): return "%s %d @ %s" % ("LXC" if self.type == "lxc" else "VM", self.source, self.snapshot) def console_url(self, host=None): kind = "lxc" if self.type == "lxc" else "kvm" return ("https://%s:8006/?console=%s&novnc=1&vmid=%d&node=%s&resize=off&cmd=" % (host or self.node, kind, self.vmid, self.node)) def to_dict(self): return {k: getattr(self, k) for k in ("vmid", "type", "node", "name", "source", "source_node", "snapshot", "mode", "created", "volumes", "protected", "exchange", "resumed")} @classmethod def from_dict(cls, data): known = {k: data.get(k) for k in ("vmid", "type", "node", "name", "source", "source_node", "snapshot", "mode", "created", "volumes", "protected", "exchange", "resumed") if data.get(k) is not None} known["vmid"] = int(known.get("vmid") or 0) return cls(**known) # --------------------------------------------------------------------------- # Merkliste # --------------------------------------------------------------------------- def _registry_load(): try: with open(REGISTRY, "r", encoding="utf-8") as handle: data = json.load(handle) return [Instance.from_dict(entry) for entry in data] if isinstance(data, list) else [] except (OSError, ValueError, TypeError): return [] def _registry_save(instances): try: os.makedirs(STATE_DIR, exist_ok=True) tmp = REGISTRY + ".tmp" with open(tmp, "w", encoding="utf-8") as handle: json.dump([i.to_dict() for i in instances], handle, indent=1) os.replace(tmp, REGISTRY) except OSError as exc: log.warning("Merkliste %s nicht schreibbar: %s", REGISTRY, exc) def _registry_add(instance): entries = [i for i in _registry_load() if i.vmid != instance.vmid] entries.append(instance) _registry_save(entries) def _registry_remove(vmid): _registry_save([i for i in _registry_load() if i.vmid != int(vmid)]) # --------------------------------------------------------------------------- # Konfigurationsdatei des neuen Gastes # --------------------------------------------------------------------------- def config_path(node, guest_type, vmid): sub = "lxc" if guest_type == "lxc" else "qemu-server" return os.path.join(CONF_ROOT, node, sub, "%d.conf" % vmid) def _write_config(path, config, description=""): """Konfiguration anlegen - und dabei die VMID belegen. O_EXCL: existiert die Datei schon, gehoert die VMID jemand anderem. Auf /etc/pve ist das die zuverlaessigste Reservierung, die es gibt. Die Beschreibung steht bei Proxmox als '#'-Zeilen am Dateianfang. """ lines = [] for line in str(description or "").rstrip("\n").split("\n"): if description: lines.append("#" + line) for key in sorted(config): value = config[key] if value is None or value == "": continue lines.append("%s: %s" % (key, value)) text = "\n".join(lines) + "\n" directory = os.path.dirname(path) if not os.path.isdir(directory): raise RecoveryError("Verzeichnis %s gibt es nicht - Node-Name richtig?" % directory) try: handle = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o640) except FileExistsError: raise RecoveryError("%s gibt es bereits - die VMID ist belegt." % path) except OSError as exc: raise RecoveryError("%s nicht anlegbar: %s" % (path, exc)) try: os.write(handle, text.encode("utf-8")) finally: os.close(handle) def _replace_volid(value, new_volid): parts = str(value).split(",") parts[0] = new_volid return ",".join(parts) def _volid_of(value): first = str(value).split(",", 1)[0].strip() return first if ":" in first and first not in ("none", "cdrom") else "" def _is_cdrom(value): return "media=cdrom" in str(value) def _set_option(value, option, wanted): """Eine Option in einem Proxmox-Wertfeld setzen oder ersetzen.""" parts = [p for p in str(value).split(",") if not p.startswith(option + "=")] parts.append("%s=%s" % (option, wanted)) return ",".join(parts) # --------------------------------------------------------------------------- # Planen # --------------------------------------------------------------------------- def _disk_keys(config, guest_type): if guest_type == "lxc": return [k for k in sorted(config) if LXC_DISK_KEY.match(k)] keys = [k for k in sorted(config) if QEMU_DISK_KEY.match(k)] keys += [k for k in QEMU_EXTRA_DISKS if k in config] return keys def plan(proxmox, guest, snapname, spec): """Alles pruefen und zusammenstellen, ohne etwas zu veraendern.""" config = proxmox.guest_config(guest, snapshot=snapname) result = Plan(guest=guest, snapshot=snapname, spec=spec) # -- Ziel-VMID und Node ------------------------------------------------ if spec.newid: if not proxmox.vmid_free(spec.newid): raise RecoveryError("VMID %d ist bereits vergeben." % spec.newid) result.newid = int(spec.newid) else: result.newid = proxmox.next_vmid() result.node = spec.node or guest.node # -- Datentraeger ------------------------------------------------------ for key in _disk_keys(config, guest.type): value = str(config[key]) if _is_cdrom(value): continue volid = _volid_of(value) if not volid: if guest.type == "lxc" and "mp=" in value: result.dropped.append("%s (%s)" % (key, value.split(",")[0])) continue size = "" for part in value.split(",")[1:]: if part.startswith("size="): size = part[5:] result.disks.append((key, volid, size)) if not result.disks: raise RecoveryError("Der Snapshot enthaelt keine Datentraeger, die sich " "klonen liessen.") # Storages muessen die Klone tragen koennen und vom Ziel-Node erreichbar sein. for _key, volid, _size in result.disks: storage = volid.split(":", 1)[0] info = _storage_info(proxmox, storage) nodes = [n.strip() for n in str(info.get("nodes") or "").split(",") if n.strip()] if nodes and result.node not in nodes: raise RecoveryError("Storage '%s' ist auf Node '%s' nicht verfuegbar." % (storage, result.node)) if not info.get("shared") and result.node != guest.node: raise RecoveryError("Storage '%s' ist nicht geteilt - die Wiederherstellung " "muss auf '%s' laufen." % (storage, guest.node)) # -- Arbeitsspeicher --------------------------------------------------- state_volid = str(config.get("vmstate") or "") if state_volid and guest.type == "qemu": result.vmstate = state_volid want = spec.resume if spec.resume is not None else True result.resume = bool(want) if result.resume: try: result.vmstate_bytes = volume_size(state_volid) except RecoveryError: result.vmstate_bytes = 0 elif spec.resume: result.notes.append("Der Snapshot enthaelt keinen Arbeitsspeicher - die " "Maschine bootet kalt (wie nach einem Stromausfall).") # -- Netzwerk ---------------------------------------------------------- result.nets = [(k, str(config[k])) for k in sorted(config) if NET_KEY.match(k)] if spec.net: result.net_mode = spec.net else: result.net_mode = "on" if not spec.isolated else "none" if result.resume and result.net_mode == "none" and result.nets: # Ein gespeicherter RAM-Zustand laesst sich nur in eine Maschine mit # genau denselben Geraeten laden. Die Netzwerkkarte muss also bleiben - # abgeklemmt wird stattdessen die Leitung. result.net_mode = "down" result.notes.append("Netzwerkkarte bleibt vorhanden, aber abgeklemmt " "(link_down): mit geladenem Arbeitsspeicher darf sich " "die Geraeteausstattung nicht aendern.") if result.net_mode == "on" and result.nets: result.warnings.append( "Die Wiederherstellung geht MIT Netzwerk online - mit denselben " "MAC-Adressen wie das Original.") status = proxmox.guest_status(guest) if status.get("status") == "running": result.critical.append( "%s LAEUFT gerade. Zwei Maschinen mit gleicher MAC-Adresse, " "gleicher IP und gleicher Identitaet im selben Netz geben Chaos - " "erst das Original stoppen." % guest.label) # -- Austauschlaufwerk ------------------------------------------------- if spec.exchange_dir: if guest.type != "lxc": raise RecoveryError("--exchange-dir gibt es nur fuer Container. Bei " "virtuellen Maschinen laesst sich kein Host-" "Verzeichnis durchreichen - dort --exchange " "verwenden.") if not os.path.isdir(spec.exchange_dir): raise RecoveryError("Verzeichnis gibt es nicht: %s" % spec.exchange_dir) result.exchange_kind = "bind" result.exchange_detail = "%s -> %s (im Gast)" % (spec.exchange_dir, EXCHANGE_GUEST_PATH) elif spec.exchange: if guest.type == "lxc": result.exchange_kind = "bind" result.exchange_detail = "%s/%d -> %s (im Gast)" % ( EXCHANGE_ROOT, result.newid, EXCHANGE_GUEST_PATH) else: size_kb = parse_size_kb(spec.exchange) storage = spec.exchange_storage or result.disks[0][1].split(":", 1)[0] if spec.exchange_fs not in MKFS: raise RecoveryError("Dateisystem %r kenne ich nicht (%s)" % (spec.exchange_fs, ", ".join(sorted(MKFS)))) if not have(MKFS[spec.exchange_fs][0]): raise RecoveryError("%s ist nicht installiert - anderes Dateisystem " "waehlen oder Paket nachinstallieren." % MKFS[spec.exchange_fs][0]) result.exchange_kind = "disk" result.exchange_detail = "%s auf %s, %s, im Gast als weitere Platte" % ( human_bytes(size_kb * 1024), storage, spec.exchange_fs) # -- Hinweise ---------------------------------------------------------- if result.dropped and not spec.keep_binds: result.notes.append("Nicht uebernommen werden Einbindungen von Host-" "Verzeichnissen: %s" % ", ".join(result.dropped)) if spec.memory and result.resume: raise RecoveryError("Ein geladener Arbeitsspeicher laesst sich nicht " "vergroessern oder verkleinern - entweder --memory " "weglassen oder --no-resume verwenden.") if spec.mode == "recovery": kinds = {_storage_info(proxmox, volid.split(":", 1)[0]).get("type") for _key, volid, _size in result.disks} if "rbd" in kinds: result.notes.append( "Die Datentraeger sind Linked Clones - auf Ceph haelt ein Klon " "seinen Quell-Snapshot fest, der dadurch unloeschbar wird. Soll " "die Maschine dauerhaft laufen, spaeter mit " "'pvesnap-recovery flatten %d' loesen." % result.newid) elif kinds <= {"lvmthin"}: result.notes.append( "Die Datentraeger sind Thin-Snapshots und damit von sich aus " "unabhaengig - Quell-Snapshot und Original lassen sich jederzeit " "loeschen. Ein Abkoppeln ist nicht noetig.") if result.resume: result.notes.append("Der Arbeitsspeicher (%s) wird kopiert; die Maschine " "laeuft danach genau dort weiter, wo sie beim Snapshot " "stand." % (human_bytes(result.vmstate_bytes) or "?")) result.notes.append("Proxmox gibt den kopierten Arbeitsspeicher nach dem " "ersten Start wieder frei - ein spaeterer Neustart " "bootet dann kalt.") return result def _storage_info(proxmox, name): data = proxmox._json(["get", "/storage/%s" % name]) if not isinstance(data, dict): raise RecoveryError("Storage %r nicht gefunden" % name) return data # --------------------------------------------------------------------------- # Einrichten # --------------------------------------------------------------------------- def create(proxmox, plan_, progress=None): """Den Plan umsetzen. Bei einem Fehler wird alles wieder abgeraeumt.""" def step(text): log.info("%s", text) if progress: progress(text) guest = plan_.guest spec = plan_.spec config = proxmox.guest_config(guest, snapshot=plan_.snapshot) created = [] # Volumes, die wir angelegt haben protected = [] # [volid, snapname] deren Schutz wir loesen muessen made_dirs = [] conf_file = "" try: new_config = {} for key, value in config.items(): if key in DROP_KEYS or NET_KEY.match(key): continue if QEMU_DISK_KEY.match(key) or LXC_DISK_KEY.match(key) \ or key in QEMU_EXTRA_DISKS: continue new_config[key] = value # -- Datentraeger klonen ------------------------------------------ for index, (key, volid, _size) in enumerate(plan_.disks, 1): step("Klone %s (%d/%d): %s" % (key, index, len(plan_.disks), volid)) clone = clone_volume(volid, plan_.newid, plan_.snapshot) created.append(clone) protected.append([volid, plan_.snapshot]) new_config[key] = _replace_volid(config[key], clone) # CD-Laufwerke und nicht klonbare Einbindungen for key in _disk_keys(config, guest.type): if key in new_config: continue value = str(config[key]) if _is_cdrom(value): new_config[key] = value elif guest.type == "lxc" and not _volid_of(value): if spec.keep_binds: new_config[key] = value # -- Arbeitsspeicher ---------------------------------------------- if plan_.resume and plan_.vmstate: step("Kopiere Arbeitsspeicher (%s) - das dauert einen Moment" % (human_bytes(plan_.vmstate_bytes) or "?")) copy = _copy_state(plan_.vmstate, plan_.newid) created.append(copy) new_config["vmstate"] = copy for key in ("runningmachine", "runningcpu"): if config.get(key): new_config[key] = config[key] # -- Netzwerk ------------------------------------------------------ for key, value in plan_.nets: if plan_.net_mode == "none": continue if plan_.net_mode == "down": value = _set_option(value, "link_down", "1") new_config[key] = value # -- Austauschlaufwerk --------------------------------------------- exchange = {} if plan_.exchange_kind == "disk": storage = spec.exchange_storage or plan_.disks[0][1].split(":", 1)[0] size_kb = parse_size_kb(spec.exchange) step("Lege Austauschlaufwerk an (%s, %s)" % (human_bytes(size_kb * 1024), spec.exchange_fs)) volid = alloc_volume(storage, plan_.newid, size_kb) created.append(volid) _format_exchange(volid, spec.exchange_fs) slot = _free_disk_slot(new_config) drive = "%s,backup=0" % volid exchange = {"kind": "disk", "volid": volid, "key": slot, "fs": spec.exchange_fs, "drive": drive, "pending": bool(plan_.resume)} # Mit geladenem Arbeitsspeicher wacht der Gast in einem Zustand auf, # in dem es diese Platte nicht gab - er wuerde sie nie bemerken. # Deshalb kommt sie erst nach dem Fortsetzen dazu, per Hotplug. if not plan_.resume: new_config[slot] = drive elif plan_.exchange_kind == "bind": host_dir = spec.exchange_dir or os.path.join(EXCHANGE_ROOT, str(plan_.newid)) if not spec.exchange_dir: os.makedirs(host_dir, exist_ok=True) made_dirs.append(host_dir) _prepare_bind_dir(host_dir, str(config.get("unprivileged")) == "1") step("Reiche %s in den Container durch" % host_dir) slot = _free_mp_slot(new_config) new_config[slot] = "%s,mp=%s" % (host_dir, EXCHANGE_GUEST_PATH) exchange = {"kind": "bind", "path": host_dir, "key": slot, "own": not spec.exchange_dir} # -- restliche Anpassungen ----------------------------------------- if spec.memory: new_config["memory"] = spec.memory if spec.cores: new_config["cores"] = spec.cores new_config["tags"] = TAG if guest.type == "qemu": new_config["name"] = spec.name or _default_name(guest, plan_) description = _description(guest, plan_) # -- Konfiguration schreiben --------------------------------------- conf_file = config_path(plan_.node, guest.type, plan_.newid) step("Lege %s an" % conf_file) _write_config(conf_file, new_config, description) instance = Instance( vmid=plan_.newid, type=guest.type, node=plan_.node, name=str(new_config.get("name") or new_config.get("hostname") or ""), source=guest.vmid, source_node=guest.node, snapshot=plan_.snapshot, mode=spec.mode, created=int(time.time()), volumes=created, protected=protected, exchange=exchange, resumed=plan_.resume) _registry_add(instance) return instance except Exception as exc: step("Fehler - raeume wieder ab: %s" % exc) if conf_file and os.path.exists(conf_file): try: os.unlink(conf_file) except OSError: pass for volid in reversed(created): try: free_volume(volid) except RecoveryError as cleanup_exc: log.warning("Klon %s blieb liegen: %s", volid, cleanup_exc) for volid, snapname in protected: try: unprotect_snapshot(proxmox, volid, snapname) except RecoveryError: pass for path in made_dirs: shutil.rmtree(path, ignore_errors=True) raise # Woran zu erkennen ist, dass der Arbeitsspeicher nicht angekommen ist. # Proxmox meldet den Startvorgang trotzdem mit "TASK OK" - die Maschine bleibt # dann aber angehalten stehen, ohne dass irgendwo ein Fehler sichtbar waere. _STATE_FAILED = ("Error while loading VM state", "Unknown savevm section", "Make sure that your current VM setup matches") def start(proxmox, instance, progress=None): """Hochfahren - und nachsehen, ob dabei herauskam, was gewollt war. Liefert einen Hinweistext, wenn etwas anders lief als geplant, sonst "". """ def step(text): log.info("%s", text) if progress: progress(text) upid = proxmox.start_guest(instance.guest) if instance.type != "qemu": return "" note = "" if instance.resumed and upid: broken = [line.strip() for line in proxmox.task_log(instance.node, upid) if any(marker in line for marker in _STATE_FAILED)] if broken: note = ("Der Arbeitsspeicher liess sich nicht laden - die Maschine " "startet stattdessen kalt vom Datentraeger. Proxmox meldet: %s" % broken[0]) # Nach dem Laden eines RAM-Standes steht die Maschine angehalten da und # muss noch fortgesetzt werden. for _ in range(15): qmp = (proxmox.guest_status(instance.guest).get("qmpstatus") or "") if qmp == "running": return _attach_pending(proxmox, instance, step) or note if qmp in ("paused", "prelaunch"): step("Setze %s fort" % instance.label) try: proxmox.resume_guest(instance.guest) except ProxmoxError as exc: return "%s laesst sich nicht fortsetzen: %s" % (instance.label, exc) time.sleep(1.0) if (proxmox.guest_status(instance.guest).get("qmpstatus") or "") == "running": return _attach_pending(proxmox, instance, step) or note time.sleep(1.0) return note or ("%s ist gestartet, laeuft aber nicht. Warum, steht im " "Task-Protokoll von Proxmox." % instance.label) def _attach_pending(proxmox, instance, step=None): """Das Austauschlaufwerk an die schon laufende Maschine anstecken.""" exchange = instance.exchange or {} if not exchange.get("pending"): return "" key, drive = exchange.get("key"), exchange.get("drive") if not key or not drive: return "" if step: step("Stecke Austauschlaufwerk als %s an" % key) try: proxmox.set_guest_config(instance.guest, {key: drive}) config = proxmox.guest_config(instance.guest) except ProxmoxError as exc: return "Das Austauschlaufwerk liess sich nicht anstecken: %s" % exc if key not in config: # Proxmox hat es nur vorgemerkt - im Gast taucht es dann nicht auf. return ("Das Austauschlaufwerk ist eingetragen, aber nicht angesteckt " "worden (Hotplug fuer Platten ist bei dieser Maschine aus). " "Es erscheint erst nach einem Neustart des Gastes.") exchange["pending"] = False _registry_add(instance) return "" def _default_name(guest, plan_): base = re.sub(r"[^A-Za-z0-9-]", "-", guest.name or "vm%d" % guest.vmid)[:40] return ("%s-wdh" % base).strip("-") def _description(guest, plan_): return "\n".join([ "%s - von pvesnap angelegt, nicht die Originalmaschine." % TAG, "", "Quelle: %s auf %s" % (guest.label, guest.node), "Snapshot: %s" % plan_.snapshot, "Modus: %s" % ("recovery (mit Netzwerk)" if plan_.net_mode == "on" else "live (ohne Netzwerk)"), "Angelegt: %s" % time.strftime("%Y-%m-%d %H:%M:%S"), "", "Verwerfen: pvesnap-recovery destroy %d" % plan_.newid, ]) def _free_disk_slot(config): for index in range(1, 31): for prefix in ("scsi", "virtio", "sata"): key = "%s%d" % (prefix, index) if key not in config and any(k.startswith(prefix) for k in config): return key for index in range(1, 31): if "scsi%d" % index not in config: return "scsi%d" % index raise RecoveryError("Kein freier Platz fuer ein Austauschlaufwerk.") def _free_mp_slot(config): for index in range(0, 256): if "mp%d" % index not in config: return "mp%d" % index raise RecoveryError("Kein freier Einhaengepunkt mehr frei.") def _prepare_bind_dir(path, unprivileged): """Damit der Container hineinschreiben darf.""" try: if unprivileged: os.chown(path, 100000, 100000) # Standard-Verschiebung von PVE os.chmod(path, 0o777) except OSError as exc: log.warning("Rechte auf %s nicht setzbar: %s", path, exc) # --------------------------------------------------------------------------- # Arbeitsspeicher kopieren # --------------------------------------------------------------------------- def _copy_state(volid, newid): """Den RAM-Stand des Snapshots duplizieren. Kopiert wird, nicht geklont: Proxmox gibt den Arbeitsspeicher nach dem Laden selbst wieder frei - das darf auf keinen Fall den Snapshot des Originals treffen. """ if not have("qemu-img"): raise RecoveryError("'qemu-img' fehlt (apt install qemu-utils) - ohne das " "laesst sich der Arbeitsspeicher nicht uebernehmen.") storage = volid.split(":", 1)[0] size = volume_size(volid) if size <= 0: raise RecoveryError("Groesse von %s nicht ermittelbar" % volid) target = alloc_volume(storage, newid, max(1, (size + 1023) // 1024)) try: storage_op("activate", volid, target, timeout=300) source_path = volume_path(volid) target_path = volume_path(target) run(["qemu-img", "convert", "-O", "raw", "-n", source_path, target_path], timeout=7200) except Exception: try: free_volume(target) except RecoveryError: pass raise return target # --------------------------------------------------------------------------- # Austauschlaufwerk formatieren # --------------------------------------------------------------------------- def _format_exchange(volid, fstype): with _block_device(volid) as device: run(MKFS[fstype] + [device], timeout=900) _place_readme(device, fstype) class _block_device: """Ein Volume als echtes Blockgeraet - noetig, weil `pvesm path` bei Ceph nur eine qemu-Adresse (rbd:pool/image:...) liefert, kein /dev/...""" def __init__(self, volid): self.volid = volid self.device = "" self.mapped = False def __enter__(self): storage_op("activate", self.volid, timeout=300) path = volume_path(self.volid) if path.startswith("rbd:"): self.device = self._map_rbd(path) self.mapped = True else: self.device = path if not os.path.exists(self.device): raise RecoveryError("%s ist nicht als Geraet aufgetaucht" % self.device) return self.device def __exit__(self, *_exc): if self.mapped and self.device: run(["rbd", "unmap", self.device], check=False, timeout=120) return False @staticmethod def _map_rbd(path): # rbd:data/vm-9802-disk-1:conf=/etc/pve/ceph.conf:id=admin:keyring=... fields = path[4:].split(":") image = fields[0] args = ["rbd", "map", image] for field_ in fields[1:]: key, _, value = field_.partition("=") if key == "id": args += ["--id", value] elif key == "keyring": args += ["--keyring", value] elif key == "conf": args += ["--conf", value] elif key == "mon_host": args += ["-m", value.replace(";", ",")] output = run(args, timeout=180) for line in output.splitlines(): if line.strip().startswith("/dev/"): return line.strip() raise RecoveryError("rbd map lieferte keinen Geraetenamen: %s" % output) def _place_readme(device, fstype): """Kurze Anleitung auf das leere Laufwerk legen.""" mountpoint = os.path.join(EXCHANGE_MOUNT, "format-%d" % os.getpid()) try: os.makedirs(mountpoint, exist_ok=True) run(["mount", "-t", fstype, device, mountpoint], timeout=120) except (RecoveryError, 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) 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 # --------------------------------------------------------------------------- # Uebersicht, Verwerfen, Austausch abholen # --------------------------------------------------------------------------- def list_instances(proxmox, refresh=True): """Bekannte Wiederherstellungen - Merkliste und Cluster zusammengefuehrt. Der Cluster wird mitgelesen, damit auch etwas auftaucht, das nach einem verlorenen Zustandsspeicher nur noch an seiner Markierung erkennbar ist. """ known = {i.vmid: i for i in _registry_load()} try: guests = proxmox.inventory(refresh=refresh) except ProxmoxError as exc: log.warning("Cluster nicht abfragbar: %s", exc) guests = [] alive = set() for guest in guests: if TAG not in guest.tags: continue alive.add(guest.vmid) instance = known.get(guest.vmid) if instance is None: instance = Instance(vmid=guest.vmid, type=guest.type, node=guest.node, name=guest.name, mode="?", snapshot="?") known[guest.vmid] = instance instance.node = guest.node or instance.node instance.name = guest.name or instance.name instance.type = guest.type instance.status = guest.status for vmid, instance in known.items(): if vmid not in alive and guests: instance.status = "weg" return sorted(known.values(), key=lambda i: i.vmid) def forget(vmid): _registry_remove(vmid) # --------------------------------------------------------------------------- # Liegengebliebenes finden # --------------------------------------------------------------------------- def used_vmids(): """Alle vergebenen VMIDs - aus den Konfigurationsdateien. Bewusst nicht ueber /cluster/resources: ein Gast auf einem abgemeldeten Node taucht dort unter Umstaenden nicht auf, und dann wuerden wir die Platten einer lebenden Maschine fuer verwaist halten. /etc/pve ist cluster-weit und kennt auch abgeschaltete Gaeste. """ used = set() try: nodes = os.listdir(CONF_ROOT) except OSError as exc: raise RecoveryError("%s nicht lesbar: %s" % (CONF_ROOT, exc)) 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 name.endswith(".conf") and name[:-5].isdigit(): used.add(int(name[:-5])) return used _IMAGE_NAME = re.compile(r"^vm-(\d+)-(?:disk|state|cloudinit)") def find_orphans(proxmox): """Datentraeger, zu denen es keinen Gast mehr gibt. Entsteht, wenn eine Wiederherstellung ausserhalb von pvesnap entfernt wird - etwa in der Proxmox-Oberflaeche. Der Gast ist dann weg, sein Klon bleibt liegen, und der Quell-Snapshot bleibt geschuetzt und damit unloeschbar. """ used = used_vmids() if not used: raise RecoveryError( "Unter %s steht keine einzige Gast-Konfiguration - das kann nicht " "stimmen. Zur Sicherheit wird nichts angefasst." % CONF_ROOT) found = [] for storage in proxmox._json(["get", "/storage"]) or []: if storage.get("type") != "rbd": continue name = storage.get("storage") context = _rbd_context(proxmox, "%s:dummy" % name) if context is None: continue args, _ = context pool = storage.get("pool") or "rbd" namespace = storage.get("namespace") prefix = "%s/%s" % (pool, (namespace + "/") if namespace else "") for image in run(args + ["ls", pool.rstrip("/")], check=False, timeout=120).splitlines(): image = image.strip() match = _IMAGE_NAME.match(image) if not match or int(match.group(1)) in used: continue try: info = json.loads(run(args + ["info", prefix + image, "--format", "json"], timeout=60)) except (RecoveryError, ValueError): continue parent = info.get("parent") or {} found.append({ "volid": "%s:%s" % (name, image), "vmid": int(match.group(1)), "bytes": int(info.get("size") or 0), "parent": ("%s/%s@%s" % (parent.get("pool"), parent.get("image"), parent.get("snapshot"))) if parent else "", "args": args, "image": prefix + image, }) return found def remove_orphans(proxmox, orphans, progress=None): """Gefundene Reste entfernen und den Schutz ihrer Quell-Snapshots loesen.""" def step(text): log.info("%s", text) if progress: progress(text) removed, failed = [], [] for entry in orphans: try: step("Entferne %s" % entry["volid"]) free_volume(entry["volid"]) removed.append(entry["volid"]) except RecoveryError as exc: failed.append("%s: %s" % (entry["volid"], exc)) continue parent = entry.get("parent") if not parent: continue args, image_snap = entry["args"], parent base, _, snapname = image_snap.partition("@") # Nur loesen, wenn wirklich kein Klon mehr daran haengt. children = run(args + ["children", image_snap], check=False, timeout=120) if children.strip(): step("%s hat noch andere Klone - Schutz bleibt" % parent) continue step("Hebe Schutz von %s auf" % parent) run(args + ["snap", "unprotect", base, "--snap", snapname], check=False, timeout=120) return removed, failed def destroy(proxmox, instance, progress=None, keep_snapshot_protection=False): """Eine Wiederherstellung restlos entfernen.""" def step(text): log.info("%s", text) if progress: progress(text) guest = instance.guest exists = proxmox.guest_exists(instance.vmid) if exists: # Absicherung: nur anfassen, was unsere Markierung traegt. config = proxmox.guest_config(guest) tags = [t.strip().lower() for t in str(config.get("tags") or "").replace(",", ";").split(";")] if TAG not in tags: raise RecoveryError( "%s traegt die Markierung '%s' nicht - das ist keine von pvesnap " "angelegte Wiederherstellung und wird nicht angefasst." % (instance.label, TAG)) status = proxmox.guest_status(guest) if status.get("status") == "running": step("Schalte %s aus" % instance.label) try: proxmox.stop_guest(guest) except ProxmoxError as exc: raise RecoveryError("%s laesst sich nicht stoppen: %s" % (instance.label, exc)) step("Entferne %s samt Klonen" % instance.label) try: proxmox.destroy_guest(guest) except ProxmoxError as exc: raise RecoveryError("%s laesst sich nicht entfernen: %s" % (instance.label, exc)) # Proxmox raeumt alles ab, was in der Konfiguration steht. Ein noch # nicht angestecktes Austauschlaufwerk steht dort aber nicht drin - # deshalb hier noch einmal ueber alles gehen, was wir angelegt haben. for volid in reversed(instance.volumes): try: free_volume(volid) step("Entferne uebrig gebliebenen Klon %s" % volid) except RecoveryError: pass # war schon weg - der Normalfall else: # Der Gast ist weg, seine Klone koennen es trotzdem noch geben. for volid in instance.volumes: try: step("Entferne uebrig gebliebenen Klon %s" % volid) free_volume(volid) except RecoveryError as exc: log.warning("%s: %s", volid, exc) if not keep_snapshot_protection: for entry in instance.protected: try: volid, snapname = entry[0], entry[1] except (IndexError, TypeError): continue step("Hebe Schutz von %s@%s auf" % (volid, snapname)) try: unprotect_snapshot(proxmox, volid, snapname) except RecoveryError as exc: log.warning("Schutz von %s@%s blieb: %s", volid, snapname, exc) exchange = instance.exchange or {} if exchange.get("kind") == "bind" and exchange.get("own") and exchange.get("path"): step("Entferne Austauschverzeichnis %s" % exchange["path"]) shutil.rmtree(exchange["path"], ignore_errors=True) _registry_remove(instance.vmid) step("Fertig.") def pull(proxmox, instance): """Austauschlaufwerk auf dem Host verfuegbar machen; liefert den Pfad.""" exchange = instance.exchange or {} if not exchange: raise RecoveryError("%s hat kein Austauschlaufwerk." % instance.label) if exchange.get("kind") == "bind": path = exchange.get("path") or "" if not os.path.isdir(path): raise RecoveryError("Austauschverzeichnis gibt es nicht: %s" % path) return path volid = exchange.get("volid") if not volid: raise RecoveryError("Zu %s ist kein Austauschlaufwerk vermerkt." % instance.label) status = proxmox.guest_status(instance.guest) if status.get("status") == "running": raise RecoveryError("%s laeuft noch. Erst herunterfahren - sonst schreiben " "Gast und Host gleichzeitig auf dasselbe Dateisystem." % instance.label) mountpoint = os.path.join(EXCHANGE_MOUNT, str(instance.vmid)) if os.path.ismount(mountpoint): return mountpoint os.makedirs(mountpoint, exist_ok=True) holder = _block_device(volid) device = holder.__enter__() try: fstype = exchange.get("fs") or "auto" run(["mount", "-o", "ro", "-t", fstype, device, mountpoint], timeout=120) except RecoveryError: holder.__exit__() raise _remember_mount(instance.vmid, mountpoint, device, holder.mapped) return mountpoint def release(vmid): """Ein mit pull() eingehaengtes Austauschlaufwerk wieder loesen.""" entry = _mounts().pop(str(vmid), None) _save_mounts(_mounts_without(vmid)) if not entry: return False run(["umount", entry["mountpoint"]], check=False, timeout=120) if entry.get("mapped"): run(["rbd", "unmap", entry["device"]], check=False, timeout=120) try: os.rmdir(entry["mountpoint"]) except OSError: pass return True _MOUNTS = os.path.join("/run/pvesnap", "recovery-mounts.json") def _mounts(): try: with open(_MOUNTS, "r", encoding="utf-8") as handle: data = json.load(handle) return data if isinstance(data, dict) else {} except (OSError, ValueError): return {} def _mounts_without(vmid): data = _mounts() data.pop(str(vmid), None) return data def _save_mounts(data): try: os.makedirs(os.path.dirname(_MOUNTS), exist_ok=True) with open(_MOUNTS, "w", encoding="utf-8") as handle: json.dump(data, handle, indent=1) except OSError as exc: log.debug("Mount-Merkliste nicht schreibbar: %s", exc) def _remember_mount(vmid, mountpoint, device, mapped): data = _mounts() data[str(vmid)] = {"mountpoint": mountpoint, "device": device, "mapped": mapped} _save_mounts(data)