Das eingebaute Austauschlaufwerk faellt weg

Es tat dasselbe wie die Transfer-Laufwerke, nur schlechter: namenlos, nicht
vorher befuellbar, nicht wiederverwendbar, und es starb mit der Maschine. Zwei
Mechanismen fuer dieselbe Sache sind schlechter als einer.

Entfernt: --exchange, --exchange-storage, --exchange-fs, --exchange-dir, die
Unterbefehle pull und release, das Formatieren und Einhaengen auf dem Host
samt Mount-Merkliste. Was davon gebraucht wurde, steckt jetzt in transfer.py -
dort aber benannt, dauerhaft und im laufenden Betrieb wechselbar.

Aeltere Eintraege in der Merkliste stoeren nicht: ihr Austauschlaufwerk ist
ein gewoehnliches Volume der Maschine und wird beim Verwerfen ohnehin von
Proxmox mitgeloescht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
duffyduck
2026-08-03 11:52:35 +02:00
co-authored by Claude Opus 5
parent 0a92ec0984
commit f2de07ecfd
3 changed files with 82 additions and 525 deletions
+4 -307
View File
@@ -43,8 +43,6 @@ 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+$")
@@ -65,35 +63,6 @@ DROP_KEYS = {
"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."""
@@ -474,10 +443,6 @@ class Spec:
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
@@ -504,8 +469,6 @@ class Plan:
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
@@ -533,7 +496,6 @@ class Instance:
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 = ""
@@ -559,14 +521,14 @@ class Instance:
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",
"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", "exchange", "transfers",
"mode", "created", "volumes", "protected", "transfers",
"resumed") if data.get(k) is not None}
known["vmid"] = int(known.get("vmid") or 0)
return cls(**known)
@@ -770,37 +732,6 @@ def plan(proxmox, guest, snapname, spec):
"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
@@ -917,38 +848,6 @@ def create(proxmox, plan_, progress=None):
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:
@@ -996,7 +895,7 @@ def create(proxmox, plan_, progress=None):
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,
protected=protected, resumed=plan_.resume,
transfers=attached)
_registry_add(instance)
return instance
@@ -1103,29 +1002,6 @@ def _attach_pending(proxmox, instance, step=None):
entry["pending"] = False
_registry_add(instance)
exchange = instance.exchange or {}
if not exchange.get("pending"):
return " ".join(notes)
key, drive = exchange.get("key"), exchange.get("drive")
if not key or not drive:
return ""
if step:
step("Stecke Austauschlaufwerk als %s an" % key)
try:
proxmox.set_guest_config(instance.guest, {key: drive})
config = proxmox.guest_config(instance.guest)
except ProxmoxError as exc:
notes.append("Das Austauschlaufwerk liess sich nicht anstecken: %s" % exc)
return " ".join(notes)
if key not in config:
# Proxmox hat es nur vorgemerkt - im Gast taucht es dann nicht auf.
notes.append("Das Austauschlaufwerk ist eingetragen, aber nicht "
"angesteckt worden (Hotplug fuer Platten ist bei dieser "
"Maschine aus). Es erscheint erst nach einem Neustart.")
else:
exchange["pending"] = False
_registry_add(instance)
return " ".join(notes)
@@ -1312,93 +1188,8 @@ def _copy_state(volid, newid):
# 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
# Uebersicht und Verwerfen
# ---------------------------------------------------------------------------
def list_instances(proxmox, refresh=True):
@@ -1632,99 +1423,5 @@ def destroy(proxmox, instance, progress=None, keep_snapshot_protection=False):
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)