Bisher liess sich beides nur beim Anlegen waehlen. Wer erst spaeter merkt, dass
er den Dongle braucht, musste die Maschine wegwerfen und neu bauen. Jetzt geht
es in der Detailansicht auf Taste p, mit dem Ist-Zustand im Dialog.
Was dabei geht und was nicht, ist am laufenden System durchgemessen:
SPICE ein/aus Neustart noetig - die Grafikkarte laesst sich einer
laufenden Maschine nicht unterschieben
USB dazu/weg sofort, solange der SPICE-Kanal schon steht
USB ohne SPICE schaltet SPICE mit ein, sonst waeren die Anschluesse
wirkungslos
Vor einem noetigen Neustart wird gewarnt, und bei einer Maschine mit geladenem
Arbeitsspeicher besonders deutlich: der ist danach endgueltig weg, weil
Proxmox ihn schon beim ersten Start freigibt. Ein zweiter startet kalt, offene
Programme und ungespeicherte Daten sind verloren.
Beim Abschalten wird der vga-Eintrag entfernt statt auf "std" gesetzt - dann
steht die Maschine wieder genau so da wie vorher, ohne eine Zeile, die es nie
gab.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1668 lines
65 KiB
Python
1668 lines
65 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")
|
|
CONF_ROOT = "/etc/pve/nodes"
|
|
|
|
QEMU_DISK_KEY = re.compile(r"^(ide|sata|scsi|virtio)\d+$")
|
|
QEMU_EXTRA_DISKS = ("efidisk0", "tpmstate0")
|
|
LXC_DISK_KEY = re.compile(r"^(rootfs|mp\d+)$")
|
|
NET_KEY = re.compile(r"^net\d+$")
|
|
|
|
# Nichts uebernehmen, was zum Snapshot oder zur Einbettung des Originals
|
|
# gehoert. vmstate/runningmachine/runningcpu werden spaeter gezielt gesetzt.
|
|
#
|
|
# vmgenid steht bewusst NICHT hier: die Kennung gehoert zur Identitaet der
|
|
# Maschine ("als waere es dieselbe") und steckt ausserdem als eigenes Geraet
|
|
# im gespeicherten Arbeitsspeicher. Fehlt sie, bricht das Laden des RAM-Standes
|
|
# mit "Unknown savevm section or instance 'vmgenid'" ab.
|
|
DROP_KEYS = {
|
|
"parent", "snaptime", "digest", "snapstate", "pending", "lock", "template",
|
|
"description", "tags", "onboot", "startup", "protection", "hookscript",
|
|
"replicate", "vmstate", "runningmachine", "runningcpu",
|
|
}
|
|
|
|
# Node-Adressen: die Namensaufloesung klappt auf dem Arbeitsplatz haeufig
|
|
# nicht, auf dem Proxmox-Host dagegen schon. Ein Link mit Hostnamen ist dann
|
|
# unbrauchbar - deshalb wird die IP eingesetzt.
|
|
_ADDRESSES = {}
|
|
|
|
|
|
def node_address(node):
|
|
"""IP-Adresse eines Nodes; faellt auf den Namen zurueck."""
|
|
node = str(node or "")
|
|
if not node:
|
|
return node
|
|
if not _ADDRESSES:
|
|
try:
|
|
with open("/etc/pve/.members", "r", encoding="utf-8") as handle:
|
|
data = json.load(handle)
|
|
for name, entry in (data.get("nodelist") or {}).items():
|
|
if entry.get("ip"):
|
|
_ADDRESSES[name] = entry["ip"]
|
|
except (OSError, ValueError, AttributeError) as exc:
|
|
log.debug("/etc/pve/.members nicht lesbar: %s", exc)
|
|
_ADDRESSES["-"] = "-" # nicht bei jedem Aufruf erneut versuchen
|
|
return _ADDRESSES.get(node) or node
|
|
|
|
|
|
class RecoveryError(Exception):
|
|
"""Eine Wiederherstellung liess sich nicht einrichten."""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Kommandos
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def run(args, timeout=300, check=True, quiet=False):
|
|
if not quiet:
|
|
log.debug("exec: %s", " ".join(str(a) for a in args))
|
|
try:
|
|
proc = subprocess.run([str(a) for a in args], stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE, timeout=timeout)
|
|
except FileNotFoundError:
|
|
raise RecoveryError("Kommando nicht gefunden: %s" % args[0])
|
|
except subprocess.TimeoutExpired:
|
|
raise RecoveryError("Zeitueberschreitung bei: %s" % " ".join(str(a) for a in args))
|
|
stdout = proc.stdout.decode("utf-8", "replace").strip()
|
|
stderr = proc.stderr.decode("utf-8", "replace").strip()
|
|
if check and proc.returncode != 0:
|
|
detail = (stderr or stdout or "Fehler").strip().splitlines()
|
|
raise RecoveryError("%s: %s" % (args[0], detail[-1] if detail else "Fehler"))
|
|
return stdout
|
|
|
|
|
|
def have(command):
|
|
return shutil.which(command) is not None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Storage-Ebene - Proxmox ihre eigene Arbeit machen lassen
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Statt rbd/zfs/lvcreate selbst nachzubauen, wird die Storage-Schicht von
|
|
# Proxmox aufgerufen. Die kennt jeden Storage-Typ, benennt die Volumes richtig
|
|
# (vm-<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
|
|
transfers: list = field(default_factory=list) # Namen vorhandener Laufwerke
|
|
spice: bool = False # SPICE-Anzeige (vga: qxl)
|
|
usb: int = 0 # so viele USB-Weiterleitungen fuer SPICE
|
|
iso: str = "" # Abbild als CD einlegen (volid)
|
|
memory: int = 0 # 0 = wie im Snapshot
|
|
cores: int = 0
|
|
name: str = ""
|
|
keep_binds: bool = False
|
|
start: bool = True
|
|
|
|
@property
|
|
def isolated(self):
|
|
return self.mode != "recovery"
|
|
|
|
|
|
@dataclass
|
|
class Plan:
|
|
"""Was passieren wird - ohne dass schon etwas passiert ist."""
|
|
guest: object
|
|
snapshot: str
|
|
spec: Spec
|
|
newid: int = 0
|
|
node: str = ""
|
|
disks: list = field(default_factory=list) # [(key, volid, groesse)]
|
|
nets: list = field(default_factory=list) # [(key, wert)]
|
|
net_mode: str = "none"
|
|
vmstate: str = ""
|
|
vmstate_bytes: int = 0
|
|
resume: bool = False
|
|
transfers: list = field(default_factory=list) # [transfer.Volume]
|
|
spice: bool = False
|
|
usb: int = 0
|
|
iso: str = ""
|
|
warnings: list = field(default_factory=list)
|
|
# Was nicht nur unschoen, sondern gefaehrlich ist. Hierfuer genuegt ein
|
|
# beilaeufiges "ja" nicht - siehe cmd_create() und die Oberflaeche.
|
|
critical: list = field(default_factory=list)
|
|
notes: list = field(default_factory=list)
|
|
dropped: list = field(default_factory=list)
|
|
|
|
@property
|
|
def label(self):
|
|
return "%s %d" % ("LXC" if self.guest.type == "lxc" else "VM", self.newid)
|
|
|
|
|
|
@dataclass
|
|
class Instance:
|
|
"""Eine eingerichtete Wiederherstellung."""
|
|
vmid: int
|
|
type: str = "qemu"
|
|
node: str = ""
|
|
name: str = ""
|
|
source: int = 0
|
|
source_node: str = ""
|
|
snapshot: str = ""
|
|
mode: str = "live"
|
|
created: int = 0
|
|
volumes: list = field(default_factory=list)
|
|
protected: list = field(default_factory=list) # [[volid, snapname]]
|
|
transfers: list = field(default_factory=list)
|
|
resumed: bool = False
|
|
status: str = ""
|
|
|
|
@property
|
|
def guest(self):
|
|
return Guest(vmid=self.vmid, name=self.name, type=self.type, node=self.node)
|
|
|
|
@property
|
|
def label(self):
|
|
return "%s %d" % ("LXC" if self.type == "lxc" else "VM", self.vmid)
|
|
|
|
@property
|
|
def origin(self):
|
|
return "%s %d @ %s" % ("LXC" if self.type == "lxc" else "VM",
|
|
self.source, self.snapshot)
|
|
|
|
def console_url(self, host=None):
|
|
"""noVNC-Adresse. Der Node steht als Name im Verweis - das erwartet
|
|
Proxmox so -, die Adresse davor aber als IP."""
|
|
kind = "lxc" if self.type == "lxc" else "kvm"
|
|
return ("https://%s:8006/?console=%s&novnc=1&vmid=%d&node=%s&resize=off&cmd="
|
|
% (host or node_address(self.node), kind, self.vmid, self.node))
|
|
|
|
def to_dict(self):
|
|
return {k: getattr(self, k) for k in
|
|
("vmid", "type", "node", "name", "source", "source_node", "snapshot",
|
|
"mode", "created", "volumes", "protected", "transfers",
|
|
"resumed")}
|
|
|
|
@classmethod
|
|
def from_dict(cls, data):
|
|
known = {k: data.get(k) for k in
|
|
("vmid", "type", "node", "name", "source", "source_node", "snapshot",
|
|
"mode", "created", "volumes", "protected", "transfers",
|
|
"resumed") if data.get(k) is not None}
|
|
known["vmid"] = int(known.get("vmid") or 0)
|
|
return cls(**known)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Merkliste
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _registry_load():
|
|
try:
|
|
with open(REGISTRY, "r", encoding="utf-8") as handle:
|
|
data = json.load(handle)
|
|
return [Instance.from_dict(entry) for entry in data] if isinstance(data, list) else []
|
|
except (OSError, ValueError, TypeError):
|
|
return []
|
|
|
|
|
|
def _registry_save(instances):
|
|
try:
|
|
os.makedirs(STATE_DIR, exist_ok=True)
|
|
tmp = REGISTRY + ".tmp"
|
|
with open(tmp, "w", encoding="utf-8") as handle:
|
|
json.dump([i.to_dict() for i in instances], handle, indent=1)
|
|
os.replace(tmp, REGISTRY)
|
|
except OSError as exc:
|
|
log.warning("Merkliste %s nicht schreibbar: %s", REGISTRY, exc)
|
|
|
|
|
|
def _registry_add(instance):
|
|
entries = [i for i in _registry_load() if i.vmid != instance.vmid]
|
|
entries.append(instance)
|
|
_registry_save(entries)
|
|
|
|
|
|
def _registry_remove(vmid):
|
|
_registry_save([i for i in _registry_load() if i.vmid != int(vmid)])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Konfigurationsdatei des neuen Gastes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def config_path(node, guest_type, vmid):
|
|
sub = "lxc" if guest_type == "lxc" else "qemu-server"
|
|
return os.path.join(CONF_ROOT, node, sub, "%d.conf" % vmid)
|
|
|
|
|
|
def _write_config(path, config, description=""):
|
|
"""Konfiguration anlegen - und dabei die VMID belegen.
|
|
|
|
O_EXCL: existiert die Datei schon, gehoert die VMID jemand anderem. Auf
|
|
/etc/pve ist das die zuverlaessigste Reservierung, die es gibt.
|
|
Die Beschreibung steht bei Proxmox als '#'-Zeilen am Dateianfang.
|
|
"""
|
|
lines = []
|
|
for line in str(description or "").rstrip("\n").split("\n"):
|
|
if description:
|
|
lines.append("#" + line)
|
|
for key in sorted(config):
|
|
value = config[key]
|
|
if value is None or value == "":
|
|
continue
|
|
lines.append("%s: %s" % (key, value))
|
|
text = "\n".join(lines) + "\n"
|
|
|
|
directory = os.path.dirname(path)
|
|
if not os.path.isdir(directory):
|
|
raise RecoveryError("Verzeichnis %s gibt es nicht - Node-Name richtig?"
|
|
% directory)
|
|
try:
|
|
handle = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o640)
|
|
except FileExistsError:
|
|
raise RecoveryError("%s gibt es bereits - die VMID ist belegt." % path)
|
|
except OSError as exc:
|
|
raise RecoveryError("%s nicht anlegbar: %s" % (path, exc))
|
|
try:
|
|
os.write(handle, text.encode("utf-8"))
|
|
finally:
|
|
os.close(handle)
|
|
|
|
|
|
def _replace_volid(value, new_volid):
|
|
parts = str(value).split(",")
|
|
parts[0] = new_volid
|
|
return ",".join(parts)
|
|
|
|
|
|
def _volid_of(value):
|
|
first = str(value).split(",", 1)[0].strip()
|
|
return first if ":" in first and first not in ("none", "cdrom") else ""
|
|
|
|
|
|
def _is_cdrom(value):
|
|
return "media=cdrom" in str(value)
|
|
|
|
|
|
def _set_option(value, option, wanted):
|
|
"""Eine Option in einem Proxmox-Wertfeld setzen oder ersetzen."""
|
|
parts = [p for p in str(value).split(",") if not p.startswith(option + "=")]
|
|
parts.append("%s=%s" % (option, wanted))
|
|
return ",".join(parts)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Planen
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _disk_keys(config, guest_type):
|
|
if guest_type == "lxc":
|
|
return [k for k in sorted(config) if LXC_DISK_KEY.match(k)]
|
|
keys = [k for k in sorted(config) if QEMU_DISK_KEY.match(k)]
|
|
keys += [k for k in QEMU_EXTRA_DISKS if k in config]
|
|
return keys
|
|
|
|
|
|
def plan(proxmox, guest, snapname, spec):
|
|
"""Alles pruefen und zusammenstellen, ohne etwas zu veraendern."""
|
|
config = proxmox.guest_config(guest, snapshot=snapname)
|
|
result = Plan(guest=guest, snapshot=snapname, spec=spec)
|
|
|
|
# -- Ziel-VMID und Node ------------------------------------------------
|
|
if spec.newid:
|
|
if not proxmox.vmid_free(spec.newid):
|
|
raise RecoveryError("VMID %d ist bereits vergeben." % spec.newid)
|
|
result.newid = int(spec.newid)
|
|
else:
|
|
result.newid = proxmox.next_vmid()
|
|
result.node = spec.node or guest.node
|
|
|
|
# -- Datentraeger ------------------------------------------------------
|
|
for key in _disk_keys(config, guest.type):
|
|
value = str(config[key])
|
|
if _is_cdrom(value):
|
|
continue
|
|
volid = _volid_of(value)
|
|
if not volid:
|
|
if guest.type == "lxc" and "mp=" in value:
|
|
result.dropped.append("%s (%s)" % (key, value.split(",")[0]))
|
|
continue
|
|
size = ""
|
|
for part in value.split(",")[1:]:
|
|
if part.startswith("size="):
|
|
size = part[5:]
|
|
result.disks.append((key, volid, size))
|
|
if not result.disks:
|
|
raise RecoveryError("Der Snapshot enthaelt keine Datentraeger, die sich "
|
|
"klonen liessen.")
|
|
|
|
# Storages muessen die Klone tragen koennen und vom Ziel-Node erreichbar sein.
|
|
for _key, volid, _size in result.disks:
|
|
storage = volid.split(":", 1)[0]
|
|
info = _storage_info(proxmox, storage)
|
|
nodes = [n.strip() for n in str(info.get("nodes") or "").split(",") if n.strip()]
|
|
if nodes and result.node not in nodes:
|
|
raise RecoveryError("Storage '%s' ist auf Node '%s' nicht verfuegbar."
|
|
% (storage, result.node))
|
|
if not info.get("shared") and result.node != guest.node:
|
|
raise RecoveryError("Storage '%s' ist nicht geteilt - die Wiederherstellung "
|
|
"muss auf '%s' laufen." % (storage, guest.node))
|
|
|
|
# -- Arbeitsspeicher ---------------------------------------------------
|
|
state_volid = str(config.get("vmstate") or "")
|
|
if state_volid and guest.type == "qemu":
|
|
result.vmstate = state_volid
|
|
want = spec.resume if spec.resume is not None else True
|
|
result.resume = bool(want)
|
|
if result.resume:
|
|
try:
|
|
result.vmstate_bytes = volume_size(state_volid)
|
|
except RecoveryError:
|
|
result.vmstate_bytes = 0
|
|
elif spec.resume:
|
|
result.notes.append("Der Snapshot enthaelt keinen Arbeitsspeicher - die "
|
|
"Maschine bootet kalt (wie nach einem Stromausfall).")
|
|
|
|
# -- Netzwerk ----------------------------------------------------------
|
|
result.nets = [(k, str(config[k])) for k in sorted(config) if NET_KEY.match(k)]
|
|
if spec.net:
|
|
result.net_mode = spec.net
|
|
else:
|
|
result.net_mode = "on" if not spec.isolated else "none"
|
|
|
|
if result.resume and result.net_mode == "none" and result.nets:
|
|
# Ein gespeicherter RAM-Zustand laesst sich nur in eine Maschine mit
|
|
# genau denselben Geraeten laden. Die Netzwerkkarte muss also bleiben -
|
|
# abgeklemmt wird stattdessen die Leitung.
|
|
result.net_mode = "down"
|
|
result.notes.append("Netzwerkkarte bleibt vorhanden, aber abgeklemmt "
|
|
"(link_down): mit geladenem Arbeitsspeicher darf sich "
|
|
"die Geraeteausstattung nicht aendern.")
|
|
|
|
if result.net_mode == "on" and result.nets:
|
|
result.warnings.append(
|
|
"Die Wiederherstellung geht MIT Netzwerk online - mit denselben "
|
|
"MAC-Adressen wie das Original.")
|
|
status = proxmox.guest_status(guest)
|
|
if status.get("status") == "running":
|
|
result.critical.append(
|
|
"%s LAEUFT gerade. Zwei Maschinen mit gleicher MAC-Adresse, "
|
|
"gleicher IP und gleicher Identitaet im selben Netz geben Chaos - "
|
|
"erst das Original stoppen." % guest.label)
|
|
|
|
# -- Transfer-Laufwerke ------------------------------------------------
|
|
for name in spec.transfers:
|
|
volume = transfer.get(name) # wirft, wenn es das nicht gibt
|
|
if not volume.free:
|
|
raise RecoveryError("Transfer-Laufwerk %r ist gerade %s."
|
|
% (name, volume.state_text))
|
|
result.transfers.append(volume)
|
|
|
|
# -- SPICE, USB-Weiterleitung, CD --------------------------------------
|
|
result.usb = max(0, int(spec.usb or 0))
|
|
result.spice = bool(spec.spice)
|
|
result.iso = spec.iso or ""
|
|
|
|
if result.usb > 14:
|
|
raise RecoveryError("Hoechstens 14 USB-Weiterleitungen.")
|
|
|
|
# USB-Weiterleitung laeuft ueber den SPICE-Kanal. Ohne SPICE legt QEMU die
|
|
# Geraete zwar an, aber es gibt keine Verbindung, die sie transportiert -
|
|
# "no spice port". Also gehoert beides zusammen.
|
|
if result.usb and not result.spice:
|
|
result.spice = True
|
|
result.notes.append(
|
|
"SPICE-Anzeige wird mit eingeschaltet: die USB-Weiterleitung laeuft "
|
|
"ueber den SPICE-Kanal, ohne den waeren die Anschluesse wirkungslos.")
|
|
|
|
original_vga = str(config.get("vga") or "")
|
|
if result.spice and result.resume and not original_vga.startswith("qxl"):
|
|
raise RecoveryError(
|
|
"SPICE und geladener Arbeitsspeicher gehen bei dieser Maschine "
|
|
"nicht zusammen.\n"
|
|
"SPICE verlangt 'vga: qxl' (64 MB Grafikspeicher), im Snapshot "
|
|
"steckt die Vorgabe mit 16 MB. Der gespeicherte Zustand passt dann "
|
|
"nicht mehr und die Maschine bleibt beim Laden haengen "
|
|
"('Size mismatch: vga.vram').\n"
|
|
"\n"
|
|
"Moeglichkeiten:\n"
|
|
" --no-resume kalt starten, dafuer mit SPICE und USB\n"
|
|
" ohne --spice warmer RAM-Zustand, dafuer nur noVNC\n"
|
|
"\n"
|
|
"Dauerhaft loesen laesst sich das nur am Original: steht dort "
|
|
"'vga: qxl', tragen alle kuenftigen Snapshots das mit, und beides "
|
|
"geht gleichzeitig.")
|
|
|
|
if result.spice:
|
|
result.notes.append(
|
|
"Zugang auf zwei Wegen: noVNC im Browser und SPICE ueber "
|
|
"'pvesnap-recovery spice %d' (remote-viewer)." % result.newid)
|
|
if result.usb:
|
|
result.notes.append(
|
|
"Im remote-viewer unter 'USB-Geraeteauswahl' das Geraet anhaken - "
|
|
"es kommt vom eigenen Rechner, nicht ueber das Netz des Gastes. "
|
|
"Guest-Tools braucht es dafuer nicht.")
|
|
if result.iso:
|
|
result.notes.append("Eingelegt wird %s - im Gast als CD-Laufwerk."
|
|
% result.iso)
|
|
|
|
# -- Hinweise ----------------------------------------------------------
|
|
if result.dropped and not spec.keep_binds:
|
|
result.notes.append("Nicht uebernommen werden Einbindungen von Host-"
|
|
"Verzeichnissen: %s" % ", ".join(result.dropped))
|
|
if spec.memory and result.resume:
|
|
raise RecoveryError("Ein geladener Arbeitsspeicher laesst sich nicht "
|
|
"vergroessern oder verkleinern - entweder --memory "
|
|
"weglassen oder --no-resume verwenden.")
|
|
if spec.mode == "recovery":
|
|
kinds = {_storage_info(proxmox, volid.split(":", 1)[0]).get("type")
|
|
for _key, volid, _size in result.disks}
|
|
if "rbd" in kinds:
|
|
result.notes.append(
|
|
"Die Datentraeger sind Linked Clones - auf Ceph haelt ein Klon "
|
|
"seinen Quell-Snapshot fest, der dadurch unloeschbar wird. Soll "
|
|
"die Maschine dauerhaft laufen, spaeter mit "
|
|
"'pvesnap-recovery flatten %d' loesen." % result.newid)
|
|
elif kinds <= {"lvmthin"}:
|
|
result.notes.append(
|
|
"Die Datentraeger sind Thin-Snapshots und damit von sich aus "
|
|
"unabhaengig - Quell-Snapshot und Original lassen sich jederzeit "
|
|
"loeschen. Ein Abkoppeln ist nicht noetig.")
|
|
if result.resume:
|
|
result.notes.append("Der Arbeitsspeicher (%s) wird kopiert; die Maschine "
|
|
"laeuft danach genau dort weiter, wo sie beim Snapshot "
|
|
"stand." % (human_bytes(result.vmstate_bytes) or "?"))
|
|
result.notes.append("Proxmox gibt den kopierten Arbeitsspeicher nach dem "
|
|
"ersten Start wieder frei - ein spaeterer Neustart "
|
|
"bootet dann kalt.")
|
|
return result
|
|
|
|
|
|
def _storage_info(proxmox, name):
|
|
data = proxmox._json(["get", "/storage/%s" % name])
|
|
if not isinstance(data, dict):
|
|
raise RecoveryError("Storage %r nicht gefunden" % name)
|
|
return data
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Einrichten
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def create(proxmox, plan_, progress=None):
|
|
"""Den Plan umsetzen. Bei einem Fehler wird alles wieder abgeraeumt."""
|
|
def step(text):
|
|
log.info("%s", text)
|
|
if progress:
|
|
progress(text)
|
|
|
|
guest = plan_.guest
|
|
spec = plan_.spec
|
|
config = proxmox.guest_config(guest, snapshot=plan_.snapshot)
|
|
|
|
created = [] # Volumes, die wir angelegt haben
|
|
protected = [] # [volid, snapname] deren Schutz wir loesen muessen
|
|
made_dirs = []
|
|
conf_file = ""
|
|
|
|
try:
|
|
new_config = {}
|
|
for key, value in config.items():
|
|
if key in DROP_KEYS or NET_KEY.match(key):
|
|
continue
|
|
if QEMU_DISK_KEY.match(key) or LXC_DISK_KEY.match(key) \
|
|
or key in QEMU_EXTRA_DISKS:
|
|
continue
|
|
new_config[key] = value
|
|
|
|
# -- Datentraeger klonen ------------------------------------------
|
|
for index, (key, volid, _size) in enumerate(plan_.disks, 1):
|
|
step("Klone %s (%d/%d): %s" % (key, index, len(plan_.disks), volid))
|
|
clone = clone_volume(volid, plan_.newid, plan_.snapshot)
|
|
created.append(clone)
|
|
protected.append([volid, plan_.snapshot])
|
|
new_config[key] = _replace_volid(config[key], clone)
|
|
|
|
# CD-Laufwerke und nicht klonbare Einbindungen
|
|
for key in _disk_keys(config, guest.type):
|
|
if key in new_config:
|
|
continue
|
|
value = str(config[key])
|
|
if _is_cdrom(value):
|
|
new_config[key] = value
|
|
elif guest.type == "lxc" and not _volid_of(value):
|
|
if spec.keep_binds:
|
|
new_config[key] = value
|
|
|
|
# -- Arbeitsspeicher ----------------------------------------------
|
|
if plan_.resume and plan_.vmstate:
|
|
step("Kopiere Arbeitsspeicher (%s) - das dauert einen Moment"
|
|
% (human_bytes(plan_.vmstate_bytes) or "?"))
|
|
copy = _copy_state(plan_.vmstate, plan_.newid)
|
|
created.append(copy)
|
|
new_config["vmstate"] = copy
|
|
for key in ("runningmachine", "runningcpu"):
|
|
if config.get(key):
|
|
new_config[key] = config[key]
|
|
|
|
# -- Netzwerk ------------------------------------------------------
|
|
for key, value in plan_.nets:
|
|
if plan_.net_mode == "none":
|
|
continue
|
|
if plan_.net_mode == "down":
|
|
value = _set_option(value, "link_down", "1")
|
|
new_config[key] = value
|
|
|
|
# -- Austauschlaufwerk ---------------------------------------------
|
|
# -- Transfer-Laufwerke --------------------------------------------
|
|
attached = []
|
|
for volume in plan_.transfers:
|
|
if guest.type == "lxc":
|
|
# Beim Container wird der Mountpunkt des Hosts durchgereicht.
|
|
# Ein Bind-Mount ist dasselbe Dateisystem an zwei Stellen, kein
|
|
# zweites Einhaengen - Host und Gast koennen beide arbeiten.
|
|
step("Reiche Transfer-Laufwerk %r durch" % volume.name)
|
|
path = transfer.host_mount(volume.name)
|
|
slot = _free_mp_slot(new_config)
|
|
value = "%s,mp=/mnt/%s" % (path, volume.name)
|
|
new_config[slot] = value
|
|
attached.append({"name": volume.name, "key": slot, "drive": value,
|
|
"kind": "bind", "pending": False})
|
|
else:
|
|
step("Haenge Transfer-Laufwerk %r an" % volume.name)
|
|
slot = _free_disk_slot(new_config)
|
|
value = transfer.drive_string(volume.name)
|
|
# Mit geladenem Arbeitsspeicher wacht der Gast in einem Zustand
|
|
# auf, in dem es die Platte nicht gab - dann muss sie erst nach
|
|
# dem Fortsetzen dazukommen, sonst bemerkt er sie nie.
|
|
if not plan_.resume:
|
|
new_config[slot] = value
|
|
attached.append({"name": volume.name, "key": slot, "drive": value,
|
|
"kind": "disk", "pending": bool(plan_.resume)})
|
|
|
|
# -- SPICE, USB-Weiterleitung, CD ----------------------------------
|
|
if plan_.spice:
|
|
step("Schalte SPICE-Anzeige ein (vga: qxl)")
|
|
new_config["vga"] = "qxl"
|
|
for _ in range(plan_.usb):
|
|
slot = _free_usb_slot(new_config)
|
|
new_config[slot] = "spice"
|
|
if plan_.usb:
|
|
step("%d USB-Weiterleitung(en) fuer SPICE eingerichtet" % plan_.usb)
|
|
if plan_.iso:
|
|
slot = _free_cdrom_slot(new_config)
|
|
new_config[slot] = "%s,media=cdrom" % plan_.iso
|
|
step("Lege %s als CD ein (%s)" % (plan_.iso, slot))
|
|
|
|
# -- restliche Anpassungen -----------------------------------------
|
|
if spec.memory:
|
|
new_config["memory"] = spec.memory
|
|
if spec.cores:
|
|
new_config["cores"] = spec.cores
|
|
new_config["tags"] = TAG
|
|
if guest.type == "qemu":
|
|
new_config["name"] = spec.name or _default_name(guest, plan_)
|
|
|
|
description = _description(guest, plan_)
|
|
|
|
# -- Konfiguration schreiben ---------------------------------------
|
|
conf_file = config_path(plan_.node, guest.type, plan_.newid)
|
|
step("Lege %s an" % conf_file)
|
|
_write_config(conf_file, new_config, description)
|
|
|
|
instance = Instance(
|
|
vmid=plan_.newid, type=guest.type, node=plan_.node,
|
|
name=str(new_config.get("name") or new_config.get("hostname") or ""),
|
|
source=guest.vmid, source_node=guest.node, snapshot=plan_.snapshot,
|
|
mode=spec.mode, created=int(time.time()), volumes=created,
|
|
protected=protected, resumed=plan_.resume,
|
|
transfers=attached)
|
|
_registry_add(instance)
|
|
return instance
|
|
|
|
except Exception as exc:
|
|
step("Fehler - raeume wieder ab: %s" % exc)
|
|
if conf_file and os.path.exists(conf_file):
|
|
try:
|
|
os.unlink(conf_file)
|
|
except OSError:
|
|
pass
|
|
for volid in reversed(created):
|
|
try:
|
|
free_volume(volid)
|
|
except RecoveryError as cleanup_exc:
|
|
log.warning("Klon %s blieb liegen: %s", volid, cleanup_exc)
|
|
for volid, snapname in protected:
|
|
try:
|
|
unprotect_snapshot(proxmox, volid, snapname)
|
|
except RecoveryError:
|
|
pass
|
|
for path in made_dirs:
|
|
shutil.rmtree(path, ignore_errors=True)
|
|
for volume in plan_.transfers:
|
|
try:
|
|
transfer.host_umount(volume.name)
|
|
transfer.release(volume.name)
|
|
except transfer.TransferError:
|
|
pass
|
|
raise
|
|
|
|
|
|
# Woran zu erkennen ist, dass der Arbeitsspeicher nicht angekommen ist.
|
|
# Proxmox meldet den Startvorgang trotzdem mit "TASK OK" - die Maschine bleibt
|
|
# dann aber angehalten stehen, ohne dass irgendwo ein Fehler sichtbar waere.
|
|
_STATE_FAILED = ("Error while loading VM state", "Unknown savevm section",
|
|
"Make sure that your current VM setup matches")
|
|
|
|
|
|
def start(proxmox, instance, progress=None):
|
|
"""Hochfahren - und nachsehen, ob dabei herauskam, was gewollt war.
|
|
|
|
Liefert einen Hinweistext, wenn etwas anders lief als geplant, sonst "".
|
|
"""
|
|
def step(text):
|
|
log.info("%s", text)
|
|
if progress:
|
|
progress(text)
|
|
|
|
upid = proxmox.start_guest(instance.guest)
|
|
if instance.type != "qemu":
|
|
return ""
|
|
|
|
note = ""
|
|
if instance.resumed and upid:
|
|
broken = [line.strip() for line in proxmox.task_log(instance.node, upid)
|
|
if any(marker in line for marker in _STATE_FAILED)]
|
|
if broken:
|
|
note = ("Der Arbeitsspeicher liess sich nicht laden - die Maschine "
|
|
"startet stattdessen kalt vom Datentraeger. Proxmox meldet: %s"
|
|
% broken[0])
|
|
|
|
# Nach dem Laden eines RAM-Standes steht die Maschine angehalten da und
|
|
# muss noch fortgesetzt werden.
|
|
for _ in range(15):
|
|
qmp = (proxmox.guest_status(instance.guest).get("qmpstatus") or "")
|
|
if qmp == "running":
|
|
return _attach_pending(proxmox, instance, step) or note
|
|
if qmp in ("paused", "prelaunch"):
|
|
step("Setze %s fort" % instance.label)
|
|
try:
|
|
proxmox.resume_guest(instance.guest)
|
|
except ProxmoxError as exc:
|
|
return "%s laesst sich nicht fortsetzen: %s" % (instance.label, exc)
|
|
time.sleep(1.0)
|
|
if (proxmox.guest_status(instance.guest).get("qmpstatus") or "") == "running":
|
|
return _attach_pending(proxmox, instance, step) or note
|
|
time.sleep(1.0)
|
|
return note or ("%s ist gestartet, laeuft aber nicht. Warum, steht im "
|
|
"Task-Protokoll von Proxmox." % instance.label)
|
|
|
|
|
|
def _attach_pending(proxmox, instance, step=None):
|
|
"""Was erst nach dem Fortsetzen dazukommen darf, jetzt anstecken."""
|
|
notes = []
|
|
for entry in _transfer_entries(instance):
|
|
if not entry.get("pending"):
|
|
continue
|
|
if step:
|
|
step("Stecke Transfer-Laufwerk %r als %s an"
|
|
% (entry["name"], entry["key"]))
|
|
try:
|
|
proxmox.set_guest_config(instance.guest, {entry["key"]: entry["drive"]})
|
|
config = proxmox.guest_config(instance.guest)
|
|
except ProxmoxError as exc:
|
|
notes.append("Transfer-Laufwerk %r liess sich nicht anstecken: %s"
|
|
% (entry["name"], exc))
|
|
continue
|
|
if entry["key"] not in config:
|
|
notes.append("Transfer-Laufwerk %r ist eingetragen, aber nicht "
|
|
"angesteckt - es erscheint erst nach einem Neustart."
|
|
% entry["name"])
|
|
else:
|
|
entry["pending"] = False
|
|
_registry_add(instance)
|
|
|
|
return " ".join(notes)
|
|
|
|
|
|
def _transfer_entries(instance):
|
|
"""Die Transfer-Laufwerke einer Instanz als Datensaetze.
|
|
|
|
Aeltere Merklisten enthalten nur Namen - die werden hier mitgelesen.
|
|
"""
|
|
entries = []
|
|
for entry in instance.transfers or []:
|
|
if isinstance(entry, dict):
|
|
entries.append(entry)
|
|
else:
|
|
entries.append({"name": str(entry), "key": "", "drive": "",
|
|
"kind": "disk", "pending": False})
|
|
return entries
|
|
|
|
|
|
def transfer_state(proxmox, instance):
|
|
"""[(Datensatz, eingesteckt?)] - was gerade in der Maschine steckt."""
|
|
try:
|
|
config = proxmox.guest_config(instance.guest)
|
|
except ProxmoxError:
|
|
config = {}
|
|
return [(entry, bool(entry.get("key") and entry["key"] in config))
|
|
for entry in _transfer_entries(instance)]
|
|
|
|
|
|
def eject_transfer(proxmox, instance, name, progress=None):
|
|
"""Ein Transfer-Laufwerk bei laufender Maschine abziehen.
|
|
|
|
Danach gehoert es wieder dem Host: einhaengen, befuellen, auslesen - und
|
|
mit insert_transfer() wieder hineingeben, ohne die Maschine anzufassen.
|
|
|
|
Wichtig: im Gast vorher aushaengen. Proxmox meldet das Geraet ab, aber ein
|
|
Dateisystem, das noch beschrieben wird, nimmt das uebel.
|
|
"""
|
|
def step(text):
|
|
log.info("%s", text)
|
|
if progress:
|
|
progress(text)
|
|
|
|
entry = next((e for e in _transfer_entries(instance) if e["name"] == name), None)
|
|
if entry is None:
|
|
raise RecoveryError("%r haengt nicht an %s." % (name, instance.label))
|
|
if entry.get("kind") == "bind":
|
|
raise RecoveryError(
|
|
"Bei Containern gibt es nichts auszuwerfen: das Verzeichnis ist "
|
|
"durchgereicht, Host und Gast sehen dieselben Dateien sofort.")
|
|
if not entry.get("key"):
|
|
raise RecoveryError("Zu %r ist kein Steckplatz vermerkt." % name)
|
|
|
|
step("Werfe %r aus (%s)" % (name, entry["key"]))
|
|
try:
|
|
proxmox.set_guest_config(instance.guest, {"delete": entry["key"]})
|
|
except ProxmoxError as exc:
|
|
raise RecoveryError(
|
|
"%r liess sich nicht abziehen: %s\n"
|
|
"Meist haelt der Gast es noch - dort erst aushaengen "
|
|
"(Linux: umount, Windows: Auswerfen im Explorer)." % (name, exc))
|
|
|
|
entry["pending"] = True
|
|
_registry_add(instance)
|
|
transfer.release(name)
|
|
step("%r gehoert jetzt wieder dem Host" % name)
|
|
return True
|
|
|
|
|
|
def insert_transfer(proxmox, instance, name, progress=None):
|
|
"""Ein zuvor ausgeworfenes Transfer-Laufwerk wieder einklinken."""
|
|
def step(text):
|
|
log.info("%s", text)
|
|
if progress:
|
|
progress(text)
|
|
|
|
entry = next((e for e in _transfer_entries(instance) if e["name"] == name), None)
|
|
if entry is None:
|
|
raise RecoveryError("%r gehoert nicht zu %s." % (name, instance.label))
|
|
if entry.get("kind") == "bind":
|
|
raise RecoveryError("Bei Containern ist das Verzeichnis dauerhaft "
|
|
"durchgereicht - nichts einzuklinken.")
|
|
|
|
volume = transfer.get(name)
|
|
if volume.mountpoint:
|
|
step("%r ist noch auf dem Host eingehaengt - haenge aus" % name)
|
|
transfer.host_umount(name)
|
|
|
|
drive = transfer.drive_string(name)
|
|
entry["drive"] = drive
|
|
step("Klinke %r wieder ein (%s)" % (name, entry["key"]))
|
|
try:
|
|
proxmox.set_guest_config(instance.guest, {entry["key"]: drive})
|
|
except ProxmoxError as exc:
|
|
raise RecoveryError("%r liess sich nicht einklinken: %s" % (name, exc))
|
|
entry["pending"] = False
|
|
_registry_add(instance)
|
|
return True
|
|
|
|
|
|
# Die Reihenfolge ist die einer .vv-Datei fuer remote-viewer; "ca" muss ganz
|
|
# zum Schluss, weil das Zertifikat mehrzeilig ist.
|
|
_VV_KEYS = ("type", "host", "port", "tls-port", "password", "proxy",
|
|
"host-subject", "title", "toggle-fullscreen", "release-cursor",
|
|
"secure-attention", "delete-this-file", "ca")
|
|
|
|
|
|
def spice_file(proxmox, instance):
|
|
"""Verbindungsbeschreibung fuer remote-viewer (.vv) als Text.
|
|
|
|
Proxmox liefert die Angaben als JSON; daraus wird hier die Datei gebaut,
|
|
die virt-viewer erwartet. Sie enthaelt ein Einmal-Kennwort und laeuft nach
|
|
kurzer Zeit ab - also frisch erzeugen, wenn sie nicht mehr zieht.
|
|
"""
|
|
data = proxmox._json(["create", "/nodes/%s/%s/%d/spiceproxy"
|
|
% (instance.node, instance.type, instance.vmid)])
|
|
if not isinstance(data, dict) or not data.get("host"):
|
|
raise RecoveryError(
|
|
"%s liefert keine SPICE-Verbindung. Laeuft die Maschine, und ist "
|
|
"die SPICE-Anzeige eingeschaltet (vga: qxl)?" % instance.label)
|
|
|
|
# Derselbe Grund wie beim noVNC-Verweis: der Proxy-Eintrag traegt den
|
|
# Hostnamen des Nodes, und wenn der Arbeitsplatz ihn nicht aufloest,
|
|
# kommt keine Verbindung zustande. Die TLS-Pruefung laeuft ueber
|
|
# "host-subject", nicht ueber diesen Namen - die bleibt also gueltig.
|
|
address = node_address(instance.node)
|
|
proxy = str(data.get("proxy") or "")
|
|
if address != instance.node and proxy:
|
|
for name in (instance.node, "%s." % instance.node):
|
|
if name in proxy:
|
|
data["proxy"] = re.sub(r"//[^:/]+", "//" + address, proxy, count=1)
|
|
break
|
|
|
|
lines = ["[virt-viewer]"]
|
|
for key in _VV_KEYS:
|
|
if key in data and data[key] not in (None, ""):
|
|
# Bewusst ohne Umwandlung: das Zertifikat enthaelt die Zeichenfolge
|
|
# \n als zwei Zeichen, und genau so erwartet virt-viewer sie. Wuerde
|
|
# man daraus echte Zeilenumbrueche machen, stuende nur die erste
|
|
# Zeile hinter "ca=" und der Rest waere kaputt.
|
|
lines.append("%s=%s" % (key, data[key]))
|
|
for key in sorted(data):
|
|
if key not in _VV_KEYS and data[key] not in (None, ""):
|
|
lines.append("%s=%s" % (key, data[key]))
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
USB_KEY = re.compile(r"^usb\d+$")
|
|
|
|
|
|
def display_state(proxmox, instance):
|
|
"""(SPICE an?, Anzahl USB-Weiterleitungen) aus der Konfiguration."""
|
|
try:
|
|
config = proxmox.guest_config(instance.guest)
|
|
except ProxmoxError:
|
|
return False, 0
|
|
spice = str(config.get("vga") or "").startswith("qxl")
|
|
usb = sum(1 for key, value in config.items()
|
|
if USB_KEY.match(key) and str(value).strip().lower() == "spice")
|
|
return spice, usb
|
|
|
|
|
|
def set_display(proxmox, instance, spice, usb, progress=None):
|
|
"""SPICE-Anzeige und USB-Weiterleitung nachtraeglich einstellen.
|
|
|
|
Liefert (geaendert, neustart_noetig). Die Anzeige selbst laesst sich nur
|
|
beim Start festlegen - qxl bringt eine andere Grafikkarte mit, die kann
|
|
man einer laufenden Maschine nicht unterschieben. USB-Anschluesse dagegen
|
|
sind steckbar, solange der SPICE-Kanal schon steht.
|
|
"""
|
|
def step(text):
|
|
log.info("%s", text)
|
|
if progress:
|
|
progress(text)
|
|
|
|
if instance.type != "qemu":
|
|
raise RecoveryError("SPICE und USB-Weiterleitung gibt es nur bei "
|
|
"virtuellen Maschinen, nicht bei Containern.")
|
|
usb = max(0, min(14, int(usb or 0)))
|
|
if usb and not spice:
|
|
spice = True
|
|
step("SPICE wird mit eingeschaltet - ohne den Kanal waeren die "
|
|
"Anschluesse wirkungslos")
|
|
|
|
config = proxmox.guest_config(instance.guest)
|
|
hat_spice = str(config.get("vga") or "").startswith("qxl")
|
|
vorhanden = sorted(key for key, value in config.items()
|
|
if USB_KEY.match(key) and str(value).strip().lower() == "spice")
|
|
|
|
aenderungen = {}
|
|
entfernen = []
|
|
if spice and not hat_spice:
|
|
aenderungen["vga"] = "qxl"
|
|
elif not spice and hat_spice:
|
|
# Eintrag entfernen statt auf "std" setzen: dann steht die Maschine
|
|
# wieder genau so da wie vorher, ohne eine Zeile, die es nie gab.
|
|
entfernen.append("vga")
|
|
entfernen.extend(vorhanden) # ohne Kanal sind sie sinnlos
|
|
vorhanden = []
|
|
|
|
if spice:
|
|
for _ in range(usb - len(vorhanden)):
|
|
slot = _free_usb_slot(dict(config, **aenderungen))
|
|
aenderungen[slot] = "spice"
|
|
config = dict(config, **{slot: "spice"})
|
|
entfernen.extend(vorhanden[usb:])
|
|
|
|
if not aenderungen and not entfernen:
|
|
return False, False
|
|
|
|
if entfernen:
|
|
step("Entferne %s" % ", ".join(entfernen))
|
|
proxmox.set_guest_config(instance.guest, {"delete": ",".join(entfernen)})
|
|
if aenderungen:
|
|
step("Setze %s" % ", ".join("%s=%s" % kv for kv in sorted(aenderungen.items())))
|
|
proxmox.set_guest_config(instance.guest, aenderungen)
|
|
|
|
# Die Grafikkarte wechselt erst beim naechsten Start. USB-Anschluesse
|
|
# stecken sofort, aber nur wenn der SPICE-Kanal schon lief.
|
|
laeuft = proxmox.guest_status(instance.guest).get("status") == "running"
|
|
neustart = laeuft and ("vga" in aenderungen or "vga" in entfernen
|
|
or (spice and not hat_spice))
|
|
return True, neustart
|
|
|
|
|
|
def _default_name(guest, plan_):
|
|
base = re.sub(r"[^A-Za-z0-9-]", "-", guest.name or "vm%d" % guest.vmid)[:40]
|
|
return ("%s-wdh" % base).strip("-")
|
|
|
|
|
|
def _description(guest, plan_):
|
|
return "\n".join([
|
|
"%s - von pvesnap angelegt, nicht die Originalmaschine." % TAG,
|
|
"",
|
|
"Quelle: %s auf %s" % (guest.label, guest.node),
|
|
"Snapshot: %s" % plan_.snapshot,
|
|
"Modus: %s" % ("recovery (mit Netzwerk)" if plan_.net_mode == "on"
|
|
else "live (ohne Netzwerk)"),
|
|
"Angelegt: %s" % time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
"",
|
|
"Verwerfen: pvesnap-recovery destroy %d" % plan_.newid,
|
|
])
|
|
|
|
|
|
def _free_disk_slot(config):
|
|
for index in range(1, 31):
|
|
for prefix in ("scsi", "virtio", "sata"):
|
|
key = "%s%d" % (prefix, index)
|
|
if key not in config and any(k.startswith(prefix) for k in config):
|
|
return key
|
|
for index in range(1, 31):
|
|
if "scsi%d" % index not in config:
|
|
return "scsi%d" % index
|
|
raise RecoveryError("Kein freier Platz fuer ein Austauschlaufwerk.")
|
|
|
|
|
|
def _free_usb_slot(config):
|
|
for index in range(0, 14):
|
|
key = "usb%d" % index
|
|
if key not in config:
|
|
return key
|
|
raise RecoveryError("Alle 14 USB-Anschluesse sind belegt.")
|
|
|
|
|
|
def _free_cdrom_slot(config):
|
|
"""Ein CD-Laufwerk - moeglichst das leere, das schon da ist."""
|
|
for key in sorted(config):
|
|
if QEMU_DISK_KEY.match(key) and "media=cdrom" in str(config[key]) \
|
|
and str(config[key]).split(",")[0] in ("none", "cdrom"):
|
|
return key
|
|
for index in range(0, 4):
|
|
key = "ide%d" % index
|
|
if key not in config:
|
|
return key
|
|
raise RecoveryError("Kein Platz fuer ein CD-Laufwerk frei.")
|
|
|
|
|
|
def _free_mp_slot(config):
|
|
for index in range(0, 256):
|
|
if "mp%d" % index not in config:
|
|
return "mp%d" % index
|
|
raise RecoveryError("Kein freier Einhaengepunkt mehr frei.")
|
|
|
|
|
|
def _prepare_bind_dir(path, unprivileged):
|
|
"""Damit der Container hineinschreiben darf."""
|
|
try:
|
|
if unprivileged:
|
|
os.chown(path, 100000, 100000) # Standard-Verschiebung von PVE
|
|
os.chmod(path, 0o777)
|
|
except OSError as exc:
|
|
log.warning("Rechte auf %s nicht setzbar: %s", path, exc)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Arbeitsspeicher kopieren
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _copy_state(volid, newid):
|
|
"""Den RAM-Stand des Snapshots duplizieren.
|
|
|
|
Kopiert wird, nicht geklont: Proxmox gibt den Arbeitsspeicher nach dem
|
|
Laden selbst wieder frei - das darf auf keinen Fall den Snapshot des
|
|
Originals treffen.
|
|
"""
|
|
if not have("qemu-img"):
|
|
raise RecoveryError("'qemu-img' fehlt (apt install qemu-utils) - ohne das "
|
|
"laesst sich der Arbeitsspeicher nicht uebernehmen.")
|
|
storage = volid.split(":", 1)[0]
|
|
size = volume_size(volid)
|
|
if size <= 0:
|
|
raise RecoveryError("Groesse von %s nicht ermittelbar" % volid)
|
|
|
|
target = alloc_volume(storage, newid, max(1, (size + 1023) // 1024))
|
|
try:
|
|
storage_op("activate", volid, target, timeout=300)
|
|
source_path = volume_path(volid)
|
|
target_path = volume_path(target)
|
|
run(["qemu-img", "convert", "-O", "raw", "-n", source_path, target_path],
|
|
timeout=7200)
|
|
except Exception:
|
|
try:
|
|
free_volume(target)
|
|
except RecoveryError:
|
|
pass
|
|
raise
|
|
return target
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Austauschlaufwerk formatieren
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Uebersicht und Verwerfen
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def list_instances(proxmox, refresh=True):
|
|
"""Bekannte Wiederherstellungen - Merkliste und Cluster zusammengefuehrt.
|
|
|
|
Der Cluster wird mitgelesen, damit auch etwas auftaucht, das nach einem
|
|
verlorenen Zustandsspeicher nur noch an seiner Markierung erkennbar ist.
|
|
"""
|
|
known = {i.vmid: i for i in _registry_load()}
|
|
try:
|
|
guests = proxmox.inventory(refresh=refresh)
|
|
except ProxmoxError as exc:
|
|
log.warning("Cluster nicht abfragbar: %s", exc)
|
|
guests = []
|
|
|
|
alive = set()
|
|
for guest in guests:
|
|
if TAG not in guest.tags:
|
|
continue
|
|
alive.add(guest.vmid)
|
|
instance = known.get(guest.vmid)
|
|
if instance is None:
|
|
instance = Instance(vmid=guest.vmid, type=guest.type, node=guest.node,
|
|
name=guest.name, mode="?", snapshot="?")
|
|
known[guest.vmid] = instance
|
|
instance.node = guest.node or instance.node
|
|
instance.name = guest.name or instance.name
|
|
instance.type = guest.type
|
|
instance.status = guest.status
|
|
|
|
for vmid, instance in known.items():
|
|
if vmid not in alive and guests:
|
|
instance.status = "weg"
|
|
|
|
return sorted(known.values(), key=lambda i: i.vmid)
|
|
|
|
|
|
def forget(vmid):
|
|
_registry_remove(vmid)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Liegengebliebenes finden
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def used_vmids():
|
|
"""Alle vergebenen VMIDs - aus den Konfigurationsdateien.
|
|
|
|
Bewusst nicht ueber /cluster/resources: ein Gast auf einem abgemeldeten
|
|
Node taucht dort unter Umstaenden nicht auf, und dann wuerden wir die
|
|
Platten einer lebenden Maschine fuer verwaist halten. /etc/pve ist
|
|
cluster-weit und kennt auch abgeschaltete Gaeste.
|
|
"""
|
|
used = set()
|
|
try:
|
|
nodes = os.listdir(CONF_ROOT)
|
|
except OSError as exc:
|
|
raise RecoveryError("%s nicht lesbar: %s" % (CONF_ROOT, exc))
|
|
for node in nodes:
|
|
for sub in ("qemu-server", "lxc"):
|
|
directory = os.path.join(CONF_ROOT, node, sub)
|
|
try:
|
|
names = os.listdir(directory)
|
|
except OSError:
|
|
continue
|
|
for name in names:
|
|
if name.endswith(".conf") and name[:-5].isdigit():
|
|
used.add(int(name[:-5]))
|
|
return used
|
|
|
|
|
|
_IMAGE_NAME = re.compile(r"^vm-(\d+)-(?:disk|state|cloudinit)")
|
|
|
|
|
|
def find_orphans(proxmox):
|
|
"""Datentraeger, zu denen es keinen Gast mehr gibt.
|
|
|
|
Entsteht, wenn eine Wiederherstellung ausserhalb von pvesnap entfernt wird -
|
|
etwa in der Proxmox-Oberflaeche. Der Gast ist dann weg, sein Klon bleibt
|
|
liegen, und der Quell-Snapshot bleibt geschuetzt und damit unloeschbar.
|
|
"""
|
|
used = used_vmids()
|
|
if not used:
|
|
raise RecoveryError(
|
|
"Unter %s steht keine einzige Gast-Konfiguration - das kann nicht "
|
|
"stimmen. Zur Sicherheit wird nichts angefasst." % CONF_ROOT)
|
|
|
|
found = []
|
|
for storage in proxmox._json(["get", "/storage"]) or []:
|
|
if storage.get("type") != "rbd":
|
|
continue
|
|
name = storage.get("storage")
|
|
context = _rbd_context(proxmox, "%s:dummy" % name)
|
|
if context is None:
|
|
continue
|
|
args, _ = context
|
|
pool = storage.get("pool") or "rbd"
|
|
namespace = storage.get("namespace")
|
|
prefix = "%s/%s" % (pool, (namespace + "/") if namespace else "")
|
|
|
|
for image in run(args + ["ls", pool.rstrip("/")], check=False,
|
|
timeout=120).splitlines():
|
|
image = image.strip()
|
|
match = _IMAGE_NAME.match(image)
|
|
if not match or int(match.group(1)) in used:
|
|
continue
|
|
try:
|
|
info = json.loads(run(args + ["info", prefix + image,
|
|
"--format", "json"], timeout=60))
|
|
except (RecoveryError, ValueError):
|
|
continue
|
|
parent = info.get("parent") or {}
|
|
found.append({
|
|
"volid": "%s:%s" % (name, image),
|
|
"vmid": int(match.group(1)),
|
|
"bytes": int(info.get("size") or 0),
|
|
"parent": ("%s/%s@%s" % (parent.get("pool"), parent.get("image"),
|
|
parent.get("snapshot")))
|
|
if parent else "",
|
|
"args": args,
|
|
"image": prefix + image,
|
|
})
|
|
return found
|
|
|
|
|
|
def remove_orphans(proxmox, orphans, progress=None):
|
|
"""Gefundene Reste entfernen und den Schutz ihrer Quell-Snapshots loesen."""
|
|
def step(text):
|
|
log.info("%s", text)
|
|
if progress:
|
|
progress(text)
|
|
|
|
removed, failed = [], []
|
|
for entry in orphans:
|
|
try:
|
|
step("Entferne %s" % entry["volid"])
|
|
free_volume(entry["volid"])
|
|
removed.append(entry["volid"])
|
|
except RecoveryError as exc:
|
|
failed.append("%s: %s" % (entry["volid"], exc))
|
|
continue
|
|
|
|
parent = entry.get("parent")
|
|
if not parent:
|
|
continue
|
|
args, image_snap = entry["args"], parent
|
|
base, _, snapname = image_snap.partition("@")
|
|
# Nur loesen, wenn wirklich kein Klon mehr daran haengt.
|
|
children = run(args + ["children", image_snap], check=False, timeout=120)
|
|
if children.strip():
|
|
step("%s hat noch andere Klone - Schutz bleibt" % parent)
|
|
continue
|
|
step("Hebe Schutz von %s auf" % parent)
|
|
run(args + ["snap", "unprotect", base, "--snap", snapname],
|
|
check=False, timeout=120)
|
|
return removed, failed
|
|
|
|
|
|
def destroy(proxmox, instance, progress=None, keep_snapshot_protection=False):
|
|
"""Eine Wiederherstellung restlos entfernen."""
|
|
def step(text):
|
|
log.info("%s", text)
|
|
if progress:
|
|
progress(text)
|
|
|
|
guest = instance.guest
|
|
exists = proxmox.guest_exists(instance.vmid)
|
|
|
|
if exists:
|
|
# Absicherung: nur anfassen, was unsere Markierung traegt.
|
|
config = proxmox.guest_config(guest)
|
|
tags = [t.strip().lower()
|
|
for t in str(config.get("tags") or "").replace(",", ";").split(";")]
|
|
if TAG not in tags:
|
|
raise RecoveryError(
|
|
"%s traegt die Markierung '%s' nicht - das ist keine von pvesnap "
|
|
"angelegte Wiederherstellung und wird nicht angefasst."
|
|
% (instance.label, TAG))
|
|
|
|
status = proxmox.guest_status(guest)
|
|
if status.get("status") == "running":
|
|
step("Schalte %s aus" % instance.label)
|
|
try:
|
|
proxmox.stop_guest(guest)
|
|
except ProxmoxError as exc:
|
|
raise RecoveryError("%s laesst sich nicht stoppen: %s"
|
|
% (instance.label, exc))
|
|
|
|
step("Entferne %s samt Klonen" % instance.label)
|
|
try:
|
|
proxmox.destroy_guest(guest)
|
|
except ProxmoxError as exc:
|
|
raise RecoveryError("%s laesst sich nicht entfernen: %s"
|
|
% (instance.label, exc))
|
|
|
|
# Proxmox raeumt alles ab, was in der Konfiguration steht. Ein noch
|
|
# nicht angestecktes Austauschlaufwerk steht dort aber nicht drin -
|
|
# deshalb hier noch einmal ueber alles gehen, was wir angelegt haben.
|
|
for volid in reversed(instance.volumes):
|
|
try:
|
|
free_volume(volid)
|
|
step("Entferne uebrig gebliebenen Klon %s" % volid)
|
|
except RecoveryError:
|
|
pass # war schon weg - der Normalfall
|
|
else:
|
|
# Der Gast ist weg, seine Klone koennen es trotzdem noch geben.
|
|
for volid in instance.volumes:
|
|
try:
|
|
step("Entferne uebrig gebliebenen Klon %s" % volid)
|
|
free_volume(volid)
|
|
except RecoveryError as exc:
|
|
log.warning("%s: %s", volid, exc)
|
|
|
|
if not keep_snapshot_protection:
|
|
for entry in instance.protected:
|
|
try:
|
|
volid, snapname = entry[0], entry[1]
|
|
except (IndexError, TypeError):
|
|
continue
|
|
step("Hebe Schutz von %s@%s auf" % (volid, snapname))
|
|
try:
|
|
unprotect_snapshot(proxmox, volid, snapname)
|
|
except RecoveryError as exc:
|
|
log.warning("Schutz von %s@%s blieb: %s", volid, snapname, exc)
|
|
|
|
for name in [e["name"] for e in _transfer_entries(instance)]:
|
|
step("Gebe Transfer-Laufwerk %r wieder frei" % name)
|
|
try:
|
|
transfer.host_umount(name)
|
|
transfer.release(name)
|
|
except transfer.TransferError as exc:
|
|
log.warning("%s: %s", name, exc)
|
|
|
|
_registry_remove(instance.vmid)
|
|
step("Fertig.")
|