pvesnap-recovery: Snapshots als Maschine starten

Zwei Betriebsarten:

  live      abgeschottet ohne Netzwerk - zum Hineinschauen ueber die
            noVNC-Konsole, Dump ziehen, Daten ueber ein Austauschlaufwerk
            herausholen
  recovery  mit Netzwerk und gleicher Identitaet (SMBIOS-UUID, vmgenid,
            MAC-Adressen, Hostname) - warnt, wenn das Original noch laeuft

Die Datentraeger werden ueber PVE::Storage::vdisk_clone geklont, also mit
derselben Funktion und Cluster-Sperre, die Proxmox selbst verwendet. Auf
Ceph/ZFS/LVM-thin ist das Copy-on-Write: 32 GiB waren im Test in 0,8s
geklont, das Original bleibt unberuehrt.

Enthaelt der Snapshot den Arbeitsspeicher, wird er mitkopiert - die Maschine
laeuft dann genau dort weiter, wo sie stand, statt zu booten. Dafuer muss die
Geraeteausstattung exakt passen:

  - vmgenid und smbios1 bleiben erhalten (ohne vmgenid bricht das Laden mit
    "Unknown savevm section" ab)
  - runningmachine/runningcpu werden uebernommen
  - die Netzwerkkarte wird abgeklemmt statt entfernt
  - das Austauschlaufwerk kommt erst nach dem Fortsetzen per Hotplug dazu,
    sonst bemerkt der Gast es nie (nachgewiesen ueber "info virtio-status")

Proxmox meldet einen fehlgeschlagenen RAM-Ladevorgang als TASK OK und laesst
die Maschine angehalten stehen - deshalb wird das Task-Protokoll mitgelesen,
fortgesetzt und deutlich gemeldet, wenn stattdessen kalt gebootet wurde.

Austausch mit dem Host: bei VMs eine formatierte Zusatzplatte, bei
Containern ein durchgereichtes Verzeichnis - dort passt auch ein bereits
eingehaengtes Netzlaufwerk.

