Auswerfen und wieder einklinken, ohne die Maschine anzufassen - damit laesst
sich mehrfach nachladen und abholen, statt nur einmal am Ende.
eject_transfer() zieht die Platte ab, danach gehoert sie dem Host
insert_transfer() gibt sie zurueck in denselben Steckplatz
transfer_state() was gerade wirklich steckt
In der Detailansicht auf Taste e; bei mehreren Laufwerken mit Auswahl. Vor dem
Auswerfen wird nachgefragt, mit dem Hinweis, im Gast vorher auszuhaengen -
Proxmox meldet das Geraet zwar ab, aber ein Dateisystem, das noch beschrieben
wird, nimmt das uebel. Schlaegt es fehl, weil der Gast es festhaelt, steht das
auch so in der Meldung.
Beim Container gibt es nichts zu wechseln und das wird auch so gesagt: dort
ist das Verzeichnis durchgereicht, Host und Gast sehen dieselben Dateien
ohnehin gleichzeitig.
Dabei ein Fehler aufgefallen, den erst der Test zeigte: bei einer Maschine mit
geladenem Arbeitsspeicher steckte das Transfer-Laufwerk in der kalten
Konfiguration - der Gast haette es nie bemerkt. Dieselbe Falle wie beim
Austauschlaufwerk, dort schon behoben, hier vergessen. Beide gehen jetzt durch
dieselbe Nachsteck-Logik.
Am laufenden System durchgespielt, mit RAM-Zustand:
Start "Stecke Transfer-Laufwerk 'hotswap' als scsi2 an"
auswerfen eingesteckt=False, Laufwerk wieder frei
befuellen runde2.txt vom Host, VM lief weiter
einklinken scsi2: /dev/loop0,backup=0,size=1G
Ende beide Dateien da, VM durchgehend running
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1731 lines
66 KiB
Python
1731 lines
66 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 . import transfer
|
|
from .proxmox import Guest, ProxmoxError
|
|
|
|
log = logging.getLogger("pvesnap.recovery")
|
|
|
|
TAG = "pvesnap-recovery"
|
|
STATE_DIR = "/var/lib/pvesnap"
|
|
REGISTRY = os.path.join(STATE_DIR, "recovery.json")
|
|
EXCHANGE_ROOT = os.path.join(STATE_DIR, "exchange")
|
|
EXCHANGE_MOUNT = "/run/pvesnap/exchange"
|
|
CONF_ROOT = "/etc/pve/nodes"
|
|
|
|
QEMU_DISK_KEY = re.compile(r"^(ide|sata|scsi|virtio)\d+$")
|
|
QEMU_EXTRA_DISKS = ("efidisk0", "tpmstate0")
|
|
LXC_DISK_KEY = re.compile(r"^(rootfs|mp\d+)$")
|
|
NET_KEY = re.compile(r"^net\d+$")
|
|
|
|
# Nichts uebernehmen, was zum Snapshot oder zur Einbettung des Originals
|
|
# gehoert. vmstate/runningmachine/runningcpu werden spaeter gezielt gesetzt.
|
|
#
|
|
# vmgenid steht bewusst NICHT hier: die Kennung gehoert zur Identitaet der
|
|
# Maschine ("als waere es dieselbe") und steckt ausserdem als eigenes Geraet
|
|
# im gespeicherten Arbeitsspeicher. Fehlt sie, bricht das Laden des RAM-Standes
|
|
# mit "Unknown savevm section or instance 'vmgenid'" ab.
|
|
DROP_KEYS = {
|
|
"parent", "snaptime", "digest", "snapstate", "pending", "lock", "template",
|
|
"description", "tags", "onboot", "startup", "protection", "hookscript",
|
|
"replicate", "vmstate", "runningmachine", "runningcpu",
|
|
}
|
|
|
|
DEFAULT_EXCHANGE_SIZE = "10G"
|
|
DEFAULT_EXCHANGE_FS = "ext4"
|
|
EXCHANGE_LABEL = "PVESNAP"
|
|
EXCHANGE_GUEST_PATH = "/mnt/pvesnap"
|
|
|
|
MKFS = {
|
|
"ext4": ["mkfs.ext4", "-q", "-m", "0", "-L", EXCHANGE_LABEL],
|
|
"ext3": ["mkfs.ext3", "-q", "-m", "0", "-L", EXCHANGE_LABEL],
|
|
"xfs": ["mkfs.xfs", "-q", "-L", EXCHANGE_LABEL],
|
|
"vfat": ["mkfs.vfat", "-n", EXCHANGE_LABEL],
|
|
"exfat": ["mkfs.exfat", "-L", EXCHANGE_LABEL],
|
|
}
|
|
|
|
README = """Austauschlaufwerk von pvesnap-recovery
|
|
|
|
Dieses Laufwerk gehoert nicht zur urspruenglichen Maschine. Es ist leer
|
|
angelegt worden, damit Daten aus diesem Gast wieder herauskommen - der Gast
|
|
hat ja kein Netzwerk.
|
|
|
|
1. Hier hineinschreiben, z.B.
|
|
mysqldump --all-databases > /mnt/pvesnap/dump.sql
|
|
2. Gast herunterfahren
|
|
3. Auf dem Proxmox-Host: pvesnap-recovery pull <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
|
|
transfers: list = field(default_factory=list) # Namen vorhandener Laufwerke
|
|
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 = ""
|
|
transfers: list = field(default_factory=list) # [transfer.Volume]
|
|
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)
|
|
transfers: list = field(default_factory=list)
|
|
resumed: bool = False
|
|
status: str = ""
|
|
|
|
@property
|
|
def guest(self):
|
|
return Guest(vmid=self.vmid, name=self.name, type=self.type, node=self.node)
|
|
|
|
@property
|
|
def label(self):
|
|
return "%s %d" % ("LXC" if self.type == "lxc" else "VM", self.vmid)
|
|
|
|
@property
|
|
def origin(self):
|
|
return "%s %d @ %s" % ("LXC" if self.type == "lxc" else "VM",
|
|
self.source, self.snapshot)
|
|
|
|
def console_url(self, host=None):
|
|
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", "transfers",
|
|
"resumed")}
|
|
|
|
@classmethod
|
|
def from_dict(cls, data):
|
|
known = {k: data.get(k) for k in
|
|
("vmid", "type", "node", "name", "source", "source_node", "snapshot",
|
|
"mode", "created", "volumes", "protected", "exchange", "transfers",
|
|
"resumed") if data.get(k) is not None}
|
|
known["vmid"] = int(known.get("vmid") or 0)
|
|
return cls(**known)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Merkliste
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _registry_load():
|
|
try:
|
|
with open(REGISTRY, "r", encoding="utf-8") as handle:
|
|
data = json.load(handle)
|
|
return [Instance.from_dict(entry) for entry in data] if isinstance(data, list) else []
|
|
except (OSError, ValueError, TypeError):
|
|
return []
|
|
|
|
|
|
def _registry_save(instances):
|
|
try:
|
|
os.makedirs(STATE_DIR, exist_ok=True)
|
|
tmp = REGISTRY + ".tmp"
|
|
with open(tmp, "w", encoding="utf-8") as handle:
|
|
json.dump([i.to_dict() for i in instances], handle, indent=1)
|
|
os.replace(tmp, REGISTRY)
|
|
except OSError as exc:
|
|
log.warning("Merkliste %s nicht schreibbar: %s", REGISTRY, exc)
|
|
|
|
|
|
def _registry_add(instance):
|
|
entries = [i for i in _registry_load() if i.vmid != instance.vmid]
|
|
entries.append(instance)
|
|
_registry_save(entries)
|
|
|
|
|
|
def _registry_remove(vmid):
|
|
_registry_save([i for i in _registry_load() if i.vmid != int(vmid)])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Konfigurationsdatei des neuen Gastes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def config_path(node, guest_type, vmid):
|
|
sub = "lxc" if guest_type == "lxc" else "qemu-server"
|
|
return os.path.join(CONF_ROOT, node, sub, "%d.conf" % vmid)
|
|
|
|
|
|
def _write_config(path, config, description=""):
|
|
"""Konfiguration anlegen - und dabei die VMID belegen.
|
|
|
|
O_EXCL: existiert die Datei schon, gehoert die VMID jemand anderem. Auf
|
|
/etc/pve ist das die zuverlaessigste Reservierung, die es gibt.
|
|
Die Beschreibung steht bei Proxmox als '#'-Zeilen am Dateianfang.
|
|
"""
|
|
lines = []
|
|
for line in str(description or "").rstrip("\n").split("\n"):
|
|
if description:
|
|
lines.append("#" + line)
|
|
for key in sorted(config):
|
|
value = config[key]
|
|
if value is None or value == "":
|
|
continue
|
|
lines.append("%s: %s" % (key, value))
|
|
text = "\n".join(lines) + "\n"
|
|
|
|
directory = os.path.dirname(path)
|
|
if not os.path.isdir(directory):
|
|
raise RecoveryError("Verzeichnis %s gibt es nicht - Node-Name richtig?"
|
|
% directory)
|
|
try:
|
|
handle = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o640)
|
|
except FileExistsError:
|
|
raise RecoveryError("%s gibt es bereits - die VMID ist belegt." % path)
|
|
except OSError as exc:
|
|
raise RecoveryError("%s nicht anlegbar: %s" % (path, exc))
|
|
try:
|
|
os.write(handle, text.encode("utf-8"))
|
|
finally:
|
|
os.close(handle)
|
|
|
|
|
|
def _replace_volid(value, new_volid):
|
|
parts = str(value).split(",")
|
|
parts[0] = new_volid
|
|
return ",".join(parts)
|
|
|
|
|
|
def _volid_of(value):
|
|
first = str(value).split(",", 1)[0].strip()
|
|
return first if ":" in first and first not in ("none", "cdrom") else ""
|
|
|
|
|
|
def _is_cdrom(value):
|
|
return "media=cdrom" in str(value)
|
|
|
|
|
|
def _set_option(value, option, wanted):
|
|
"""Eine Option in einem Proxmox-Wertfeld setzen oder ersetzen."""
|
|
parts = [p for p in str(value).split(",") if not p.startswith(option + "=")]
|
|
parts.append("%s=%s" % (option, wanted))
|
|
return ",".join(parts)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Planen
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _disk_keys(config, guest_type):
|
|
if guest_type == "lxc":
|
|
return [k for k in sorted(config) if LXC_DISK_KEY.match(k)]
|
|
keys = [k for k in sorted(config) if QEMU_DISK_KEY.match(k)]
|
|
keys += [k for k in QEMU_EXTRA_DISKS if k in config]
|
|
return keys
|
|
|
|
|
|
def plan(proxmox, guest, snapname, spec):
|
|
"""Alles pruefen und zusammenstellen, ohne etwas zu veraendern."""
|
|
config = proxmox.guest_config(guest, snapshot=snapname)
|
|
result = Plan(guest=guest, snapshot=snapname, spec=spec)
|
|
|
|
# -- Ziel-VMID und Node ------------------------------------------------
|
|
if spec.newid:
|
|
if not proxmox.vmid_free(spec.newid):
|
|
raise RecoveryError("VMID %d ist bereits vergeben." % spec.newid)
|
|
result.newid = int(spec.newid)
|
|
else:
|
|
result.newid = proxmox.next_vmid()
|
|
result.node = spec.node or guest.node
|
|
|
|
# -- Datentraeger ------------------------------------------------------
|
|
for key in _disk_keys(config, guest.type):
|
|
value = str(config[key])
|
|
if _is_cdrom(value):
|
|
continue
|
|
volid = _volid_of(value)
|
|
if not volid:
|
|
if guest.type == "lxc" and "mp=" in value:
|
|
result.dropped.append("%s (%s)" % (key, value.split(",")[0]))
|
|
continue
|
|
size = ""
|
|
for part in value.split(",")[1:]:
|
|
if part.startswith("size="):
|
|
size = part[5:]
|
|
result.disks.append((key, volid, size))
|
|
if not result.disks:
|
|
raise RecoveryError("Der Snapshot enthaelt keine Datentraeger, die sich "
|
|
"klonen liessen.")
|
|
|
|
# Storages muessen die Klone tragen koennen und vom Ziel-Node erreichbar sein.
|
|
for _key, volid, _size in result.disks:
|
|
storage = volid.split(":", 1)[0]
|
|
info = _storage_info(proxmox, storage)
|
|
nodes = [n.strip() for n in str(info.get("nodes") or "").split(",") if n.strip()]
|
|
if nodes and result.node not in nodes:
|
|
raise RecoveryError("Storage '%s' ist auf Node '%s' nicht verfuegbar."
|
|
% (storage, result.node))
|
|
if not info.get("shared") and result.node != guest.node:
|
|
raise RecoveryError("Storage '%s' ist nicht geteilt - die Wiederherstellung "
|
|
"muss auf '%s' laufen." % (storage, guest.node))
|
|
|
|
# -- Arbeitsspeicher ---------------------------------------------------
|
|
state_volid = str(config.get("vmstate") or "")
|
|
if state_volid and guest.type == "qemu":
|
|
result.vmstate = state_volid
|
|
want = spec.resume if spec.resume is not None else True
|
|
result.resume = bool(want)
|
|
if result.resume:
|
|
try:
|
|
result.vmstate_bytes = volume_size(state_volid)
|
|
except RecoveryError:
|
|
result.vmstate_bytes = 0
|
|
elif spec.resume:
|
|
result.notes.append("Der Snapshot enthaelt keinen Arbeitsspeicher - die "
|
|
"Maschine bootet kalt (wie nach einem Stromausfall).")
|
|
|
|
# -- Netzwerk ----------------------------------------------------------
|
|
result.nets = [(k, str(config[k])) for k in sorted(config) if NET_KEY.match(k)]
|
|
if spec.net:
|
|
result.net_mode = spec.net
|
|
else:
|
|
result.net_mode = "on" if not spec.isolated else "none"
|
|
|
|
if result.resume and result.net_mode == "none" and result.nets:
|
|
# Ein gespeicherter RAM-Zustand laesst sich nur in eine Maschine mit
|
|
# genau denselben Geraeten laden. Die Netzwerkkarte muss also bleiben -
|
|
# abgeklemmt wird stattdessen die Leitung.
|
|
result.net_mode = "down"
|
|
result.notes.append("Netzwerkkarte bleibt vorhanden, aber abgeklemmt "
|
|
"(link_down): mit geladenem Arbeitsspeicher darf sich "
|
|
"die Geraeteausstattung nicht aendern.")
|
|
|
|
if result.net_mode == "on" and result.nets:
|
|
result.warnings.append(
|
|
"Die Wiederherstellung geht MIT Netzwerk online - mit denselben "
|
|
"MAC-Adressen wie das Original.")
|
|
status = proxmox.guest_status(guest)
|
|
if status.get("status") == "running":
|
|
result.critical.append(
|
|
"%s LAEUFT gerade. Zwei Maschinen mit gleicher MAC-Adresse, "
|
|
"gleicher IP und gleicher Identitaet im selben Netz geben Chaos - "
|
|
"erst das Original stoppen." % guest.label)
|
|
|
|
# -- 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)
|
|
|
|
# -- Transfer-Laufwerke ------------------------------------------------
|
|
for name in spec.transfers:
|
|
volume = transfer.get(name) # wirft, wenn es das nicht gibt
|
|
if not volume.free:
|
|
raise RecoveryError("Transfer-Laufwerk %r ist gerade %s."
|
|
% (name, volume.state_text))
|
|
result.transfers.append(volume)
|
|
|
|
# -- 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}
|
|
|
|
# -- Transfer-Laufwerke --------------------------------------------
|
|
attached = []
|
|
for volume in plan_.transfers:
|
|
if guest.type == "lxc":
|
|
# Beim Container wird der Mountpunkt des Hosts durchgereicht.
|
|
# Ein Bind-Mount ist dasselbe Dateisystem an zwei Stellen, kein
|
|
# zweites Einhaengen - Host und Gast koennen beide arbeiten.
|
|
step("Reiche Transfer-Laufwerk %r durch" % volume.name)
|
|
path = transfer.host_mount(volume.name)
|
|
slot = _free_mp_slot(new_config)
|
|
value = "%s,mp=/mnt/%s" % (path, volume.name)
|
|
new_config[slot] = value
|
|
attached.append({"name": volume.name, "key": slot, "drive": value,
|
|
"kind": "bind", "pending": False})
|
|
else:
|
|
step("Haenge Transfer-Laufwerk %r an" % volume.name)
|
|
slot = _free_disk_slot(new_config)
|
|
value = transfer.drive_string(volume.name)
|
|
# Mit geladenem Arbeitsspeicher wacht der Gast in einem Zustand
|
|
# auf, in dem es die Platte nicht gab - dann muss sie erst nach
|
|
# dem Fortsetzen dazukommen, sonst bemerkt er sie nie.
|
|
if not plan_.resume:
|
|
new_config[slot] = value
|
|
attached.append({"name": volume.name, "key": slot, "drive": value,
|
|
"kind": "disk", "pending": bool(plan_.resume)})
|
|
|
|
# -- 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,
|
|
transfers=attached)
|
|
_registry_add(instance)
|
|
return instance
|
|
|
|
except Exception as exc:
|
|
step("Fehler - raeume wieder ab: %s" % exc)
|
|
if conf_file and os.path.exists(conf_file):
|
|
try:
|
|
os.unlink(conf_file)
|
|
except OSError:
|
|
pass
|
|
for volid in reversed(created):
|
|
try:
|
|
free_volume(volid)
|
|
except RecoveryError as cleanup_exc:
|
|
log.warning("Klon %s blieb liegen: %s", volid, cleanup_exc)
|
|
for volid, snapname in protected:
|
|
try:
|
|
unprotect_snapshot(proxmox, volid, snapname)
|
|
except RecoveryError:
|
|
pass
|
|
for path in made_dirs:
|
|
shutil.rmtree(path, ignore_errors=True)
|
|
for volume in plan_.transfers:
|
|
try:
|
|
transfer.host_umount(volume.name)
|
|
transfer.release(volume.name)
|
|
except transfer.TransferError:
|
|
pass
|
|
raise
|
|
|
|
|
|
# Woran zu erkennen ist, dass der Arbeitsspeicher nicht angekommen ist.
|
|
# Proxmox meldet den Startvorgang trotzdem mit "TASK OK" - die Maschine bleibt
|
|
# dann aber angehalten stehen, ohne dass irgendwo ein Fehler sichtbar waere.
|
|
_STATE_FAILED = ("Error while loading VM state", "Unknown savevm section",
|
|
"Make sure that your current VM setup matches")
|
|
|
|
|
|
def start(proxmox, instance, progress=None):
|
|
"""Hochfahren - und nachsehen, ob dabei herauskam, was gewollt war.
|
|
|
|
Liefert einen Hinweistext, wenn etwas anders lief als geplant, sonst "".
|
|
"""
|
|
def step(text):
|
|
log.info("%s", text)
|
|
if progress:
|
|
progress(text)
|
|
|
|
upid = proxmox.start_guest(instance.guest)
|
|
if instance.type != "qemu":
|
|
return ""
|
|
|
|
note = ""
|
|
if instance.resumed and upid:
|
|
broken = [line.strip() for line in proxmox.task_log(instance.node, upid)
|
|
if any(marker in line for marker in _STATE_FAILED)]
|
|
if broken:
|
|
note = ("Der Arbeitsspeicher liess sich nicht laden - die Maschine "
|
|
"startet stattdessen kalt vom Datentraeger. Proxmox meldet: %s"
|
|
% broken[0])
|
|
|
|
# Nach dem Laden eines RAM-Standes steht die Maschine angehalten da und
|
|
# muss noch fortgesetzt werden.
|
|
for _ in range(15):
|
|
qmp = (proxmox.guest_status(instance.guest).get("qmpstatus") or "")
|
|
if qmp == "running":
|
|
return _attach_pending(proxmox, instance, step) or note
|
|
if qmp in ("paused", "prelaunch"):
|
|
step("Setze %s fort" % instance.label)
|
|
try:
|
|
proxmox.resume_guest(instance.guest)
|
|
except ProxmoxError as exc:
|
|
return "%s laesst sich nicht fortsetzen: %s" % (instance.label, exc)
|
|
time.sleep(1.0)
|
|
if (proxmox.guest_status(instance.guest).get("qmpstatus") or "") == "running":
|
|
return _attach_pending(proxmox, instance, step) or note
|
|
time.sleep(1.0)
|
|
return note or ("%s ist gestartet, laeuft aber nicht. Warum, steht im "
|
|
"Task-Protokoll von Proxmox." % instance.label)
|
|
|
|
|
|
def _attach_pending(proxmox, instance, step=None):
|
|
"""Was erst nach dem Fortsetzen dazukommen darf, jetzt anstecken."""
|
|
notes = []
|
|
for entry in _transfer_entries(instance):
|
|
if not entry.get("pending"):
|
|
continue
|
|
if step:
|
|
step("Stecke Transfer-Laufwerk %r als %s an"
|
|
% (entry["name"], entry["key"]))
|
|
try:
|
|
proxmox.set_guest_config(instance.guest, {entry["key"]: entry["drive"]})
|
|
config = proxmox.guest_config(instance.guest)
|
|
except ProxmoxError as exc:
|
|
notes.append("Transfer-Laufwerk %r liess sich nicht anstecken: %s"
|
|
% (entry["name"], exc))
|
|
continue
|
|
if entry["key"] not in config:
|
|
notes.append("Transfer-Laufwerk %r ist eingetragen, aber nicht "
|
|
"angesteckt - es erscheint erst nach einem Neustart."
|
|
% entry["name"])
|
|
else:
|
|
entry["pending"] = False
|
|
_registry_add(instance)
|
|
|
|
exchange = instance.exchange or {}
|
|
if not exchange.get("pending"):
|
|
return " ".join(notes)
|
|
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:
|
|
notes.append("Das Austauschlaufwerk liess sich nicht anstecken: %s" % exc)
|
|
return " ".join(notes)
|
|
|
|
if key not in config:
|
|
# Proxmox hat es nur vorgemerkt - im Gast taucht es dann nicht auf.
|
|
notes.append("Das Austauschlaufwerk ist eingetragen, aber nicht "
|
|
"angesteckt worden (Hotplug fuer Platten ist bei dieser "
|
|
"Maschine aus). Es erscheint erst nach einem Neustart.")
|
|
else:
|
|
exchange["pending"] = False
|
|
_registry_add(instance)
|
|
return " ".join(notes)
|
|
|
|
|
|
def _transfer_entries(instance):
|
|
"""Die Transfer-Laufwerke einer Instanz als Datensaetze.
|
|
|
|
Aeltere Merklisten enthalten nur Namen - die werden hier mitgelesen.
|
|
"""
|
|
entries = []
|
|
for entry in instance.transfers or []:
|
|
if isinstance(entry, dict):
|
|
entries.append(entry)
|
|
else:
|
|
entries.append({"name": str(entry), "key": "", "drive": "",
|
|
"kind": "disk", "pending": False})
|
|
return entries
|
|
|
|
|
|
def transfer_state(proxmox, instance):
|
|
"""[(Datensatz, eingesteckt?)] - was gerade in der Maschine steckt."""
|
|
try:
|
|
config = proxmox.guest_config(instance.guest)
|
|
except ProxmoxError:
|
|
config = {}
|
|
return [(entry, bool(entry.get("key") and entry["key"] in config))
|
|
for entry in _transfer_entries(instance)]
|
|
|
|
|
|
def eject_transfer(proxmox, instance, name, progress=None):
|
|
"""Ein Transfer-Laufwerk bei laufender Maschine abziehen.
|
|
|
|
Danach gehoert es wieder dem Host: einhaengen, befuellen, auslesen - und
|
|
mit insert_transfer() wieder hineingeben, ohne die Maschine anzufassen.
|
|
|
|
Wichtig: im Gast vorher aushaengen. Proxmox meldet das Geraet ab, aber ein
|
|
Dateisystem, das noch beschrieben wird, nimmt das uebel.
|
|
"""
|
|
def step(text):
|
|
log.info("%s", text)
|
|
if progress:
|
|
progress(text)
|
|
|
|
entry = next((e for e in _transfer_entries(instance) if e["name"] == name), None)
|
|
if entry is None:
|
|
raise RecoveryError("%r haengt nicht an %s." % (name, instance.label))
|
|
if entry.get("kind") == "bind":
|
|
raise RecoveryError(
|
|
"Bei Containern gibt es nichts auszuwerfen: das Verzeichnis ist "
|
|
"durchgereicht, Host und Gast sehen dieselben Dateien sofort.")
|
|
if not entry.get("key"):
|
|
raise RecoveryError("Zu %r ist kein Steckplatz vermerkt." % name)
|
|
|
|
step("Werfe %r aus (%s)" % (name, entry["key"]))
|
|
try:
|
|
proxmox.set_guest_config(instance.guest, {"delete": entry["key"]})
|
|
except ProxmoxError as exc:
|
|
raise RecoveryError(
|
|
"%r liess sich nicht abziehen: %s\n"
|
|
"Meist haelt der Gast es noch - dort erst aushaengen "
|
|
"(Linux: umount, Windows: Auswerfen im Explorer)." % (name, exc))
|
|
|
|
entry["pending"] = True
|
|
_registry_add(instance)
|
|
transfer.release(name)
|
|
step("%r gehoert jetzt wieder dem Host" % name)
|
|
return True
|
|
|
|
|
|
def insert_transfer(proxmox, instance, name, progress=None):
|
|
"""Ein zuvor ausgeworfenes Transfer-Laufwerk wieder einklinken."""
|
|
def step(text):
|
|
log.info("%s", text)
|
|
if progress:
|
|
progress(text)
|
|
|
|
entry = next((e for e in _transfer_entries(instance) if e["name"] == name), None)
|
|
if entry is None:
|
|
raise RecoveryError("%r gehoert nicht zu %s." % (name, instance.label))
|
|
if entry.get("kind") == "bind":
|
|
raise RecoveryError("Bei Containern ist das Verzeichnis dauerhaft "
|
|
"durchgereicht - nichts einzuklinken.")
|
|
|
|
volume = transfer.get(name)
|
|
if volume.mountpoint:
|
|
step("%r ist noch auf dem Host eingehaengt - haenge aus" % name)
|
|
transfer.host_umount(name)
|
|
|
|
drive = transfer.drive_string(name)
|
|
entry["drive"] = drive
|
|
step("Klinke %r wieder ein (%s)" % (name, entry["key"]))
|
|
try:
|
|
proxmox.set_guest_config(instance.guest, {entry["key"]: drive})
|
|
except ProxmoxError as exc:
|
|
raise RecoveryError("%r liess sich nicht einklinken: %s" % (name, exc))
|
|
entry["pending"] = False
|
|
_registry_add(instance)
|
|
return True
|
|
|
|
|
|
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:
|
|
# Ohne das gehoert das Wurzelverzeichnis root, und ein gewoehnlicher
|
|
# Benutzer im Gast kann nichts hineinschreiben - was sich anfuehlt wie
|
|
# ein schreibgeschuetzter Datentraeger, aber keiner ist.
|
|
try:
|
|
os.chmod(mountpoint, 0o777)
|
|
except OSError:
|
|
pass
|
|
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)
|
|
|
|
for name in [e["name"] for e in _transfer_entries(instance)]:
|
|
step("Gebe Transfer-Laufwerk %r wieder frei" % name)
|
|
try:
|
|
transfer.host_umount(name)
|
|
transfer.release(name)
|
|
except transfer.TransferError as exc:
|
|
log.warning("%s: %s", name, exc)
|
|
|
|
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)
|