Files
proxmox-snapshot-service/pvesnap/recovery.py
T
duffyduckandClaude Opus 5 2c85011a08 Storage-Unterschiede beim Abkoppeln benennen statt pauschal zu melden
Die Abhaengigkeit vom Quell-Snapshot ist eine Ceph-Eigenheit. Bisher meldete
flatten alles andere pauschal als "kein Ceph-Datentraeger" - das klingt nach
Einschraenkung, ist bei LVM-thin aber das Gegenteil.

Nachgemessen auf local-lvm: ein Thin-Snapshot teilt sich die Bloecke im Pool
(256 MB Nutzdaten liegen dort nur einmal), ist aber trotzdem eigenstaendig -
Quell-Snapshot und Original liessen sich loeschen, waehrend der Klon existiert,
und seine Pruefsumme blieb unveraendert. Dort gibt es also weder geschuetzte
Snapshots noch etwas zu flatten.

  lvmthin   nicht noetig, Klon ist von sich aus unabhaengig
  zfspool   nicht moeglich, ginge nur ueber zfs send | zfs recv
  rbd       noetig fuer den Dauerbetrieb
  lvm dick  PVE kann dort gar keine Snapshots

Der Hinweis in der Zusammenfassung richtet sich jetzt ebenfalls nach dem
Storage-Typ, statt immer zum Flatten zu raten.

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

1550 lines
59 KiB
Python

"""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],
"ntfs": ["mkfs.ntfs", "-Q", "-F", "-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 <VMID>
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-<id>-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("<TOTAL>"):
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 <groesse> "
"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)