Verworfen wird restlos, samt Aufheben des rbd-Snapshot-Schutzes, den das
Klonen setzt - sonst koennte die Vorhaltezeit den Snapshot nie mehr loeschen.
Angefasst wird nur, was den Tag pvesnap-recovery traegt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
duffyduck
2026-07-31 11:38:38 +02:00
co-authored by Claude Opus 5
parent 8577b23b0c
commit 725fbf8732
6 changed files with 2486 additions and 6 deletions
+105 -4
View File
@@ -207,6 +207,103 @@ class Proxmox:
what = "Loeschen von %s bei %s" % (name, guest.label)
return self._retry(what, lambda: self._task(guest, args, what))
# -- Gaeste lesen und steuern -----------------------------------------
def guest_config(self, guest, snapshot=None):
"""Konfiguration eines Gastes - wahlweise die zu einem Snapshot."""
tail = ("snapshot/%s/config" % snapshot) if snapshot else "config"
data = self._json(["get", "/nodes/%s/%s/%d/%s"
% (guest.node, guest.type, guest.vmid, tail)])
if not isinstance(data, dict):
raise ProxmoxError("Konfiguration von %s nicht lesbar" % guest.label)
return data
def guest_status(self, guest):
try:
data = self._json(["get", "/nodes/%s/%s/%d/status/current"
% (guest.node, guest.type, guest.vmid)])
except ProxmoxError:
return {}
return data if isinstance(data, dict) else {}
def guest_exists(self, vmid):
return any(g.vmid == int(vmid) for g in self.inventory(refresh=True))
def next_vmid(self, wanted=None):
"""Freie VMID. Mit `wanted` wird geprueft, ob genau die frei ist."""
args = ["get", "/cluster/nextid"]
if wanted:
args += ["--vmid", str(int(wanted))]
return int(self._json(args))
def vmid_free(self, vmid):
try:
self.next_vmid(vmid)
return True
except (ProxmoxError, TypeError, ValueError):
return False
def start_guest(self, guest):
return self._guest_task(guest, "create", "status/start",
"Starten von %s" % guest.label)
def shutdown_guest(self, guest, timeout=180, force=True):
"""Sauberes Herunterfahren; `force` schaltet nach Ablauf hart ab."""
extra = ["--timeout", str(int(timeout))]
if force:
extra += ["--forceStop", "1"]
return self._guest_task(guest, "create", "status/shutdown",
"Herunterfahren von %s" % guest.label, extra,
timeout=timeout + 60)
def stop_guest(self, guest):
return self._guest_task(guest, "create", "status/stop",
"Ausschalten von %s" % guest.label)
def set_guest_config(self, guest, values):
"""Konfigurationswerte setzen. Laeuft der Gast, steckt Proxmox
hotplug-faehige Geraete dabei gleich an."""
args = ["set", "/nodes/%s/%s/%d/config" % (guest.node, guest.type, guest.vmid)]
for key, value in values.items():
args += ["--%s" % key, str(value)]
if self.dry_run:
log.info("[TESTLAUF] wuerde setzen: %s", " ".join(args))
return None
stdout, _ = self._run(args, timeout=self.task_timeout)
return self._wait_upid(guest.node, stdout, "Aendern von %s" % guest.label,
guest)
def resume_guest(self, guest):
return self._guest_task(guest, "create", "status/resume",
"Fortsetzen von %s" % guest.label)
def task_log(self, node, upid, limit=200):
"""Das Protokoll eines Tasks als Liste von Zeilen."""
try:
entries = self._json(["get", "/nodes/%s/tasks/%s/log" % (node, upid),
"--limit", str(int(limit))]) or []
except ProxmoxError:
return []
return [str(entry.get("t") or "") for entry in entries]
def destroy_guest(self, guest, purge=True):
extra = ["--purge", "1"] if purge else []
if guest.type == "qemu":
extra += ["--destroy-unreferenced-disks", "1"]
return self._guest_task(guest, "delete", "", "Entfernen von %s" % guest.label,
extra)
def _guest_task(self, guest, verb, sub, what, extra=None, timeout=None):
path = "/nodes/%s/%s/%d" % (guest.node, guest.type, guest.vmid)
if sub:
path += "/" + sub
args = [verb, path] + (extra or [])
if self.dry_run:
log.info("[TESTLAUF] wuerde ausfuehren: %s", " ".join(args))
return None
stdout, _ = self._run(args, timeout=timeout or self.task_timeout)
return self._wait_upid(guest.node, stdout, what, guest)
def _task(self, guest, args, what):
"""Fuehrt eine Snapshot-Aktion aus und wartet, bis sie wirklich fertig ist.
@@ -255,27 +352,31 @@ class Proxmox:
# -- Task-Verfolgung --------------------------------------------------
def _wait_task(self, guest, output, what):
"""pvesh liefert bei Snapshot-Aktionen eine UPID; darauf warten wir."""
return self._wait_upid(guest.node, output, what, guest)
def _wait_upid(self, node, output, what, guest=None):
"""pvesh liefert bei laengeren Aktionen eine UPID; darauf warten wir."""
upid = self._extract_upid(output)
if not upid:
# Ohne UPID koennen wir den Task nicht verfolgen - dann warten wir
# ersatzweise, bis der Gast nicht mehr gesperrt ist. Sonst wuerde
# die naechste Aktion in eine noch laufende hineinlaufen.
log.debug("%s: keine UPID in der Antwort von pvesh", what)
self._wait_unlocked(guest)
if guest is not None:
self._wait_unlocked(guest)
return None
deadline = time.time() + self.task_timeout
delay = 0.5
while True:
status = self._json(["get", "/nodes/%s/tasks/%s/status"
% (guest.node, upid)]) or {}
% (node, upid)]) or {}
if status.get("status") == "stopped":
exit_status = status.get("exitstatus") or "unbekannt"
if exit_status != "OK":
# Nur die Ursache melden - wer und was, ergaenzt der Aufrufer.
raise ProxmoxError("%s%s" % (exit_status,
self._task_log_tail(guest.node, upid)))
self._task_log_tail(node, upid)))
return upid
if time.time() > deadline:
raise ProxmoxError("%s: Zeitueberschreitung nach %ds (Task laeuft weiter: %s)"