Files
proxmox-snapshot-service/pvesnap/recovery.py
T
duffyduckandClaude Opus 5 91c23ce2c0 SPICE und USB lassen sich nachtraeglich einstellen
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>
2026-08-09 10:32:29 +02:00

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.")