Am ganzen Durchlauf gemessen: VM 9802 aus einem 802-Snapshot mit geladenem Arbeitsspeicher gestartet, dann geflattet, waehrend sie lief. 32-GiB-Platte, 13 GiB kopiert, rund 70s bei jeder Messung qmpstatus=running, QEMU-Monitor antwortete Uhr auf der Konsole lief weiter (10:38 -> 10:39, mit Bildschirmanimation) danach kein PARENT mehr, Quell-Snapshots entschuetzt Kostenschaetzung sagte 14.1G vorher - kopiert wurden 13 GiB Anders als beim Live-Restore des Proxmox Backup Servers wird nichts nachgeladen: der Klon liegt im selben Pool und ist von Anfang an vollstaendig. flatten loest nur die Abhaengigkeit, nicht die Verfuegbarkeit. Nebenbei: ein Datentraeger, den es gar nicht mehr gibt - etwa der kopierte Arbeitsspeicher, den Proxmox nach dem Fortsetzen selbst freigibt - wurde als "war schon eigenstaendig" gemeldet. Er wird jetzt uebergangen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1521 lines
58 KiB
Python
1521 lines
58 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_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":
|
|
result.notes.append(
|
|
"Die Datentraeger sind Linked Clones - sie haengen am Quell-Snapshot, "
|
|
"der dadurch unloeschbar wird. Soll die Maschine dauerhaft laufen, "
|
|
"spaeter mit 'pvesnap-recovery flatten %d' loesen." % result.newid)
|
|
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)
|