"""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 . import transfer 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") 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", } # Node-Adressen: die Namensaufloesung klappt auf dem Arbeitsplatz haeufig # nicht, auf dem Proxmox-Host dagegen schon. Ein Link mit Hostnamen ist dann # unbrauchbar - deshalb wird die IP eingesetzt. _ADDRESSES = {} def node_address(node): """IP-Adresse eines Nodes; faellt auf den Namen zurueck.""" node = str(node or "") if not node: return node if not _ADDRESSES: try: with open("/etc/pve/.members", "r", encoding="utf-8") as handle: data = json.load(handle) for name, entry in (data.get("nodelist") or {}).items(): if entry.get("ip"): _ADDRESSES[name] = entry["ip"] except (OSError, ValueError, AttributeError) as exc: log.debug("/etc/pve/.members nicht lesbar: %s", exc) _ADDRESSES["-"] = "-" # nicht bei jedem Aufruf erneut versuchen return _ADDRESSES.get(node) or node 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 transfers: list = field(default_factory=list) # Namen vorhandener Laufwerke spice: bool = False # SPICE-Anzeige (vga: qxl) usb: int = 0 # so viele USB-Weiterleitungen fuer SPICE iso: str = "" # Abbild als CD einlegen (volid) 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 transfers: list = field(default_factory=list) # [transfer.Volume] spice: bool = False usb: int = 0 iso: 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]] transfers: list = field(default_factory=list) 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): """noVNC-Adresse. Der Node steht als Name im Verweis - das erwartet Proxmox so -, die Adresse davor aber als IP.""" 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 node_address(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", "transfers", "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", "transfers", "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) # -- Transfer-Laufwerke ------------------------------------------------ for name in spec.transfers: volume = transfer.get(name) # wirft, wenn es das nicht gibt if not volume.free: raise RecoveryError("Transfer-Laufwerk %r ist gerade %s." % (name, volume.state_text)) result.transfers.append(volume) # -- SPICE, USB-Weiterleitung, CD -------------------------------------- result.usb = max(0, int(spec.usb or 0)) result.spice = bool(spec.spice) result.iso = spec.iso or "" if result.usb > 14: raise RecoveryError("Hoechstens 14 USB-Weiterleitungen.") # USB-Weiterleitung laeuft ueber den SPICE-Kanal. Ohne SPICE legt QEMU die # Geraete zwar an, aber es gibt keine Verbindung, die sie transportiert - # "no spice port". Also gehoert beides zusammen. if result.usb and not result.spice: result.spice = True result.notes.append( "SPICE-Anzeige wird mit eingeschaltet: die USB-Weiterleitung laeuft " "ueber den SPICE-Kanal, ohne den waeren die Anschluesse wirkungslos.") original_vga = str(config.get("vga") or "") if result.spice and result.resume and not original_vga.startswith("qxl"): raise RecoveryError( "SPICE und geladener Arbeitsspeicher gehen bei dieser Maschine " "nicht zusammen.\n" "SPICE verlangt 'vga: qxl' (64 MB Grafikspeicher), im Snapshot " "steckt die Vorgabe mit 16 MB. Der gespeicherte Zustand passt dann " "nicht mehr und die Maschine bleibt beim Laden haengen " "('Size mismatch: vga.vram').\n" "\n" "Moeglichkeiten:\n" " --no-resume kalt starten, dafuer mit SPICE und USB\n" " ohne --spice warmer RAM-Zustand, dafuer nur noVNC\n" "\n" "Dauerhaft loesen laesst sich das nur am Original: steht dort " "'vga: qxl', tragen alle kuenftigen Snapshots das mit, und beides " "geht gleichzeitig.") if result.spice: result.notes.append( "Zugang auf zwei Wegen: noVNC im Browser und SPICE ueber " "'pvesnap-recovery spice %d' (remote-viewer)." % result.newid) if result.usb: result.notes.append( "Im remote-viewer unter 'USB-Geraeteauswahl' das Geraet anhaken - " "es kommt vom eigenen Rechner, nicht ueber das Netz des Gastes. " "Guest-Tools braucht es dafuer nicht.") if result.iso: result.notes.append("Eingelegt wird %s - im Gast als CD-Laufwerk." % result.iso) # -- 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 --------------------------------------------- # -- Transfer-Laufwerke -------------------------------------------- attached = [] for volume in plan_.transfers: if guest.type == "lxc": # Beim Container wird der Mountpunkt des Hosts durchgereicht. # Ein Bind-Mount ist dasselbe Dateisystem an zwei Stellen, kein # zweites Einhaengen - Host und Gast koennen beide arbeiten. step("Reiche Transfer-Laufwerk %r durch" % volume.name) path = transfer.host_mount(volume.name) slot = _free_mp_slot(new_config) value = "%s,mp=/mnt/%s" % (path, volume.name) new_config[slot] = value attached.append({"name": volume.name, "key": slot, "drive": value, "kind": "bind", "pending": False}) else: step("Haenge Transfer-Laufwerk %r an" % volume.name) slot = _free_disk_slot(new_config) value = transfer.drive_string(volume.name) # Mit geladenem Arbeitsspeicher wacht der Gast in einem Zustand # auf, in dem es die Platte nicht gab - dann muss sie erst nach # dem Fortsetzen dazukommen, sonst bemerkt er sie nie. if not plan_.resume: new_config[slot] = value attached.append({"name": volume.name, "key": slot, "drive": value, "kind": "disk", "pending": bool(plan_.resume)}) # -- SPICE, USB-Weiterleitung, CD ---------------------------------- if plan_.spice: step("Schalte SPICE-Anzeige ein (vga: qxl)") new_config["vga"] = "qxl" for _ in range(plan_.usb): slot = _free_usb_slot(new_config) new_config[slot] = "spice" if plan_.usb: step("%d USB-Weiterleitung(en) fuer SPICE eingerichtet" % plan_.usb) if plan_.iso: slot = _free_cdrom_slot(new_config) new_config[slot] = "%s,media=cdrom" % plan_.iso step("Lege %s als CD ein (%s)" % (plan_.iso, slot)) # -- 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, resumed=plan_.resume, transfers=attached) _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) for volume in plan_.transfers: try: transfer.host_umount(volume.name) transfer.release(volume.name) except transfer.TransferError: pass 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): """Was erst nach dem Fortsetzen dazukommen darf, jetzt anstecken.""" notes = [] for entry in _transfer_entries(instance): if not entry.get("pending"): continue if step: step("Stecke Transfer-Laufwerk %r als %s an" % (entry["name"], entry["key"])) try: proxmox.set_guest_config(instance.guest, {entry["key"]: entry["drive"]}) config = proxmox.guest_config(instance.guest) except ProxmoxError as exc: notes.append("Transfer-Laufwerk %r liess sich nicht anstecken: %s" % (entry["name"], exc)) continue if entry["key"] not in config: notes.append("Transfer-Laufwerk %r ist eingetragen, aber nicht " "angesteckt - es erscheint erst nach einem Neustart." % entry["name"]) else: entry["pending"] = False _registry_add(instance) return " ".join(notes) def _transfer_entries(instance): """Die Transfer-Laufwerke einer Instanz als Datensaetze. Aeltere Merklisten enthalten nur Namen - die werden hier mitgelesen. """ entries = [] for entry in instance.transfers or []: if isinstance(entry, dict): entries.append(entry) else: entries.append({"name": str(entry), "key": "", "drive": "", "kind": "disk", "pending": False}) return entries def transfer_state(proxmox, instance): """[(Datensatz, eingesteckt?)] - was gerade in der Maschine steckt.""" try: config = proxmox.guest_config(instance.guest) except ProxmoxError: config = {} return [(entry, bool(entry.get("key") and entry["key"] in config)) for entry in _transfer_entries(instance)] def eject_transfer(proxmox, instance, name, progress=None): """Ein Transfer-Laufwerk bei laufender Maschine abziehen. Danach gehoert es wieder dem Host: einhaengen, befuellen, auslesen - und mit insert_transfer() wieder hineingeben, ohne die Maschine anzufassen. Wichtig: im Gast vorher aushaengen. Proxmox meldet das Geraet ab, aber ein Dateisystem, das noch beschrieben wird, nimmt das uebel. """ def step(text): log.info("%s", text) if progress: progress(text) entry = next((e for e in _transfer_entries(instance) if e["name"] == name), None) if entry is None: raise RecoveryError("%r haengt nicht an %s." % (name, instance.label)) if entry.get("kind") == "bind": raise RecoveryError( "Bei Containern gibt es nichts auszuwerfen: das Verzeichnis ist " "durchgereicht, Host und Gast sehen dieselben Dateien sofort.") if not entry.get("key"): raise RecoveryError("Zu %r ist kein Steckplatz vermerkt." % name) step("Werfe %r aus (%s)" % (name, entry["key"])) try: proxmox.set_guest_config(instance.guest, {"delete": entry["key"]}) except ProxmoxError as exc: raise RecoveryError( "%r liess sich nicht abziehen: %s\n" "Meist haelt der Gast es noch - dort erst aushaengen " "(Linux: umount, Windows: Auswerfen im Explorer)." % (name, exc)) entry["pending"] = True _registry_add(instance) transfer.release(name) step("%r gehoert jetzt wieder dem Host" % name) return True def insert_transfer(proxmox, instance, name, progress=None): """Ein zuvor ausgeworfenes Transfer-Laufwerk wieder einklinken.""" def step(text): log.info("%s", text) if progress: progress(text) entry = next((e for e in _transfer_entries(instance) if e["name"] == name), None) if entry is None: raise RecoveryError("%r gehoert nicht zu %s." % (name, instance.label)) if entry.get("kind") == "bind": raise RecoveryError("Bei Containern ist das Verzeichnis dauerhaft " "durchgereicht - nichts einzuklinken.") volume = transfer.get(name) if volume.mountpoint: step("%r ist noch auf dem Host eingehaengt - haenge aus" % name) transfer.host_umount(name) drive = transfer.drive_string(name) entry["drive"] = drive step("Klinke %r wieder ein (%s)" % (name, entry["key"])) try: proxmox.set_guest_config(instance.guest, {entry["key"]: drive}) except ProxmoxError as exc: raise RecoveryError("%r liess sich nicht einklinken: %s" % (name, exc)) entry["pending"] = False _registry_add(instance) return True # Die Reihenfolge ist die einer .vv-Datei fuer remote-viewer; "ca" muss ganz # zum Schluss, weil das Zertifikat mehrzeilig ist. _VV_KEYS = ("type", "host", "port", "tls-port", "password", "proxy", "host-subject", "title", "toggle-fullscreen", "release-cursor", "secure-attention", "delete-this-file", "ca") def spice_file(proxmox, instance): """Verbindungsbeschreibung fuer remote-viewer (.vv) als Text. Proxmox liefert die Angaben als JSON; daraus wird hier die Datei gebaut, die virt-viewer erwartet. Sie enthaelt ein Einmal-Kennwort und laeuft nach kurzer Zeit ab - also frisch erzeugen, wenn sie nicht mehr zieht. """ data = proxmox._json(["create", "/nodes/%s/%s/%d/spiceproxy" % (instance.node, instance.type, instance.vmid)]) if not isinstance(data, dict) or not data.get("host"): raise RecoveryError( "%s liefert keine SPICE-Verbindung. Laeuft die Maschine, und ist " "die SPICE-Anzeige eingeschaltet (vga: qxl)?" % instance.label) # Derselbe Grund wie beim noVNC-Verweis: der Proxy-Eintrag traegt den # Hostnamen des Nodes, und wenn der Arbeitsplatz ihn nicht aufloest, # kommt keine Verbindung zustande. Die TLS-Pruefung laeuft ueber # "host-subject", nicht ueber diesen Namen - die bleibt also gueltig. address = node_address(instance.node) proxy = str(data.get("proxy") or "") if address != instance.node and proxy: for name in (instance.node, "%s." % instance.node): if name in proxy: data["proxy"] = re.sub(r"//[^:/]+", "//" + address, proxy, count=1) break lines = ["[virt-viewer]"] for key in _VV_KEYS: if key in data and data[key] not in (None, ""): # Bewusst ohne Umwandlung: das Zertifikat enthaelt die Zeichenfolge # \n als zwei Zeichen, und genau so erwartet virt-viewer sie. Wuerde # man daraus echte Zeilenumbrueche machen, stuende nur die erste # Zeile hinter "ca=" und der Rest waere kaputt. lines.append("%s=%s" % (key, data[key])) for key in sorted(data): if key not in _VV_KEYS and data[key] not in (None, ""): lines.append("%s=%s" % (key, data[key])) return "\n".join(lines) + "\n" 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_usb_slot(config): for index in range(0, 14): key = "usb%d" % index if key not in config: return key raise RecoveryError("Alle 14 USB-Anschluesse sind belegt.") def _free_cdrom_slot(config): """Ein CD-Laufwerk - moeglichst das leere, das schon da ist.""" for key in sorted(config): if QEMU_DISK_KEY.match(key) and "media=cdrom" in str(config[key]) \ and str(config[key]).split(",")[0] in ("none", "cdrom"): return key for index in range(0, 4): key = "ide%d" % index if key not in config: return key raise RecoveryError("Kein Platz fuer ein CD-Laufwerk frei.") 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 # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # Uebersicht und Verwerfen # --------------------------------------------------------------------------- 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) for name in [e["name"] for e in _transfer_entries(instance)]: step("Gebe Transfer-Laufwerk %r wieder frei" % name) try: transfer.host_umount(name) transfer.release(name) except transfer.TransferError as exc: log.warning("%s: %s", name, exc) _registry_remove(instance.vmid) step("Fertig.")