Ein einziges Dateisystem, mit Absicht: exFAT koennen Windows und Linux beide
von Haus aus, es kennt keine Besitzrechte auf dem Datentraeger und hat anders
als FAT32 keine 4-GB-Grenze je Datei. Die Auswahl faellt damit weg - eine
Entscheidung weniger, wenn es schnell gehen muss.
Neu in der Oberflaeche:
Taste v Uebersicht der Laufwerke mit Zustand
Enter darauf haengt am Host ein und startet den Commander - links das
Laufwerk, rechts der eigene Rechner
n / u / l anlegen, aushaengen, loeschen
Optionsmaske Mehrfachauswahl statt Groessenangabe
--transfer NAME dasselbe auf der Kommandozeile, mehrfach moeglich
Am laufenden System durchgespielt: Laufwerk angelegt, vom Host ein Skript
hineingelegt, Wiederherstellung damit gestartet, im Gast gelesen, ausgefuehrt
und geschrieben, Wiederherstellung verworfen - das Laufwerk lebte samt
Ergebnis weiter.
Zwei Rechtefallen dabei gefunden und geschlossen:
Der Gast konnte lesen, aber nicht schreiben. exFAT hat keine Besitzrechte
auf der Platte, die entstehen beim Einhaengen - mit der Vorgabe gehoert
alles root, und der root eines unprivilegierten Containers ist auf dem Host
uid 100000. Wird jetzt mit umask=0000 eingehaengt.
Ein durchgereichtes Laufwerk galt als "am Host eingehaengt", weil beim
Container genau das der Fall ist. Wer das sah, haette es aushaengen und dem
laufenden Gast den Boden wegziehen koennen. Die Belegung wird jetzt auch aus
Container-Konfigurationen gelesen, der Gast hat Vorrang in der Anzeige, und
Aushaengen wird abgelehnt, solange jemand darauf arbeitet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1602 lines
61 KiB
Python
1602 lines
61 KiB
Python
"""Einen Snapshot als eigenstaendige Maschine starten.
|
|
|
|
Zwei Betriebsarten:
|
|
|
|
live Der Snapshot laeuft abgeschottet - ohne Netzwerk. Gedacht zum
|
|
Hineinschauen: ueber die noVNC-Konsole arbeiten, einen
|
|
Datenbank-Dump ziehen und ihn ueber ein Austauschlaufwerk
|
|
wieder herausholen.
|
|
|
|
recovery Der Snapshot laeuft mit Netzwerk und behaelt MAC-Adressen,
|
|
SMBIOS-UUID und bei Containern den Hostnamen. Fuer alles
|
|
drumherum ist er dieselbe Maschine - entsprechend gefaehrlich,
|
|
solange das Original noch laeuft.
|
|
|
|
Die Datentraeger werden nicht kopiert, sondern geklont. Auf Ceph, ZFS und
|
|
LVM-thin ist das ein Copy-on-Write-Klon: er dauert Sekunden, egal wie gross
|
|
die Platte ist, und das Original wird dabei nicht angefasst. Dafuer wird
|
|
`PVE::Storage::vdisk_clone` verwendet - dieselbe Funktion, die auch Proxmox
|
|
selbst benutzt, samt Cluster-Sperre auf dem Storage.
|
|
|
|
Enthaelt der Snapshot den Arbeitsspeicher (vmstate, also "mit RAM"), wird der
|
|
mitgenommen: die Maschine laeuft dann genau dort weiter, wo sie beim Snapshot
|
|
stand, statt wie nach einem Stromausfall zu booten. Fuer einen sauberen
|
|
Datenbank-Dump ist das der entscheidende Unterschied.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
|
|
from . import transfer
|
|
from .proxmox import Guest, ProxmoxError
|
|
|
|
log = logging.getLogger("pvesnap.recovery")
|
|
|
|
TAG = "pvesnap-recovery"
|
|
STATE_DIR = "/var/lib/pvesnap"
|
|
REGISTRY = os.path.join(STATE_DIR, "recovery.json")
|
|
EXCHANGE_ROOT = os.path.join(STATE_DIR, "exchange")
|
|
EXCHANGE_MOUNT = "/run/pvesnap/exchange"
|
|
CONF_ROOT = "/etc/pve/nodes"
|
|
|
|
QEMU_DISK_KEY = re.compile(r"^(ide|sata|scsi|virtio)\d+$")
|
|
QEMU_EXTRA_DISKS = ("efidisk0", "tpmstate0")
|
|
LXC_DISK_KEY = re.compile(r"^(rootfs|mp\d+)$")
|
|
NET_KEY = re.compile(r"^net\d+$")
|
|
|
|
# Nichts uebernehmen, was zum Snapshot oder zur Einbettung des Originals
|
|
# gehoert. vmstate/runningmachine/runningcpu werden spaeter gezielt gesetzt.
|
|
#
|
|
# vmgenid steht bewusst NICHT hier: die Kennung gehoert zur Identitaet der
|
|
# Maschine ("als waere es dieselbe") und steckt ausserdem als eigenes Geraet
|
|
# im gespeicherten Arbeitsspeicher. Fehlt sie, bricht das Laden des RAM-Standes
|
|
# mit "Unknown savevm section or instance 'vmgenid'" ab.
|
|
DROP_KEYS = {
|
|
"parent", "snaptime", "digest", "snapstate", "pending", "lock", "template",
|
|
"description", "tags", "onboot", "startup", "protection", "hookscript",
|
|
"replicate", "vmstate", "runningmachine", "runningcpu",
|
|
}
|
|
|
|
DEFAULT_EXCHANGE_SIZE = "10G"
|
|
DEFAULT_EXCHANGE_FS = "ext4"
|
|
EXCHANGE_LABEL = "PVESNAP"
|
|
EXCHANGE_GUEST_PATH = "/mnt/pvesnap"
|
|
|
|
MKFS = {
|
|
"ext4": ["mkfs.ext4", "-q", "-m", "0", "-L", EXCHANGE_LABEL],
|
|
"ext3": ["mkfs.ext3", "-q", "-m", "0", "-L", EXCHANGE_LABEL],
|
|
"xfs": ["mkfs.xfs", "-q", "-L", EXCHANGE_LABEL],
|
|
"vfat": ["mkfs.vfat", "-n", EXCHANGE_LABEL],
|
|
"exfat": ["mkfs.exfat", "-L", EXCHANGE_LABEL],
|
|
}
|
|
|
|
README = """Austauschlaufwerk von pvesnap-recovery
|
|
|
|
Dieses Laufwerk gehoert nicht zur urspruenglichen Maschine. Es ist leer
|
|
angelegt worden, damit Daten aus diesem Gast wieder herauskommen - der Gast
|
|
hat ja kein Netzwerk.
|
|
|
|
1. Hier hineinschreiben, z.B.
|
|
mysqldump --all-databases > /mnt/pvesnap/dump.sql
|
|
2. Gast herunterfahren
|
|
3. Auf dem Proxmox-Host: pvesnap-recovery pull <VMID>
|
|
|
|
Danach liegen die Dateien auf dem Host und lassen sich mit
|
|
pvesnap-explorer oder ganz normal per scp abholen.
|
|
"""
|
|
|
|
|
|
class RecoveryError(Exception):
|
|
"""Eine Wiederherstellung liess sich nicht einrichten."""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Kommandos
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def run(args, timeout=300, check=True, quiet=False):
|
|
if not quiet:
|
|
log.debug("exec: %s", " ".join(str(a) for a in args))
|
|
try:
|
|
proc = subprocess.run([str(a) for a in args], stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE, timeout=timeout)
|
|
except FileNotFoundError:
|
|
raise RecoveryError("Kommando nicht gefunden: %s" % args[0])
|
|
except subprocess.TimeoutExpired:
|
|
raise RecoveryError("Zeitueberschreitung bei: %s" % " ".join(str(a) for a in args))
|
|
stdout = proc.stdout.decode("utf-8", "replace").strip()
|
|
stderr = proc.stderr.decode("utf-8", "replace").strip()
|
|
if check and proc.returncode != 0:
|
|
detail = (stderr or stdout or "Fehler").strip().splitlines()
|
|
raise RecoveryError("%s: %s" % (args[0], detail[-1] if detail else "Fehler"))
|
|
return stdout
|
|
|
|
|
|
def have(command):
|
|
return shutil.which(command) is not None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Storage-Ebene - Proxmox ihre eigene Arbeit machen lassen
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Statt rbd/zfs/lvcreate selbst nachzubauen, wird die Storage-Schicht von
|
|
# Proxmox aufgerufen. Die kennt jeden Storage-Typ, benennt die Volumes richtig
|
|
# (vm-<id>-disk-N) und nimmt die Cluster-Sperre - ein Nachbau waere in jedem
|
|
# Punkt schlechter.
|
|
_PERL = r"""
|
|
use strict;
|
|
use warnings;
|
|
use PVE::Storage;
|
|
|
|
my ($op, @rest) = @ARGV;
|
|
my $cfg = PVE::Storage::config();
|
|
|
|
if ($op eq 'clone') {
|
|
my ($volid, $vmid, $snap) = @rest;
|
|
print "RESULT ", PVE::Storage::vdisk_clone($cfg, $volid, $vmid, $snap), "\n";
|
|
} elsif ($op eq 'alloc') {
|
|
my ($storeid, $vmid, $fmt, $size) = @rest;
|
|
print "RESULT ",
|
|
PVE::Storage::vdisk_alloc($cfg, $storeid, $vmid, $fmt, undef, $size), "\n";
|
|
} elsif ($op eq 'free') {
|
|
PVE::Storage::vdisk_free($cfg, $rest[0]);
|
|
print "RESULT ok\n";
|
|
} elsif ($op eq 'path') {
|
|
my ($path) = PVE::Storage::path($cfg, $rest[0]);
|
|
print "RESULT $path\n";
|
|
} elsif ($op eq 'activate') {
|
|
PVE::Storage::activate_volumes($cfg, [@rest]);
|
|
print "RESULT ok\n";
|
|
} elsif ($op eq 'deactivate') {
|
|
PVE::Storage::deactivate_volumes($cfg, [@rest]);
|
|
print "RESULT ok\n";
|
|
} elsif ($op eq 'size') {
|
|
my ($size, $format) = PVE::Storage::volume_size_info($cfg, $rest[0], 10);
|
|
print "RESULT $size $format\n";
|
|
} else {
|
|
die "unbekannte Operation: $op\n";
|
|
}
|
|
"""
|
|
|
|
|
|
def storage_op(op, *args, timeout=600):
|
|
"""Ruft die Storage-Schicht von Proxmox auf und liefert deren Ergebnis."""
|
|
output = run(["perl", "-e", _PERL, str(op)] + [str(a) for a in args],
|
|
timeout=timeout)
|
|
for line in output.splitlines():
|
|
if line.startswith("RESULT "):
|
|
return line[len("RESULT "):].strip()
|
|
raise RecoveryError("Unerwartete Antwort der Storage-Schicht bei '%s': %s"
|
|
% (op, output[:200] or "(leer)"))
|
|
|
|
|
|
def clone_volume(volid, newid, snapname):
|
|
"""Copy-on-Write-Klon eines Snapshot-Datentraegers."""
|
|
return storage_op("clone", volid, newid, snapname)
|
|
|
|
|
|
def alloc_volume(storeid, newid, size_kb, fmt="raw"):
|
|
return storage_op("alloc", storeid, newid, fmt, int(size_kb))
|
|
|
|
|
|
def free_volume(volid):
|
|
storage_op("free", volid, timeout=900)
|
|
|
|
|
|
def volume_path(volid):
|
|
return storage_op("path", volid, timeout=120)
|
|
|
|
|
|
def volume_size(volid):
|
|
parts = storage_op("size", volid, timeout=120).split()
|
|
return int(parts[0]) if parts and parts[0].isdigit() else 0
|
|
|
|
|
|
def _rbd_context(proxmox, volid):
|
|
"""(rbd-Basisbefehl, Image-Pfad) fuer ein Volume - None, wenn kein Ceph."""
|
|
if ":" not in volid:
|
|
return None
|
|
storage, volname = volid.split(":", 1)
|
|
try:
|
|
info = proxmox._json(["get", "/storage/%s" % storage]) or {}
|
|
except ProxmoxError:
|
|
return None
|
|
if info.get("type") != "rbd" or not have("rbd"):
|
|
return None
|
|
|
|
pool = info.get("pool") or "rbd"
|
|
namespace = info.get("namespace")
|
|
image = "%s/%s%s" % (pool, (namespace + "/") if namespace else "", volname)
|
|
args = ["rbd"]
|
|
if info.get("monhost"):
|
|
args += ["-m", str(info["monhost"]).replace(" ", ",")]
|
|
args += ["--id", info.get("username") or "admin"]
|
|
keyring = "/etc/pve/priv/ceph/%s.keyring" % storage
|
|
if os.path.exists(keyring):
|
|
args += ["--keyring", keyring]
|
|
return args, image
|
|
|
|
|
|
def unprotect_snapshot(proxmox, volid, snapname):
|
|
"""Den Schutz wieder loesen, den das Klonen auf Ceph gesetzt hat.
|
|
|
|
RBD verlangt fuer einen Klon, dass der Quell-Snapshot geschuetzt ist -
|
|
Proxmox setzt das beim Klonen selbst. Bleibt der Schutz stehen, kann die
|
|
Vorhaltezeit den Snapshot spaeter nicht mehr loeschen. Deshalb wird er
|
|
beim Verwerfen der Wiederherstellung wieder entfernt.
|
|
"""
|
|
context = _rbd_context(proxmox, volid)
|
|
if context is None:
|
|
return False
|
|
args, image = context
|
|
result = run(args + ["snap", "unprotect", image, "--snap", snapname],
|
|
check=False, timeout=120)
|
|
log.debug("unprotect %s@%s: %s", image, snapname, result or "ok")
|
|
return True
|
|
|
|
|
|
def linked_volumes(proxmox, instance):
|
|
"""Welche Datentraeger noch am Quell-Snapshot haengen."""
|
|
linked = []
|
|
for volid in instance.volumes:
|
|
context = _rbd_context(proxmox, volid)
|
|
if context is None:
|
|
continue
|
|
args, image = context
|
|
if "parent:" in run(args + ["info", image], check=False, timeout=60):
|
|
linked.append(volid)
|
|
return linked
|
|
|
|
|
|
def flatten_hint(proxmox, volid):
|
|
"""Warum ein Datentraeger nicht abgekoppelt wird - je nach Storage.
|
|
|
|
Nur Ceph kennt das Problem ueberhaupt: dort haelt ein Klon seinen
|
|
Quell-Snapshot fest. Andere Storage-Typen loesen das anders oder gar nicht.
|
|
"""
|
|
storage = volid.split(":", 1)[0] if ":" in volid else ""
|
|
try:
|
|
kind = (proxmox._json(["get", "/storage/%s" % storage]) or {}).get("type", "")
|
|
except ProxmoxError:
|
|
kind = ""
|
|
if kind == "lvmthin":
|
|
return ("nicht noetig - Thin-Snapshots sind von sich aus unabhaengig; "
|
|
"Quell-Snapshot und Original lassen sich jederzeit loeschen")
|
|
if kind == "zfspool":
|
|
return ("nicht moeglich - ein ZFS-Klon haengt am Snapshot. Loesen ginge "
|
|
"nur ueber 'zfs send | zfs recv' in einen neuen Datentraeger")
|
|
return "kein Ceph-Datentraeger"
|
|
|
|
|
|
def flatten_cost(proxmox, instance):
|
|
"""Wieviele Bytes ein flatten kopieren muesste.
|
|
|
|
Wichtig, weil `rbd du` je Snapshot nur den *Zuwachs* zeigt. Kopiert wird
|
|
aber der gesamte an dieser Stelle sichtbare Inhalt - das ist typisch um
|
|
Groessenordnungen mehr, als die Zeile des Snapshots vermuten laesst.
|
|
"""
|
|
total = 0
|
|
for volid in linked_volumes(proxmox, instance):
|
|
context = _rbd_context(proxmox, volid)
|
|
if context is None:
|
|
continue
|
|
args, image = context
|
|
parent = ""
|
|
for line in run(args + ["info", image], check=False, timeout=60).splitlines():
|
|
if line.strip().startswith("parent:"):
|
|
parent = line.split(":", 1)[1].strip()
|
|
if not parent:
|
|
continue
|
|
# "pool/image@snap" -> die Kette des Elternteils vermessen. Der Pool
|
|
# muss dranbleiben, sonst sucht rbd im Standard-Pool.
|
|
base = parent.split("@")[0]
|
|
measured = 0
|
|
for line in run(args + ["du", base], check=False, timeout=300).splitlines():
|
|
parts = line.split()
|
|
if len(parts) < 2 or parts[0] in ("NAME",):
|
|
continue
|
|
# "... 32 GiB 14 GiB" - die letzten beiden Felder sind USED
|
|
value = _parse_bytes(" ".join(parts[-2:]))
|
|
if line.strip().startswith("<TOTAL>"):
|
|
measured = value
|
|
break
|
|
measured = max(measured, value)
|
|
total += measured
|
|
return total
|
|
|
|
|
|
_UNITS = {"B": 1, "KIB": 1024, "MIB": 1024 ** 2, "GIB": 1024 ** 3,
|
|
"TIB": 1024 ** 4, "K": 1024, "M": 1024 ** 2, "G": 1024 ** 3}
|
|
|
|
|
|
def _parse_bytes(text):
|
|
match = re.match(r"^([\d.]+)\s*([A-Za-z]+)?$", str(text).strip())
|
|
if not match:
|
|
return 0
|
|
return int(float(match.group(1)) * _UNITS.get((match.group(2) or "B").upper(), 1))
|
|
|
|
|
|
def run_streamed(args, timeout=6 * 3600):
|
|
"""Wie run(), laesst das Kommando aber direkt ins Terminal schreiben.
|
|
|
|
Fuer `rbd flatten`: das bringt eine eigene Fortschrittsanzeige mit, und bei
|
|
einer Platte, deren Kopie eine Stunde dauert, ist die mehr wert als eine
|
|
aufgeraeumte Ausgabe. In der ncurses-Oberflaeche waere sie dagegen toedlich -
|
|
dort wird weiter run() mit --no-progress verwendet.
|
|
"""
|
|
log.debug("exec: %s", " ".join(str(a) for a in args))
|
|
try:
|
|
proc = subprocess.run([str(a) for a in args], timeout=timeout)
|
|
except FileNotFoundError:
|
|
raise RecoveryError("Kommando nicht gefunden: %s" % args[0])
|
|
except subprocess.TimeoutExpired:
|
|
raise RecoveryError("Zeitueberschreitung bei: %s" % " ".join(str(a) for a in args))
|
|
if proc.returncode != 0:
|
|
raise RecoveryError("%s ist fehlgeschlagen (Rueckgabewert %d)"
|
|
% (args[0], proc.returncode))
|
|
return ""
|
|
|
|
|
|
def _check_space(proxmox, instance, force=False, reserve=1.15):
|
|
"""Vor dem Flatten nachsehen, ob der Platz ueberhaupt reicht.
|
|
|
|
Bitter gelernt: laeuft ein Storage waehrend des Kopierens voll, blockiert
|
|
Ceph *alle* Schreibvorgaenge im Pool. Dann steht nicht nur das Flatten,
|
|
sondern jede laufende VM - und selbst Aufraeumen wird schwierig, weil auch
|
|
Loeschen ein Schreibvorgang ist.
|
|
"""
|
|
needed = flatten_cost(proxmox, instance)
|
|
if not needed:
|
|
return
|
|
storages = {v.split(":", 1)[0] for v in instance.volumes if ":" in v}
|
|
for name in sorted(storages):
|
|
try:
|
|
info = proxmox._json(["get", "/nodes/%s/storage/%s/status"
|
|
% (instance.node, name)]) or {}
|
|
except ProxmoxError:
|
|
continue
|
|
free = int(info.get("avail") or 0)
|
|
if not free:
|
|
continue
|
|
if free < needed * reserve:
|
|
message = ("Auf '%s' sind nur %s frei, gebraucht werden aber rund %s. "
|
|
"Laeuft der Storage dabei voll, stehen alle Maschinen "
|
|
"darauf - auch das Aufraeumen." % (name, human_bytes(free),
|
|
human_bytes(needed)))
|
|
if not force:
|
|
raise RecoveryError(message + " Mit --force trotzdem.")
|
|
log.warning("%s (--force)", message)
|
|
|
|
|
|
def flatten(proxmox, instance, progress=None, stream=False, force=False):
|
|
"""Die Klone vom Quell-Snapshot loesen.
|
|
|
|
Danach steht die Maschine auf eigenen Beinen: Sie belegt ihren Platz
|
|
vollstaendig selbst, und die Snapshots, aus denen sie entstanden ist,
|
|
lassen sich wieder loeschen. Fuer eine dauerhafte Wiederherstellung ist
|
|
das der letzte Schritt - vorher haengt sie fuer immer am Original.
|
|
|
|
Das kostet Zeit und Platz: hier werden die Daten wirklich kopiert.
|
|
"""
|
|
def step(text):
|
|
log.info("%s", text)
|
|
if progress:
|
|
progress(text)
|
|
|
|
if not instance.volumes:
|
|
raise RecoveryError("Zu %s sind keine Datentraeger vermerkt." % instance.label)
|
|
|
|
_check_space(proxmox, instance, force=force)
|
|
|
|
flattened, already, unsupported = [], [], []
|
|
for volid in instance.volumes:
|
|
context = _rbd_context(proxmox, volid)
|
|
if context is None:
|
|
unsupported.append(volid)
|
|
continue
|
|
args, image = context
|
|
described = run(args + ["info", image], check=False, timeout=60)
|
|
if not described or "No such file" in described or "does not exist" in described:
|
|
# Gibt es nicht mehr - etwa der kopierte Arbeitsspeicher, den
|
|
# Proxmox nach dem Fortsetzen selbst wieder freigegeben hat.
|
|
continue
|
|
if "parent:" not in described:
|
|
already.append(volid)
|
|
continue
|
|
step("Loese %s vom Quell-Snapshot - dabei werden die Daten wirklich "
|
|
"kopiert" % volid)
|
|
if stream:
|
|
run_streamed(args + ["flatten", image])
|
|
else:
|
|
run(args + ["flatten", image, "--no-progress"], timeout=6 * 3600)
|
|
flattened.append(volid)
|
|
|
|
# Erst wenn nichts mehr am Snapshot haengt, darf der Schutz weg.
|
|
if not linked_volumes(proxmox, instance):
|
|
for entry in list(instance.protected):
|
|
try:
|
|
step("Hebe Schutz von %s@%s auf" % (entry[0], entry[1]))
|
|
unprotect_snapshot(proxmox, entry[0], entry[1])
|
|
except (RecoveryError, IndexError, TypeError):
|
|
pass
|
|
instance.protected = []
|
|
_registry_add(instance)
|
|
|
|
return flattened, already, unsupported
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Groessenangaben
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_SIZE = re.compile(r"^\s*(\d+(?:[.,]\d+)?)\s*([KMGT])?i?B?\s*$", re.I)
|
|
_FACTOR = {"K": 1, "M": 1024, "G": 1024 ** 2, "T": 1024 ** 3}
|
|
|
|
|
|
def parse_size_kb(text, default_unit="G"):
|
|
"""'10G' -> Kilobyte. vdisk_alloc rechnet in KiB."""
|
|
match = _SIZE.match(str(text or ""))
|
|
if not match:
|
|
raise RecoveryError("Groessenangabe nicht verstanden: %r "
|
|
"(erwartet z.B. 10G, 512M)" % text)
|
|
value = float(match.group(1).replace(",", "."))
|
|
unit = (match.group(2) or default_unit).upper()
|
|
return max(1, int(value * _FACTOR[unit]))
|
|
|
|
|
|
def human_bytes(count):
|
|
value = float(count or 0)
|
|
for unit in ("B", "K", "M", "G", "T", "P"):
|
|
if value < 1024 or unit == "P":
|
|
return "%d%s" % (value, unit) if unit == "B" else "%.1f%s" % (value, unit)
|
|
value /= 1024
|
|
return "%.1fP" % value
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Datenmodell
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@dataclass
|
|
class Spec:
|
|
"""Was gewuenscht ist."""
|
|
mode: str = "live" # live | recovery
|
|
newid: int = 0 # 0 = naechste freie
|
|
node: str = "" # leer = Node des Originals
|
|
net: str = "" # leer = Vorgabe des Modus; none|down|on
|
|
resume: object = None # None = RAM-Zustand nehmen, wenn vorhanden
|
|
exchange: str = "" # Groesse, z.B. "10G"; leer = keins
|
|
exchange_storage: str = ""
|
|
exchange_fs: str = DEFAULT_EXCHANGE_FS
|
|
exchange_dir: str = "" # nur LXC: vorhandenes Host-Verzeichnis
|
|
transfers: list = field(default_factory=list) # Namen vorhandener Laufwerke
|
|
memory: int = 0 # 0 = wie im Snapshot
|
|
cores: int = 0
|
|
name: str = ""
|
|
keep_binds: bool = False
|
|
start: bool = True
|
|
|
|
@property
|
|
def isolated(self):
|
|
return self.mode != "recovery"
|
|
|
|
|
|
@dataclass
|
|
class Plan:
|
|
"""Was passieren wird - ohne dass schon etwas passiert ist."""
|
|
guest: object
|
|
snapshot: str
|
|
spec: Spec
|
|
newid: int = 0
|
|
node: str = ""
|
|
disks: list = field(default_factory=list) # [(key, volid, groesse)]
|
|
nets: list = field(default_factory=list) # [(key, wert)]
|
|
net_mode: str = "none"
|
|
vmstate: str = ""
|
|
vmstate_bytes: int = 0
|
|
resume: bool = False
|
|
exchange_kind: str = "" # "disk" | "bind" | ""
|
|
exchange_detail: str = ""
|
|
transfers: list = field(default_factory=list) # [transfer.Volume]
|
|
warnings: list = field(default_factory=list)
|
|
# Was nicht nur unschoen, sondern gefaehrlich ist. Hierfuer genuegt ein
|
|
# beilaeufiges "ja" nicht - siehe cmd_create() und die Oberflaeche.
|
|
critical: list = field(default_factory=list)
|
|
notes: list = field(default_factory=list)
|
|
dropped: list = field(default_factory=list)
|
|
|
|
@property
|
|
def label(self):
|
|
return "%s %d" % ("LXC" if self.guest.type == "lxc" else "VM", self.newid)
|
|
|
|
|
|
@dataclass
|
|
class Instance:
|
|
"""Eine eingerichtete Wiederherstellung."""
|
|
vmid: int
|
|
type: str = "qemu"
|
|
node: str = ""
|
|
name: str = ""
|
|
source: int = 0
|
|
source_node: str = ""
|
|
snapshot: str = ""
|
|
mode: str = "live"
|
|
created: int = 0
|
|
volumes: list = field(default_factory=list)
|
|
protected: list = field(default_factory=list) # [[volid, snapname]]
|
|
exchange: dict = field(default_factory=dict)
|
|
transfers: list = field(default_factory=list)
|
|
resumed: bool = False
|
|
status: str = ""
|
|
|
|
@property
|
|
def guest(self):
|
|
return Guest(vmid=self.vmid, name=self.name, type=self.type, node=self.node)
|
|
|
|
@property
|
|
def label(self):
|
|
return "%s %d" % ("LXC" if self.type == "lxc" else "VM", self.vmid)
|
|
|
|
@property
|
|
def origin(self):
|
|
return "%s %d @ %s" % ("LXC" if self.type == "lxc" else "VM",
|
|
self.source, self.snapshot)
|
|
|
|
def console_url(self, host=None):
|
|
kind = "lxc" if self.type == "lxc" else "kvm"
|
|
return ("https://%s:8006/?console=%s&novnc=1&vmid=%d&node=%s&resize=off&cmd="
|
|
% (host or self.node, kind, self.vmid, self.node))
|
|
|
|
def to_dict(self):
|
|
return {k: getattr(self, k) for k in
|
|
("vmid", "type", "node", "name", "source", "source_node", "snapshot",
|
|
"mode", "created", "volumes", "protected", "exchange", "transfers",
|
|
"resumed")}
|
|
|
|
@classmethod
|
|
def from_dict(cls, data):
|
|
known = {k: data.get(k) for k in
|
|
("vmid", "type", "node", "name", "source", "source_node", "snapshot",
|
|
"mode", "created", "volumes", "protected", "exchange", "transfers",
|
|
"resumed") if data.get(k) is not None}
|
|
known["vmid"] = int(known.get("vmid") or 0)
|
|
return cls(**known)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Merkliste
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _registry_load():
|
|
try:
|
|
with open(REGISTRY, "r", encoding="utf-8") as handle:
|
|
data = json.load(handle)
|
|
return [Instance.from_dict(entry) for entry in data] if isinstance(data, list) else []
|
|
except (OSError, ValueError, TypeError):
|
|
return []
|
|
|
|
|
|
def _registry_save(instances):
|
|
try:
|
|
os.makedirs(STATE_DIR, exist_ok=True)
|
|
tmp = REGISTRY + ".tmp"
|
|
with open(tmp, "w", encoding="utf-8") as handle:
|
|
json.dump([i.to_dict() for i in instances], handle, indent=1)
|
|
os.replace(tmp, REGISTRY)
|
|
except OSError as exc:
|
|
log.warning("Merkliste %s nicht schreibbar: %s", REGISTRY, exc)
|
|
|
|
|
|
def _registry_add(instance):
|
|
entries = [i for i in _registry_load() if i.vmid != instance.vmid]
|
|
entries.append(instance)
|
|
_registry_save(entries)
|
|
|
|
|
|
def _registry_remove(vmid):
|
|
_registry_save([i for i in _registry_load() if i.vmid != int(vmid)])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Konfigurationsdatei des neuen Gastes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def config_path(node, guest_type, vmid):
|
|
sub = "lxc" if guest_type == "lxc" else "qemu-server"
|
|
return os.path.join(CONF_ROOT, node, sub, "%d.conf" % vmid)
|
|
|
|
|
|
def _write_config(path, config, description=""):
|
|
"""Konfiguration anlegen - und dabei die VMID belegen.
|
|
|
|
O_EXCL: existiert die Datei schon, gehoert die VMID jemand anderem. Auf
|
|
/etc/pve ist das die zuverlaessigste Reservierung, die es gibt.
|
|
Die Beschreibung steht bei Proxmox als '#'-Zeilen am Dateianfang.
|
|
"""
|
|
lines = []
|
|
for line in str(description or "").rstrip("\n").split("\n"):
|
|
if description:
|
|
lines.append("#" + line)
|
|
for key in sorted(config):
|
|
value = config[key]
|
|
if value is None or value == "":
|
|
continue
|
|
lines.append("%s: %s" % (key, value))
|
|
text = "\n".join(lines) + "\n"
|
|
|
|
directory = os.path.dirname(path)
|
|
if not os.path.isdir(directory):
|
|
raise RecoveryError("Verzeichnis %s gibt es nicht - Node-Name richtig?"
|
|
% directory)
|
|
try:
|
|
handle = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o640)
|
|
except FileExistsError:
|
|
raise RecoveryError("%s gibt es bereits - die VMID ist belegt." % path)
|
|
except OSError as exc:
|
|
raise RecoveryError("%s nicht anlegbar: %s" % (path, exc))
|
|
try:
|
|
os.write(handle, text.encode("utf-8"))
|
|
finally:
|
|
os.close(handle)
|
|
|
|
|
|
def _replace_volid(value, new_volid):
|
|
parts = str(value).split(",")
|
|
parts[0] = new_volid
|
|
return ",".join(parts)
|
|
|
|
|
|
def _volid_of(value):
|
|
first = str(value).split(",", 1)[0].strip()
|
|
return first if ":" in first and first not in ("none", "cdrom") else ""
|
|
|
|
|
|
def _is_cdrom(value):
|
|
return "media=cdrom" in str(value)
|
|
|
|
|
|
def _set_option(value, option, wanted):
|
|
"""Eine Option in einem Proxmox-Wertfeld setzen oder ersetzen."""
|
|
parts = [p for p in str(value).split(",") if not p.startswith(option + "=")]
|
|
parts.append("%s=%s" % (option, wanted))
|
|
return ",".join(parts)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Planen
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _disk_keys(config, guest_type):
|
|
if guest_type == "lxc":
|
|
return [k for k in sorted(config) if LXC_DISK_KEY.match(k)]
|
|
keys = [k for k in sorted(config) if QEMU_DISK_KEY.match(k)]
|
|
keys += [k for k in QEMU_EXTRA_DISKS if k in config]
|
|
return keys
|
|
|
|
|
|
def plan(proxmox, guest, snapname, spec):
|
|
"""Alles pruefen und zusammenstellen, ohne etwas zu veraendern."""
|
|
config = proxmox.guest_config(guest, snapshot=snapname)
|
|
result = Plan(guest=guest, snapshot=snapname, spec=spec)
|
|
|
|
# -- Ziel-VMID und Node ------------------------------------------------
|
|
if spec.newid:
|
|
if not proxmox.vmid_free(spec.newid):
|
|
raise RecoveryError("VMID %d ist bereits vergeben." % spec.newid)
|
|
result.newid = int(spec.newid)
|
|
else:
|
|
result.newid = proxmox.next_vmid()
|
|
result.node = spec.node or guest.node
|
|
|
|
# -- Datentraeger ------------------------------------------------------
|
|
for key in _disk_keys(config, guest.type):
|
|
value = str(config[key])
|
|
if _is_cdrom(value):
|
|
continue
|
|
volid = _volid_of(value)
|
|
if not volid:
|
|
if guest.type == "lxc" and "mp=" in value:
|
|
result.dropped.append("%s (%s)" % (key, value.split(",")[0]))
|
|
continue
|
|
size = ""
|
|
for part in value.split(",")[1:]:
|
|
if part.startswith("size="):
|
|
size = part[5:]
|
|
result.disks.append((key, volid, size))
|
|
if not result.disks:
|
|
raise RecoveryError("Der Snapshot enthaelt keine Datentraeger, die sich "
|
|
"klonen liessen.")
|
|
|
|
# Storages muessen die Klone tragen koennen und vom Ziel-Node erreichbar sein.
|
|
for _key, volid, _size in result.disks:
|
|
storage = volid.split(":", 1)[0]
|
|
info = _storage_info(proxmox, storage)
|
|
nodes = [n.strip() for n in str(info.get("nodes") or "").split(",") if n.strip()]
|
|
if nodes and result.node not in nodes:
|
|
raise RecoveryError("Storage '%s' ist auf Node '%s' nicht verfuegbar."
|
|
% (storage, result.node))
|
|
if not info.get("shared") and result.node != guest.node:
|
|
raise RecoveryError("Storage '%s' ist nicht geteilt - die Wiederherstellung "
|
|
"muss auf '%s' laufen." % (storage, guest.node))
|
|
|
|
# -- Arbeitsspeicher ---------------------------------------------------
|
|
state_volid = str(config.get("vmstate") or "")
|
|
if state_volid and guest.type == "qemu":
|
|
result.vmstate = state_volid
|
|
want = spec.resume if spec.resume is not None else True
|
|
result.resume = bool(want)
|
|
if result.resume:
|
|
try:
|
|
result.vmstate_bytes = volume_size(state_volid)
|
|
except RecoveryError:
|
|
result.vmstate_bytes = 0
|
|
elif spec.resume:
|
|
result.notes.append("Der Snapshot enthaelt keinen Arbeitsspeicher - die "
|
|
"Maschine bootet kalt (wie nach einem Stromausfall).")
|
|
|
|
# -- Netzwerk ----------------------------------------------------------
|
|
result.nets = [(k, str(config[k])) for k in sorted(config) if NET_KEY.match(k)]
|
|
if spec.net:
|
|
result.net_mode = spec.net
|
|
else:
|
|
result.net_mode = "on" if not spec.isolated else "none"
|
|
|
|
if result.resume and result.net_mode == "none" and result.nets:
|
|
# Ein gespeicherter RAM-Zustand laesst sich nur in eine Maschine mit
|
|
# genau denselben Geraeten laden. Die Netzwerkkarte muss also bleiben -
|
|
# abgeklemmt wird stattdessen die Leitung.
|
|
result.net_mode = "down"
|
|
result.notes.append("Netzwerkkarte bleibt vorhanden, aber abgeklemmt "
|
|
"(link_down): mit geladenem Arbeitsspeicher darf sich "
|
|
"die Geraeteausstattung nicht aendern.")
|
|
|
|
if result.net_mode == "on" and result.nets:
|
|
result.warnings.append(
|
|
"Die Wiederherstellung geht MIT Netzwerk online - mit denselben "
|
|
"MAC-Adressen wie das Original.")
|
|
status = proxmox.guest_status(guest)
|
|
if status.get("status") == "running":
|
|
result.critical.append(
|
|
"%s LAEUFT gerade. Zwei Maschinen mit gleicher MAC-Adresse, "
|
|
"gleicher IP und gleicher Identitaet im selben Netz geben Chaos - "
|
|
"erst das Original stoppen." % guest.label)
|
|
|
|
# -- Austauschlaufwerk -------------------------------------------------
|
|
if spec.exchange_dir:
|
|
if guest.type != "lxc":
|
|
raise RecoveryError("--exchange-dir gibt es nur fuer Container. Bei "
|
|
"virtuellen Maschinen laesst sich kein Host-"
|
|
"Verzeichnis durchreichen - dort --exchange <groesse> "
|
|
"verwenden.")
|
|
if not os.path.isdir(spec.exchange_dir):
|
|
raise RecoveryError("Verzeichnis gibt es nicht: %s" % spec.exchange_dir)
|
|
result.exchange_kind = "bind"
|
|
result.exchange_detail = "%s -> %s (im Gast)" % (spec.exchange_dir,
|
|
EXCHANGE_GUEST_PATH)
|
|
elif spec.exchange:
|
|
if guest.type == "lxc":
|
|
result.exchange_kind = "bind"
|
|
result.exchange_detail = "%s/%d -> %s (im Gast)" % (
|
|
EXCHANGE_ROOT, result.newid, EXCHANGE_GUEST_PATH)
|
|
else:
|
|
size_kb = parse_size_kb(spec.exchange)
|
|
storage = spec.exchange_storage or result.disks[0][1].split(":", 1)[0]
|
|
if spec.exchange_fs not in MKFS:
|
|
raise RecoveryError("Dateisystem %r kenne ich nicht (%s)"
|
|
% (spec.exchange_fs, ", ".join(sorted(MKFS))))
|
|
if not have(MKFS[spec.exchange_fs][0]):
|
|
raise RecoveryError("%s ist nicht installiert - anderes Dateisystem "
|
|
"waehlen oder Paket nachinstallieren."
|
|
% MKFS[spec.exchange_fs][0])
|
|
result.exchange_kind = "disk"
|
|
result.exchange_detail = "%s auf %s, %s, im Gast als weitere Platte" % (
|
|
human_bytes(size_kb * 1024), storage, spec.exchange_fs)
|
|
|
|
# -- Transfer-Laufwerke ------------------------------------------------
|
|
for name in spec.transfers:
|
|
volume = transfer.get(name) # wirft, wenn es das nicht gibt
|
|
if not volume.free:
|
|
raise RecoveryError("Transfer-Laufwerk %r ist gerade %s."
|
|
% (name, volume.state_text))
|
|
result.transfers.append(volume)
|
|
|
|
# -- Hinweise ----------------------------------------------------------
|
|
if result.dropped and not spec.keep_binds:
|
|
result.notes.append("Nicht uebernommen werden Einbindungen von Host-"
|
|
"Verzeichnissen: %s" % ", ".join(result.dropped))
|
|
if spec.memory and result.resume:
|
|
raise RecoveryError("Ein geladener Arbeitsspeicher laesst sich nicht "
|
|
"vergroessern oder verkleinern - entweder --memory "
|
|
"weglassen oder --no-resume verwenden.")
|
|
if spec.mode == "recovery":
|
|
kinds = {_storage_info(proxmox, volid.split(":", 1)[0]).get("type")
|
|
for _key, volid, _size in result.disks}
|
|
if "rbd" in kinds:
|
|
result.notes.append(
|
|
"Die Datentraeger sind Linked Clones - auf Ceph haelt ein Klon "
|
|
"seinen Quell-Snapshot fest, der dadurch unloeschbar wird. Soll "
|
|
"die Maschine dauerhaft laufen, spaeter mit "
|
|
"'pvesnap-recovery flatten %d' loesen." % result.newid)
|
|
elif kinds <= {"lvmthin"}:
|
|
result.notes.append(
|
|
"Die Datentraeger sind Thin-Snapshots und damit von sich aus "
|
|
"unabhaengig - Quell-Snapshot und Original lassen sich jederzeit "
|
|
"loeschen. Ein Abkoppeln ist nicht noetig.")
|
|
if result.resume:
|
|
result.notes.append("Der Arbeitsspeicher (%s) wird kopiert; die Maschine "
|
|
"laeuft danach genau dort weiter, wo sie beim Snapshot "
|
|
"stand." % (human_bytes(result.vmstate_bytes) or "?"))
|
|
result.notes.append("Proxmox gibt den kopierten Arbeitsspeicher nach dem "
|
|
"ersten Start wieder frei - ein spaeterer Neustart "
|
|
"bootet dann kalt.")
|
|
return result
|
|
|
|
|
|
def _storage_info(proxmox, name):
|
|
data = proxmox._json(["get", "/storage/%s" % name])
|
|
if not isinstance(data, dict):
|
|
raise RecoveryError("Storage %r nicht gefunden" % name)
|
|
return data
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Einrichten
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def create(proxmox, plan_, progress=None):
|
|
"""Den Plan umsetzen. Bei einem Fehler wird alles wieder abgeraeumt."""
|
|
def step(text):
|
|
log.info("%s", text)
|
|
if progress:
|
|
progress(text)
|
|
|
|
guest = plan_.guest
|
|
spec = plan_.spec
|
|
config = proxmox.guest_config(guest, snapshot=plan_.snapshot)
|
|
|
|
created = [] # Volumes, die wir angelegt haben
|
|
protected = [] # [volid, snapname] deren Schutz wir loesen muessen
|
|
made_dirs = []
|
|
conf_file = ""
|
|
|
|
try:
|
|
new_config = {}
|
|
for key, value in config.items():
|
|
if key in DROP_KEYS or NET_KEY.match(key):
|
|
continue
|
|
if QEMU_DISK_KEY.match(key) or LXC_DISK_KEY.match(key) \
|
|
or key in QEMU_EXTRA_DISKS:
|
|
continue
|
|
new_config[key] = value
|
|
|
|
# -- Datentraeger klonen ------------------------------------------
|
|
for index, (key, volid, _size) in enumerate(plan_.disks, 1):
|
|
step("Klone %s (%d/%d): %s" % (key, index, len(plan_.disks), volid))
|
|
clone = clone_volume(volid, plan_.newid, plan_.snapshot)
|
|
created.append(clone)
|
|
protected.append([volid, plan_.snapshot])
|
|
new_config[key] = _replace_volid(config[key], clone)
|
|
|
|
# CD-Laufwerke und nicht klonbare Einbindungen
|
|
for key in _disk_keys(config, guest.type):
|
|
if key in new_config:
|
|
continue
|
|
value = str(config[key])
|
|
if _is_cdrom(value):
|
|
new_config[key] = value
|
|
elif guest.type == "lxc" and not _volid_of(value):
|
|
if spec.keep_binds:
|
|
new_config[key] = value
|
|
|
|
# -- Arbeitsspeicher ----------------------------------------------
|
|
if plan_.resume and plan_.vmstate:
|
|
step("Kopiere Arbeitsspeicher (%s) - das dauert einen Moment"
|
|
% (human_bytes(plan_.vmstate_bytes) or "?"))
|
|
copy = _copy_state(plan_.vmstate, plan_.newid)
|
|
created.append(copy)
|
|
new_config["vmstate"] = copy
|
|
for key in ("runningmachine", "runningcpu"):
|
|
if config.get(key):
|
|
new_config[key] = config[key]
|
|
|
|
# -- Netzwerk ------------------------------------------------------
|
|
for key, value in plan_.nets:
|
|
if plan_.net_mode == "none":
|
|
continue
|
|
if plan_.net_mode == "down":
|
|
value = _set_option(value, "link_down", "1")
|
|
new_config[key] = value
|
|
|
|
# -- Austauschlaufwerk ---------------------------------------------
|
|
exchange = {}
|
|
if plan_.exchange_kind == "disk":
|
|
storage = spec.exchange_storage or plan_.disks[0][1].split(":", 1)[0]
|
|
size_kb = parse_size_kb(spec.exchange)
|
|
step("Lege Austauschlaufwerk an (%s, %s)"
|
|
% (human_bytes(size_kb * 1024), spec.exchange_fs))
|
|
volid = alloc_volume(storage, plan_.newid, size_kb)
|
|
created.append(volid)
|
|
_format_exchange(volid, spec.exchange_fs)
|
|
slot = _free_disk_slot(new_config)
|
|
drive = "%s,backup=0" % volid
|
|
exchange = {"kind": "disk", "volid": volid, "key": slot,
|
|
"fs": spec.exchange_fs, "drive": drive,
|
|
"pending": bool(plan_.resume)}
|
|
# Mit geladenem Arbeitsspeicher wacht der Gast in einem Zustand auf,
|
|
# in dem es diese Platte nicht gab - er wuerde sie nie bemerken.
|
|
# Deshalb kommt sie erst nach dem Fortsetzen dazu, per Hotplug.
|
|
if not plan_.resume:
|
|
new_config[slot] = drive
|
|
elif plan_.exchange_kind == "bind":
|
|
host_dir = spec.exchange_dir or os.path.join(EXCHANGE_ROOT,
|
|
str(plan_.newid))
|
|
if not spec.exchange_dir:
|
|
os.makedirs(host_dir, exist_ok=True)
|
|
made_dirs.append(host_dir)
|
|
_prepare_bind_dir(host_dir, str(config.get("unprivileged")) == "1")
|
|
step("Reiche %s in den Container durch" % host_dir)
|
|
slot = _free_mp_slot(new_config)
|
|
new_config[slot] = "%s,mp=%s" % (host_dir, EXCHANGE_GUEST_PATH)
|
|
exchange = {"kind": "bind", "path": host_dir, "key": slot,
|
|
"own": not spec.exchange_dir}
|
|
|
|
# -- Transfer-Laufwerke --------------------------------------------
|
|
attached = []
|
|
for volume in plan_.transfers:
|
|
if guest.type == "lxc":
|
|
# Beim Container wird der Mountpunkt des Hosts durchgereicht.
|
|
# Ein Bind-Mount ist dasselbe Dateisystem an zwei Stellen, kein
|
|
# zweites Einhaengen - das ist unbedenklich.
|
|
step("Reiche Transfer-Laufwerk %r durch" % volume.name)
|
|
path = transfer.host_mount(volume.name)
|
|
slot = _free_mp_slot(new_config)
|
|
new_config[slot] = "%s,mp=/mnt/%s" % (path, volume.name)
|
|
else:
|
|
step("Haenge Transfer-Laufwerk %r an" % volume.name)
|
|
slot = _free_disk_slot(new_config)
|
|
new_config[slot] = transfer.drive_string(volume.name)
|
|
attached.append(volume.name)
|
|
|
|
# -- restliche Anpassungen -----------------------------------------
|
|
if spec.memory:
|
|
new_config["memory"] = spec.memory
|
|
if spec.cores:
|
|
new_config["cores"] = spec.cores
|
|
new_config["tags"] = TAG
|
|
if guest.type == "qemu":
|
|
new_config["name"] = spec.name or _default_name(guest, plan_)
|
|
|
|
description = _description(guest, plan_)
|
|
|
|
# -- Konfiguration schreiben ---------------------------------------
|
|
conf_file = config_path(plan_.node, guest.type, plan_.newid)
|
|
step("Lege %s an" % conf_file)
|
|
_write_config(conf_file, new_config, description)
|
|
|
|
instance = Instance(
|
|
vmid=plan_.newid, type=guest.type, node=plan_.node,
|
|
name=str(new_config.get("name") or new_config.get("hostname") or ""),
|
|
source=guest.vmid, source_node=guest.node, snapshot=plan_.snapshot,
|
|
mode=spec.mode, created=int(time.time()), volumes=created,
|
|
protected=protected, exchange=exchange, resumed=plan_.resume,
|
|
transfers=attached)
|
|
_registry_add(instance)
|
|
return instance
|
|
|
|
except Exception as exc:
|
|
step("Fehler - raeume wieder ab: %s" % exc)
|
|
if conf_file and os.path.exists(conf_file):
|
|
try:
|
|
os.unlink(conf_file)
|
|
except OSError:
|
|
pass
|
|
for volid in reversed(created):
|
|
try:
|
|
free_volume(volid)
|
|
except RecoveryError as cleanup_exc:
|
|
log.warning("Klon %s blieb liegen: %s", volid, cleanup_exc)
|
|
for volid, snapname in protected:
|
|
try:
|
|
unprotect_snapshot(proxmox, volid, snapname)
|
|
except RecoveryError:
|
|
pass
|
|
for path in made_dirs:
|
|
shutil.rmtree(path, ignore_errors=True)
|
|
for volume in plan_.transfers:
|
|
try:
|
|
transfer.host_umount(volume.name)
|
|
transfer.release(volume.name)
|
|
except transfer.TransferError:
|
|
pass
|
|
raise
|
|
|
|
|
|
# Woran zu erkennen ist, dass der Arbeitsspeicher nicht angekommen ist.
|
|
# Proxmox meldet den Startvorgang trotzdem mit "TASK OK" - die Maschine bleibt
|
|
# dann aber angehalten stehen, ohne dass irgendwo ein Fehler sichtbar waere.
|
|
_STATE_FAILED = ("Error while loading VM state", "Unknown savevm section",
|
|
"Make sure that your current VM setup matches")
|
|
|
|
|
|
def start(proxmox, instance, progress=None):
|
|
"""Hochfahren - und nachsehen, ob dabei herauskam, was gewollt war.
|
|
|
|
Liefert einen Hinweistext, wenn etwas anders lief als geplant, sonst "".
|
|
"""
|
|
def step(text):
|
|
log.info("%s", text)
|
|
if progress:
|
|
progress(text)
|
|
|
|
upid = proxmox.start_guest(instance.guest)
|
|
if instance.type != "qemu":
|
|
return ""
|
|
|
|
note = ""
|
|
if instance.resumed and upid:
|
|
broken = [line.strip() for line in proxmox.task_log(instance.node, upid)
|
|
if any(marker in line for marker in _STATE_FAILED)]
|
|
if broken:
|
|
note = ("Der Arbeitsspeicher liess sich nicht laden - die Maschine "
|
|
"startet stattdessen kalt vom Datentraeger. Proxmox meldet: %s"
|
|
% broken[0])
|
|
|
|
# Nach dem Laden eines RAM-Standes steht die Maschine angehalten da und
|
|
# muss noch fortgesetzt werden.
|
|
for _ in range(15):
|
|
qmp = (proxmox.guest_status(instance.guest).get("qmpstatus") or "")
|
|
if qmp == "running":
|
|
return _attach_pending(proxmox, instance, step) or note
|
|
if qmp in ("paused", "prelaunch"):
|
|
step("Setze %s fort" % instance.label)
|
|
try:
|
|
proxmox.resume_guest(instance.guest)
|
|
except ProxmoxError as exc:
|
|
return "%s laesst sich nicht fortsetzen: %s" % (instance.label, exc)
|
|
time.sleep(1.0)
|
|
if (proxmox.guest_status(instance.guest).get("qmpstatus") or "") == "running":
|
|
return _attach_pending(proxmox, instance, step) or note
|
|
time.sleep(1.0)
|
|
return note or ("%s ist gestartet, laeuft aber nicht. Warum, steht im "
|
|
"Task-Protokoll von Proxmox." % instance.label)
|
|
|
|
|
|
def _attach_pending(proxmox, instance, step=None):
|
|
"""Das Austauschlaufwerk an die schon laufende Maschine anstecken."""
|
|
exchange = instance.exchange or {}
|
|
if not exchange.get("pending"):
|
|
return ""
|
|
key, drive = exchange.get("key"), exchange.get("drive")
|
|
if not key or not drive:
|
|
return ""
|
|
if step:
|
|
step("Stecke Austauschlaufwerk als %s an" % key)
|
|
try:
|
|
proxmox.set_guest_config(instance.guest, {key: drive})
|
|
config = proxmox.guest_config(instance.guest)
|
|
except ProxmoxError as exc:
|
|
return "Das Austauschlaufwerk liess sich nicht anstecken: %s" % exc
|
|
|
|
if key not in config:
|
|
# Proxmox hat es nur vorgemerkt - im Gast taucht es dann nicht auf.
|
|
return ("Das Austauschlaufwerk ist eingetragen, aber nicht angesteckt "
|
|
"worden (Hotplug fuer Platten ist bei dieser Maschine aus). "
|
|
"Es erscheint erst nach einem Neustart des Gastes.")
|
|
exchange["pending"] = False
|
|
_registry_add(instance)
|
|
return ""
|
|
|
|
|
|
def _default_name(guest, plan_):
|
|
base = re.sub(r"[^A-Za-z0-9-]", "-", guest.name or "vm%d" % guest.vmid)[:40]
|
|
return ("%s-wdh" % base).strip("-")
|
|
|
|
|
|
def _description(guest, plan_):
|
|
return "\n".join([
|
|
"%s - von pvesnap angelegt, nicht die Originalmaschine." % TAG,
|
|
"",
|
|
"Quelle: %s auf %s" % (guest.label, guest.node),
|
|
"Snapshot: %s" % plan_.snapshot,
|
|
"Modus: %s" % ("recovery (mit Netzwerk)" if plan_.net_mode == "on"
|
|
else "live (ohne Netzwerk)"),
|
|
"Angelegt: %s" % time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
"",
|
|
"Verwerfen: pvesnap-recovery destroy %d" % plan_.newid,
|
|
])
|
|
|
|
|
|
def _free_disk_slot(config):
|
|
for index in range(1, 31):
|
|
for prefix in ("scsi", "virtio", "sata"):
|
|
key = "%s%d" % (prefix, index)
|
|
if key not in config and any(k.startswith(prefix) for k in config):
|
|
return key
|
|
for index in range(1, 31):
|
|
if "scsi%d" % index not in config:
|
|
return "scsi%d" % index
|
|
raise RecoveryError("Kein freier Platz fuer ein Austauschlaufwerk.")
|
|
|
|
|
|
def _free_mp_slot(config):
|
|
for index in range(0, 256):
|
|
if "mp%d" % index not in config:
|
|
return "mp%d" % index
|
|
raise RecoveryError("Kein freier Einhaengepunkt mehr frei.")
|
|
|
|
|
|
def _prepare_bind_dir(path, unprivileged):
|
|
"""Damit der Container hineinschreiben darf."""
|
|
try:
|
|
if unprivileged:
|
|
os.chown(path, 100000, 100000) # Standard-Verschiebung von PVE
|
|
os.chmod(path, 0o777)
|
|
except OSError as exc:
|
|
log.warning("Rechte auf %s nicht setzbar: %s", path, exc)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Arbeitsspeicher kopieren
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _copy_state(volid, newid):
|
|
"""Den RAM-Stand des Snapshots duplizieren.
|
|
|
|
Kopiert wird, nicht geklont: Proxmox gibt den Arbeitsspeicher nach dem
|
|
Laden selbst wieder frei - das darf auf keinen Fall den Snapshot des
|
|
Originals treffen.
|
|
"""
|
|
if not have("qemu-img"):
|
|
raise RecoveryError("'qemu-img' fehlt (apt install qemu-utils) - ohne das "
|
|
"laesst sich der Arbeitsspeicher nicht uebernehmen.")
|
|
storage = volid.split(":", 1)[0]
|
|
size = volume_size(volid)
|
|
if size <= 0:
|
|
raise RecoveryError("Groesse von %s nicht ermittelbar" % volid)
|
|
|
|
target = alloc_volume(storage, newid, max(1, (size + 1023) // 1024))
|
|
try:
|
|
storage_op("activate", volid, target, timeout=300)
|
|
source_path = volume_path(volid)
|
|
target_path = volume_path(target)
|
|
run(["qemu-img", "convert", "-O", "raw", "-n", source_path, target_path],
|
|
timeout=7200)
|
|
except Exception:
|
|
try:
|
|
free_volume(target)
|
|
except RecoveryError:
|
|
pass
|
|
raise
|
|
return target
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Austauschlaufwerk formatieren
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _format_exchange(volid, fstype):
|
|
with _block_device(volid) as device:
|
|
run(MKFS[fstype] + [device], timeout=900)
|
|
_place_readme(device, fstype)
|
|
|
|
|
|
class _block_device:
|
|
"""Ein Volume als echtes Blockgeraet - noetig, weil `pvesm path` bei Ceph
|
|
nur eine qemu-Adresse (rbd:pool/image:...) liefert, kein /dev/..."""
|
|
|
|
def __init__(self, volid):
|
|
self.volid = volid
|
|
self.device = ""
|
|
self.mapped = False
|
|
|
|
def __enter__(self):
|
|
storage_op("activate", self.volid, timeout=300)
|
|
path = volume_path(self.volid)
|
|
if path.startswith("rbd:"):
|
|
self.device = self._map_rbd(path)
|
|
self.mapped = True
|
|
else:
|
|
self.device = path
|
|
if not os.path.exists(self.device):
|
|
raise RecoveryError("%s ist nicht als Geraet aufgetaucht" % self.device)
|
|
return self.device
|
|
|
|
def __exit__(self, *_exc):
|
|
if self.mapped and self.device:
|
|
run(["rbd", "unmap", self.device], check=False, timeout=120)
|
|
return False
|
|
|
|
@staticmethod
|
|
def _map_rbd(path):
|
|
# rbd:data/vm-9802-disk-1:conf=/etc/pve/ceph.conf:id=admin:keyring=...
|
|
fields = path[4:].split(":")
|
|
image = fields[0]
|
|
args = ["rbd", "map", image]
|
|
for field_ in fields[1:]:
|
|
key, _, value = field_.partition("=")
|
|
if key == "id":
|
|
args += ["--id", value]
|
|
elif key == "keyring":
|
|
args += ["--keyring", value]
|
|
elif key == "conf":
|
|
args += ["--conf", value]
|
|
elif key == "mon_host":
|
|
args += ["-m", value.replace(";", ",")]
|
|
output = run(args, timeout=180)
|
|
for line in output.splitlines():
|
|
if line.strip().startswith("/dev/"):
|
|
return line.strip()
|
|
raise RecoveryError("rbd map lieferte keinen Geraetenamen: %s" % output)
|
|
|
|
|
|
def _place_readme(device, fstype):
|
|
"""Kurze Anleitung auf das leere Laufwerk legen."""
|
|
mountpoint = os.path.join(EXCHANGE_MOUNT, "format-%d" % os.getpid())
|
|
try:
|
|
os.makedirs(mountpoint, exist_ok=True)
|
|
run(["mount", "-t", fstype, device, mountpoint], timeout=120)
|
|
except (RecoveryError, OSError) as exc:
|
|
log.debug("Anleitung nicht ablegbar: %s", exc)
|
|
return
|
|
try:
|
|
# Ohne das gehoert das Wurzelverzeichnis root, und ein gewoehnlicher
|
|
# Benutzer im Gast kann nichts hineinschreiben - was sich anfuehlt wie
|
|
# ein schreibgeschuetzter Datentraeger, aber keiner ist.
|
|
try:
|
|
os.chmod(mountpoint, 0o777)
|
|
except OSError:
|
|
pass
|
|
with open(os.path.join(mountpoint, "LIESMICH.txt"), "w",
|
|
encoding="utf-8") as handle:
|
|
handle.write(README)
|
|
except OSError as exc:
|
|
log.debug("LIESMICH.txt nicht schreibbar: %s", exc)
|
|
finally:
|
|
run(["umount", mountpoint], check=False, timeout=120)
|
|
try:
|
|
os.rmdir(mountpoint)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Uebersicht, Verwerfen, Austausch abholen
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def list_instances(proxmox, refresh=True):
|
|
"""Bekannte Wiederherstellungen - Merkliste und Cluster zusammengefuehrt.
|
|
|
|
Der Cluster wird mitgelesen, damit auch etwas auftaucht, das nach einem
|
|
verlorenen Zustandsspeicher nur noch an seiner Markierung erkennbar ist.
|
|
"""
|
|
known = {i.vmid: i for i in _registry_load()}
|
|
try:
|
|
guests = proxmox.inventory(refresh=refresh)
|
|
except ProxmoxError as exc:
|
|
log.warning("Cluster nicht abfragbar: %s", exc)
|
|
guests = []
|
|
|
|
alive = set()
|
|
for guest in guests:
|
|
if TAG not in guest.tags:
|
|
continue
|
|
alive.add(guest.vmid)
|
|
instance = known.get(guest.vmid)
|
|
if instance is None:
|
|
instance = Instance(vmid=guest.vmid, type=guest.type, node=guest.node,
|
|
name=guest.name, mode="?", snapshot="?")
|
|
known[guest.vmid] = instance
|
|
instance.node = guest.node or instance.node
|
|
instance.name = guest.name or instance.name
|
|
instance.type = guest.type
|
|
instance.status = guest.status
|
|
|
|
for vmid, instance in known.items():
|
|
if vmid not in alive and guests:
|
|
instance.status = "weg"
|
|
|
|
return sorted(known.values(), key=lambda i: i.vmid)
|
|
|
|
|
|
def forget(vmid):
|
|
_registry_remove(vmid)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Liegengebliebenes finden
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def used_vmids():
|
|
"""Alle vergebenen VMIDs - aus den Konfigurationsdateien.
|
|
|
|
Bewusst nicht ueber /cluster/resources: ein Gast auf einem abgemeldeten
|
|
Node taucht dort unter Umstaenden nicht auf, und dann wuerden wir die
|
|
Platten einer lebenden Maschine fuer verwaist halten. /etc/pve ist
|
|
cluster-weit und kennt auch abgeschaltete Gaeste.
|
|
"""
|
|
used = set()
|
|
try:
|
|
nodes = os.listdir(CONF_ROOT)
|
|
except OSError as exc:
|
|
raise RecoveryError("%s nicht lesbar: %s" % (CONF_ROOT, exc))
|
|
for node in nodes:
|
|
for sub in ("qemu-server", "lxc"):
|
|
directory = os.path.join(CONF_ROOT, node, sub)
|
|
try:
|
|
names = os.listdir(directory)
|
|
except OSError:
|
|
continue
|
|
for name in names:
|
|
if name.endswith(".conf") and name[:-5].isdigit():
|
|
used.add(int(name[:-5]))
|
|
return used
|
|
|
|
|
|
_IMAGE_NAME = re.compile(r"^vm-(\d+)-(?:disk|state|cloudinit)")
|
|
|
|
|
|
def find_orphans(proxmox):
|
|
"""Datentraeger, zu denen es keinen Gast mehr gibt.
|
|
|
|
Entsteht, wenn eine Wiederherstellung ausserhalb von pvesnap entfernt wird -
|
|
etwa in der Proxmox-Oberflaeche. Der Gast ist dann weg, sein Klon bleibt
|
|
liegen, und der Quell-Snapshot bleibt geschuetzt und damit unloeschbar.
|
|
"""
|
|
used = used_vmids()
|
|
if not used:
|
|
raise RecoveryError(
|
|
"Unter %s steht keine einzige Gast-Konfiguration - das kann nicht "
|
|
"stimmen. Zur Sicherheit wird nichts angefasst." % CONF_ROOT)
|
|
|
|
found = []
|
|
for storage in proxmox._json(["get", "/storage"]) or []:
|
|
if storage.get("type") != "rbd":
|
|
continue
|
|
name = storage.get("storage")
|
|
context = _rbd_context(proxmox, "%s:dummy" % name)
|
|
if context is None:
|
|
continue
|
|
args, _ = context
|
|
pool = storage.get("pool") or "rbd"
|
|
namespace = storage.get("namespace")
|
|
prefix = "%s/%s" % (pool, (namespace + "/") if namespace else "")
|
|
|
|
for image in run(args + ["ls", pool.rstrip("/")], check=False,
|
|
timeout=120).splitlines():
|
|
image = image.strip()
|
|
match = _IMAGE_NAME.match(image)
|
|
if not match or int(match.group(1)) in used:
|
|
continue
|
|
try:
|
|
info = json.loads(run(args + ["info", prefix + image,
|
|
"--format", "json"], timeout=60))
|
|
except (RecoveryError, ValueError):
|
|
continue
|
|
parent = info.get("parent") or {}
|
|
found.append({
|
|
"volid": "%s:%s" % (name, image),
|
|
"vmid": int(match.group(1)),
|
|
"bytes": int(info.get("size") or 0),
|
|
"parent": ("%s/%s@%s" % (parent.get("pool"), parent.get("image"),
|
|
parent.get("snapshot")))
|
|
if parent else "",
|
|
"args": args,
|
|
"image": prefix + image,
|
|
})
|
|
return found
|
|
|
|
|
|
def remove_orphans(proxmox, orphans, progress=None):
|
|
"""Gefundene Reste entfernen und den Schutz ihrer Quell-Snapshots loesen."""
|
|
def step(text):
|
|
log.info("%s", text)
|
|
if progress:
|
|
progress(text)
|
|
|
|
removed, failed = [], []
|
|
for entry in orphans:
|
|
try:
|
|
step("Entferne %s" % entry["volid"])
|
|
free_volume(entry["volid"])
|
|
removed.append(entry["volid"])
|
|
except RecoveryError as exc:
|
|
failed.append("%s: %s" % (entry["volid"], exc))
|
|
continue
|
|
|
|
parent = entry.get("parent")
|
|
if not parent:
|
|
continue
|
|
args, image_snap = entry["args"], parent
|
|
base, _, snapname = image_snap.partition("@")
|
|
# Nur loesen, wenn wirklich kein Klon mehr daran haengt.
|
|
children = run(args + ["children", image_snap], check=False, timeout=120)
|
|
if children.strip():
|
|
step("%s hat noch andere Klone - Schutz bleibt" % parent)
|
|
continue
|
|
step("Hebe Schutz von %s auf" % parent)
|
|
run(args + ["snap", "unprotect", base, "--snap", snapname],
|
|
check=False, timeout=120)
|
|
return removed, failed
|
|
|
|
|
|
def destroy(proxmox, instance, progress=None, keep_snapshot_protection=False):
|
|
"""Eine Wiederherstellung restlos entfernen."""
|
|
def step(text):
|
|
log.info("%s", text)
|
|
if progress:
|
|
progress(text)
|
|
|
|
guest = instance.guest
|
|
exists = proxmox.guest_exists(instance.vmid)
|
|
|
|
if exists:
|
|
# Absicherung: nur anfassen, was unsere Markierung traegt.
|
|
config = proxmox.guest_config(guest)
|
|
tags = [t.strip().lower()
|
|
for t in str(config.get("tags") or "").replace(",", ";").split(";")]
|
|
if TAG not in tags:
|
|
raise RecoveryError(
|
|
"%s traegt die Markierung '%s' nicht - das ist keine von pvesnap "
|
|
"angelegte Wiederherstellung und wird nicht angefasst."
|
|
% (instance.label, TAG))
|
|
|
|
status = proxmox.guest_status(guest)
|
|
if status.get("status") == "running":
|
|
step("Schalte %s aus" % instance.label)
|
|
try:
|
|
proxmox.stop_guest(guest)
|
|
except ProxmoxError as exc:
|
|
raise RecoveryError("%s laesst sich nicht stoppen: %s"
|
|
% (instance.label, exc))
|
|
|
|
step("Entferne %s samt Klonen" % instance.label)
|
|
try:
|
|
proxmox.destroy_guest(guest)
|
|
except ProxmoxError as exc:
|
|
raise RecoveryError("%s laesst sich nicht entfernen: %s"
|
|
% (instance.label, exc))
|
|
|
|
# Proxmox raeumt alles ab, was in der Konfiguration steht. Ein noch
|
|
# nicht angestecktes Austauschlaufwerk steht dort aber nicht drin -
|
|
# deshalb hier noch einmal ueber alles gehen, was wir angelegt haben.
|
|
for volid in reversed(instance.volumes):
|
|
try:
|
|
free_volume(volid)
|
|
step("Entferne uebrig gebliebenen Klon %s" % volid)
|
|
except RecoveryError:
|
|
pass # war schon weg - der Normalfall
|
|
else:
|
|
# Der Gast ist weg, seine Klone koennen es trotzdem noch geben.
|
|
for volid in instance.volumes:
|
|
try:
|
|
step("Entferne uebrig gebliebenen Klon %s" % volid)
|
|
free_volume(volid)
|
|
except RecoveryError as exc:
|
|
log.warning("%s: %s", volid, exc)
|
|
|
|
if not keep_snapshot_protection:
|
|
for entry in instance.protected:
|
|
try:
|
|
volid, snapname = entry[0], entry[1]
|
|
except (IndexError, TypeError):
|
|
continue
|
|
step("Hebe Schutz von %s@%s auf" % (volid, snapname))
|
|
try:
|
|
unprotect_snapshot(proxmox, volid, snapname)
|
|
except RecoveryError as exc:
|
|
log.warning("Schutz von %s@%s blieb: %s", volid, snapname, exc)
|
|
|
|
for name in instance.transfers or []:
|
|
step("Gebe Transfer-Laufwerk %r wieder frei" % name)
|
|
try:
|
|
transfer.host_umount(name)
|
|
transfer.release(name)
|
|
except transfer.TransferError as exc:
|
|
log.warning("%s: %s", name, exc)
|
|
|
|
exchange = instance.exchange or {}
|
|
if exchange.get("kind") == "bind" and exchange.get("own") and exchange.get("path"):
|
|
step("Entferne Austauschverzeichnis %s" % exchange["path"])
|
|
shutil.rmtree(exchange["path"], ignore_errors=True)
|
|
|
|
_registry_remove(instance.vmid)
|
|
step("Fertig.")
|
|
|
|
|
|
def pull(proxmox, instance):
|
|
"""Austauschlaufwerk auf dem Host verfuegbar machen; liefert den Pfad."""
|
|
exchange = instance.exchange or {}
|
|
if not exchange:
|
|
raise RecoveryError("%s hat kein Austauschlaufwerk." % instance.label)
|
|
|
|
if exchange.get("kind") == "bind":
|
|
path = exchange.get("path") or ""
|
|
if not os.path.isdir(path):
|
|
raise RecoveryError("Austauschverzeichnis gibt es nicht: %s" % path)
|
|
return path
|
|
|
|
volid = exchange.get("volid")
|
|
if not volid:
|
|
raise RecoveryError("Zu %s ist kein Austauschlaufwerk vermerkt."
|
|
% instance.label)
|
|
|
|
status = proxmox.guest_status(instance.guest)
|
|
if status.get("status") == "running":
|
|
raise RecoveryError("%s laeuft noch. Erst herunterfahren - sonst schreiben "
|
|
"Gast und Host gleichzeitig auf dasselbe Dateisystem."
|
|
% instance.label)
|
|
|
|
mountpoint = os.path.join(EXCHANGE_MOUNT, str(instance.vmid))
|
|
if os.path.ismount(mountpoint):
|
|
return mountpoint
|
|
os.makedirs(mountpoint, exist_ok=True)
|
|
|
|
holder = _block_device(volid)
|
|
device = holder.__enter__()
|
|
try:
|
|
fstype = exchange.get("fs") or "auto"
|
|
run(["mount", "-o", "ro", "-t", fstype, device, mountpoint], timeout=120)
|
|
except RecoveryError:
|
|
holder.__exit__()
|
|
raise
|
|
_remember_mount(instance.vmid, mountpoint, device, holder.mapped)
|
|
return mountpoint
|
|
|
|
|
|
def release(vmid):
|
|
"""Ein mit pull() eingehaengtes Austauschlaufwerk wieder loesen."""
|
|
entry = _mounts().pop(str(vmid), None)
|
|
_save_mounts(_mounts_without(vmid))
|
|
if not entry:
|
|
return False
|
|
run(["umount", entry["mountpoint"]], check=False, timeout=120)
|
|
if entry.get("mapped"):
|
|
run(["rbd", "unmap", entry["device"]], check=False, timeout=120)
|
|
try:
|
|
os.rmdir(entry["mountpoint"])
|
|
except OSError:
|
|
pass
|
|
return True
|
|
|
|
|
|
_MOUNTS = os.path.join("/run/pvesnap", "recovery-mounts.json")
|
|
|
|
|
|
def _mounts():
|
|
try:
|
|
with open(_MOUNTS, "r", encoding="utf-8") as handle:
|
|
data = json.load(handle)
|
|
return data if isinstance(data, dict) else {}
|
|
except (OSError, ValueError):
|
|
return {}
|
|
|
|
|
|
def _mounts_without(vmid):
|
|
data = _mounts()
|
|
data.pop(str(vmid), None)
|
|
return data
|
|
|
|
|
|
def _save_mounts(data):
|
|
try:
|
|
os.makedirs(os.path.dirname(_MOUNTS), exist_ok=True)
|
|
with open(_MOUNTS, "w", encoding="utf-8") as handle:
|
|
json.dump(data, handle, indent=1)
|
|
except OSError as exc:
|
|
log.debug("Mount-Merkliste nicht schreibbar: %s", exc)
|
|
|
|
|
|
def _remember_mount(vmid, mountpoint, device, mapped):
|
|
data = _mounts()
|
|
data[str(vmid)] = {"mountpoint": mountpoint, "device": device, "mapped": mapped}
|
|
_save_mounts(data)
|