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)"
+1207
View File
File diff suppressed because it is too large Load Diff
+953
View File
@@ -0,0 +1,953 @@
"""pvesnap-recovery - einen Snapshot als Maschine starten.
Ohne Argumente kommt die ncurses-Oberflaeche, alles geht aber auch direkt:
pvesnap-recovery Uebersicht und Assistent
pvesnap-recovery list was gerade laeuft
pvesnap-recovery live 802 neuesten Snapshot ohne Netz starten
pvesnap-recovery live 802 <snapshot> --exchange 20G
pvesnap-recovery recover 802 <snapshot> mit Netz, gleiche Identitaet
pvesnap-recovery console 9802 noVNC-Adresse anzeigen
pvesnap-recovery pull 9802 Austauschlaufwerk auf den Host holen
pvesnap-recovery destroy 9802 restlos verwerfen
"""
from __future__ import annotations
import argparse
import curses
import logging
import os
import sys
from datetime import datetime
from . import __version__
from .curses_util import (C_DIM, C_FOOTER, C_HEADER, C_MARK, C_OK, C_SEL, C_WARN,
choose, confirm, fill, init_colors, is_enter, is_escape,
keybar, message, prompt, put, read_key)
from .proxmox import Proxmox, ProxmoxError, pvesh_available
from .recovery import (EXCHANGE_GUEST_PATH, MKFS, RecoveryError, Spec, create,
destroy, human_bytes, list_instances, plan, pull, release,
start)
from .snapfs import list_snapshots
from .util import truncate
log = logging.getLogger("pvesnap.recovery_ui")
KEYS = [
("Enter", "Details", "Detail"),
("n", "Neu", "Neu"),
("s", "Starten", "Start"),
("h", "Herunterfahren", "Stop"),
("a", "Austausch", "Austau."),
("x", "Verwerfen", "Verwerf."),
("r", "Aktualisieren", "Aktual."),
("q", "Beenden", "Ende"),
]
NET_CHOICES = (
("", "automatisch (nach Modus)"),
("none", "keine Netzwerkkarte"),
("down", "Karte vorhanden, abgeklemmt"),
("on", "voll am Netz - Vorsicht!"),
)
RESUME_CHOICES = (
(None, "wenn im Snapshot vorhanden"),
(True, "ja, Arbeitsspeicher laden"),
(False, "nein, kalt starten"),
)
# ---------------------------------------------------------------------------
# Kleine Bausteine
# ---------------------------------------------------------------------------
def _wait_screen(win, text, title="pvesnap-recovery"):
height, width = win.getmaxyx()
win.erase()
fill(win, 0, " " + title, curses.color_pair(C_HEADER) | curses.A_BOLD)
put(win, height // 2, max(2, (width - len(text)) // 2), text, curses.A_BOLD)
win.refresh()
def _pages(win, lines, title, footer="beliebige Taste = weiter"):
"""Einen Textblock anzeigen, bei Bedarf seitenweise."""
height, width = win.getmaxyx()
rows = max(1, height - 4)
top = 0
while True:
win.erase()
fill(win, 0, " " + title, curses.color_pair(C_HEADER) | curses.A_BOLD)
for row, (text, attr) in enumerate(lines[top:top + rows]):
put(win, row + 2, 2, truncate(text, width - 4), attr)
more = " (%d-%d von %d)" % (top + 1, min(top + rows, len(lines)), len(lines)) \
if len(lines) > rows else ""
fill(win, height - 1, " " + footer + more, curses.color_pair(C_FOOTER))
win.refresh()
key = read_key(win)
if key == curses.KEY_DOWN and top + rows < len(lines):
top += 1
elif key == curses.KEY_UP and top > 0:
top -= 1
elif key == curses.KEY_NPAGE and top + rows < len(lines):
top = min(len(lines) - rows, top + rows)
elif key == curses.KEY_PPAGE:
top = max(0, top - rows)
else:
return key
def _plain(lines):
return [(text, 0) for text in lines]
class _Progress:
"""Fortschrittsanzeige waehrend des Anlegens."""
def __init__(self, win, title):
self.win = win
self.title = title
self.lines = []
def __call__(self, text):
self.lines.append(text)
height, width = self.win.getmaxyx()
self.win.erase()
fill(self.win, 0, " " + self.title, curses.color_pair(C_HEADER) | curses.A_BOLD)
visible = self.lines[-(height - 4):]
for row, line in enumerate(visible):
attr = curses.A_BOLD if line is visible[-1] else curses.color_pair(C_DIM)
put(self.win, row + 2, 2, truncate(line, width - 4), attr)
fill(self.win, height - 1, " bitte warten ...", curses.color_pair(C_FOOTER))
self.win.refresh()
# ---------------------------------------------------------------------------
# Auswahl
# ---------------------------------------------------------------------------
def _pick_guest(win, guests):
entries = [(g, "%s %-6d %-24s %-8s %s"
% ("LXC" if g.type == "lxc" else "VM ", g.vmid,
truncate(g.name or "-", 24), g.status, g.node))
for g in guests]
return choose(win, "Von welchem Gast? (Esc = zurueck)", entries)
def _pick_snapshot(win, snapshots):
entries = []
for snap in snapshots:
when = (datetime.fromtimestamp(snap.snaptime).strftime("%d.%m.%Y %H:%M")
if snap.snaptime else "?")
ram = "RAM" if "vmstate" in (snap.description or "") else ""
entries.append((snap.name, "%-34s %s %-4s %s"
% (truncate(snap.name, 34), when, ram,
truncate((snap.description or "").replace("\n", " "), 34))))
return choose(win, "Welcher Snapshot? (Esc = zurueck)", entries)
# ---------------------------------------------------------------------------
# Optionsmaske
# ---------------------------------------------------------------------------
def _fields(guest):
"""Die Eingabefelder - je nach Gasttyp etwas andere."""
rows = [
("mode", "Betriebsart", "choice",
(("live", "live - abgeschottet, ohne Netzwerk"),
("recovery", "recovery - mit Netz, gleiche Identitaet"))),
("net", "Netzwerk", "choice", NET_CHOICES),
]
if guest.type == "qemu":
rows.append(("resume", "Arbeitsspeicher", "choice", RESUME_CHOICES))
rows.append(("exchange", "Austauschlaufwerk", "text", ()))
rows.append(("exchange_fs", " Dateisystem", "choice",
tuple((f, f) for f in sorted(MKFS))))
else:
rows.append(("exchange", "Austauschverzeichnis", "choice",
(("", "keins"), ("bind", "ja, unter %s im Gast"
% EXCHANGE_GUEST_PATH))))
rows.append(("exchange_dir", " eigenes Host-Verzeichnis", "text", ()))
rows += [
("newid", "Neue VMID", "text", ()),
("node", "Node", "text", ()),
("start", "Danach starten", "choice", ((True, "ja"), (False, "nein"))),
]
return rows
def _field_text(spec, key, kind, choices):
value = getattr(spec, key)
if kind == "choice":
for candidate, label in choices:
if candidate == value:
return label
return str(value)
if key == "newid":
return str(value) if value else "automatisch (naechste freie)"
if key == "exchange":
return value or "keins"
if key == "node":
return value or "wie das Original"
if key == "exchange_dir":
return value or "(eigenes anlegen)"
return str(value or "")
def _options(win, guest, spec):
"""Maske fuer die Einstellungen. True = anlegen, False = abbrechen."""
rows = _fields(guest)
index = 0
while True:
height, width = win.getmaxyx()
win.erase()
fill(win, 0, " Wiederherstellung einrichten - %s" % guest.label,
curses.color_pair(C_HEADER) | curses.A_BOLD)
for row, (key, label, kind, choices) in enumerate(rows):
selected = row == index
attr = curses.color_pair(C_SEL) if selected else 0
text = " %-26s %s" % (label + ":", _field_text(spec, key, kind, choices))
fill(win, row + 2, text.ljust(width - 2), attr)
note = {
"mode": "live = zum Hineinschauen. recovery = die Maschine wirklich "
"wieder in Betrieb nehmen.",
"net": "Ohne Netz kommt nichts hinein und nichts hinaus - dafuer ist "
"das Austauschlaufwerk da.",
"resume": "Snapshots mit Arbeitsspeicher laufen genau dort weiter, wo "
"sie standen - ohne Crash-Recovery.",
"exchange": ("Groesse wie 20G. Leer = keins."
if guest.type == "qemu" else
"Ein Host-Verzeichnis, das im Container auftaucht."),
"exchange_fs": "Dateisystem des Austauschlaufwerks.",
"exchange_dir": "Leer = pvesnap legt selbst eines an. Hier passt auch "
"ein eingehaengtes Netzlaufwerk.",
"newid": "VMID der neuen Maschine.",
"node": "Auf welchem Node sie laufen soll.",
"start": "Sofort hochfahren.",
}.get(rows[index][0], "")
for offset, line in enumerate(_wrap(note, width - 6)[:3]):
put(win, len(rows) + 4 + offset, 3, line, curses.color_pair(C_DIM))
for offset, line in enumerate(keybar(
width, [("Pfeile", "Auswaehlen", "Wahl"),
("Enter/Leer", "Aendern", "Aendern"),
("F10", "Anlegen", "Anlegen"),
("q", "Abbrechen", "Abbr.")])):
fill(win, height - 2 + offset, " " + line, curses.color_pair(C_FOOTER))
win.refresh()
key = read_key(win)
current_key, _label, kind, choices = rows[index]
if key == curses.KEY_UP:
index = (index - 1) % len(rows)
elif key == curses.KEY_DOWN:
index = (index + 1) % len(rows)
elif key in (curses.KEY_F10,) or key == "\x06":
return True
elif is_escape(key) or key == "q":
return False
elif is_enter(key) or key == " " or key in (curses.KEY_RIGHT, curses.KEY_LEFT):
_edit(win, spec, current_key, kind, choices,
back=key == curses.KEY_LEFT)
def _wrap(text, width):
words, lines, current = str(text).split(), [], ""
for word in words:
if len(current) + len(word) + 1 > width:
lines.append(current)
current = word
else:
current = (current + " " + word).strip()
if current:
lines.append(current)
return lines
def _edit(win, spec, key, kind, choices, back=False):
if kind == "choice":
values = [c[0] for c in choices]
try:
position = values.index(getattr(spec, key))
except ValueError:
position = 0
position = (position - 1 if back else position + 1) % len(values)
setattr(spec, key, values[position])
if key == "mode":
# Die Betriebsart gibt die Vorgabe fuer das Netz vor.
spec.net = ""
return
label = {"exchange": "Groesse (z.B. 20G, leer = keins)",
"newid": "Neue VMID (leer = automatisch)",
"node": "Node (leer = wie das Original)",
"exchange_dir": "Host-Verzeichnis (leer = selbst anlegen)"}.get(key, key)
answer = prompt(win, label, str(getattr(spec, key) or ""))
if answer is None:
return
if key == "newid":
spec.newid = int(answer) if answer.isdigit() else 0
else:
setattr(spec, key, answer)
# ---------------------------------------------------------------------------
# Zusammenfassung
# ---------------------------------------------------------------------------
def summary_lines(plan_):
"""Was passieren wird - als Liste von (Text, Attribut)."""
guest, spec = plan_.guest, plan_.spec
lines = [
("Quelle: %s auf %s" % (guest.label, guest.node), 0),
("Snapshot: %s" % plan_.snapshot, 0),
("Neu: %s auf %s" % (plan_.label, plan_.node), curses.A_BOLD),
("Betriebsart: %s" % ("recovery - mit Netz, gleiche Identitaet"
if spec.mode == "recovery"
else "live - abgeschottet"), 0),
("", 0),
("Datentraeger (Copy-on-Write-Klone, das Original bleibt unberuehrt):", 0),
]
for key, volid, size in plan_.disks:
lines.append((" %-9s %s%s" % (key, volid, (" %s" % size) if size else ""), 0))
lines.append(("", 0))
netzwerk = {"none": "keine Netzwerkkarte",
"down": "Karte vorhanden, Leitung abgeklemmt",
"on": "AM NETZ - mit den MAC-Adressen des Originals"}
lines.append(("Netzwerk: %s" % netzwerk.get(plan_.net_mode, plan_.net_mode),
curses.color_pair(C_WARN) if plan_.net_mode == "on" else 0))
if plan_.nets:
for key, value in plan_.nets:
lines.append((" %-9s %s" % (key, truncate(value, 60)), 0))
if plan_.vmstate:
lines.append(("Arbeitsspeicher: %s"
% ("wird geladen (%s) - laeuft weiter statt zu booten"
% (human_bytes(plan_.vmstate_bytes) or "?")
if plan_.resume else "vorhanden, wird aber nicht geladen"),
curses.color_pair(C_OK) if plan_.resume else 0))
if plan_.exchange_kind:
lines.append(("Austausch: %s" % plan_.exchange_detail, 0))
if plan_.notes:
lines.append(("", 0))
for note in plan_.notes:
for line in _wrap("- " + note, 74):
lines.append((line, curses.color_pair(C_DIM)))
if plan_.warnings:
lines.append(("", 0))
for warning in plan_.warnings:
for line in _wrap("!! " + warning, 74):
lines.append((line, curses.color_pair(C_WARN) | curses.A_BOLD))
return lines
# ---------------------------------------------------------------------------
# Assistent
# ---------------------------------------------------------------------------
def _new(stdscr, proxmox):
_wait_screen(stdscr, "Frage Proxmox nach VMs und Containern ...")
try:
guests = proxmox.inventory(refresh=True)
except ProxmoxError as exc:
message(stdscr, str(exc), error=True)
return
while True:
guest = _pick_guest(stdscr, guests)
if guest is None:
return
_wait_screen(stdscr, "Lese Snapshots von %s ..." % guest.label)
try:
snapshots = [s for s in list_snapshots(proxmox, guest) if s.complete]
except ProxmoxError as exc:
message(stdscr, str(exc), error=True)
continue
if not snapshots:
message(stdscr, "%s hat keine brauchbaren Snapshots." % guest.label,
error=True)
continue
snapname = _pick_snapshot(stdscr, snapshots)
if snapname is None:
continue
spec = Spec(exchange_dir="")
while True:
if not _options(stdscr, guest, spec):
break
_wait_screen(stdscr, "Pruefe, was noetig ist ...")
try:
plan_ = plan(proxmox, guest, snapname, spec)
except (RecoveryError, ProxmoxError) as exc:
_pages(stdscr, [(line, curses.color_pair(C_WARN))
for line in _wrap(str(exc), 74)],
"Geht so nicht")
continue
key = _pages(stdscr, summary_lines(plan_),
"Zusammenfassung - noch ist nichts passiert",
"j = anlegen | beliebige andere Taste = zurueck")
if key not in ("j", "J", "y", "Y"):
continue
if plan_.warnings and not confirm(
stdscr, "Warnungen gelesen und trotzdem anlegen?"):
continue
progress = _Progress(stdscr, "Richte %s ein" % plan_.label)
try:
instance = create(proxmox, plan_, progress=progress)
except (RecoveryError, ProxmoxError, OSError) as exc:
_pages(stdscr, [(line, curses.color_pair(C_WARN))
for line in _wrap(str(exc), 74)]
+ [("", 0)] + _plain(progress.lines),
"Fehlgeschlagen")
break
if spec.start:
progress("Starte %s ..." % instance.label)
try:
hint = start(proxmox, instance, progress=progress)
except (ProxmoxError, RecoveryError) as exc:
message(stdscr, "Angelegt, aber nicht gestartet: %s" % exc,
error=True)
else:
if hint:
_pages(stdscr, [(line, curses.color_pair(C_WARN))
for line in _wrap(hint, 74)],
"Angelegt und gestartet - mit einem Hinweis")
_details(stdscr, proxmox, instance)
return
# ---------------------------------------------------------------------------
# Detailansicht
# ---------------------------------------------------------------------------
def _details(stdscr, proxmox, instance):
while True:
status = proxmox.guest_status(instance.guest)
running = status.get("status") == "running"
lines = [
("Maschine: %s auf %s" % (instance.label, instance.node), curses.A_BOLD),
("Zustand: %s" % (status.get("status") or "unbekannt"),
curses.color_pair(C_OK if running else C_DIM)),
("Herkunft: %s" % instance.origin, 0),
("Betriebsart: %s" % instance.mode, 0),
("Angelegt: %s" % (datetime.fromtimestamp(instance.created)
.strftime("%d.%m.%Y %H:%M")
if instance.created else "?"), 0),
("", 0),
("Konsole im Browser:", 0),
]
for line in _wrap(instance.console_url(), 74):
lines.append((" " + line, curses.color_pair(C_MARK)))
if instance.type == "lxc":
lines.append((" oder auf dem Host: pct enter %d" % instance.vmid,
curses.color_pair(C_MARK)))
exchange = instance.exchange or {}
if exchange:
lines.append(("", 0))
if exchange.get("kind") == "bind":
lines.append(("Austausch: %s" % exchange.get("path"), 0))
lines.append((" im Gast unter %s" % EXCHANGE_GUEST_PATH, 0))
else:
lines.append(("Austausch: %s (%s), im Gast als weitere Platte"
% (exchange.get("volid"), exchange.get("fs")), 0))
lines.append((" nach dem Herunterfahren mit 'a' "
"auf den Host holen", curses.color_pair(C_DIM)))
if instance.resumed:
lines.append(("", 0))
lines.append(("Der Arbeitsspeicher des Snapshots wurde geladen - beim "
"ersten Start lief die Maschine genau weiter.",
curses.color_pair(C_DIM)))
footer = ("s Starten | h Herunterfahren | a Austausch | x Verwerfen | "
"q zurueck")
key = _pages(stdscr, lines, "Wiederherstellung %s" % instance.label, footer)
if key in ("q", "\x1b") or is_escape(key):
return
if key in ("s", "S"):
_do(stdscr, "Starte %s ..." % instance.label,
lambda: start(proxmox, instance))
elif key in ("h", "H"):
_do(stdscr, "Fahre %s herunter ..." % instance.label,
lambda: proxmox.shutdown_guest(instance.guest))
elif key in ("a", "A"):
_pull(stdscr, proxmox, instance)
elif key in ("x", "X"):
if _destroy(stdscr, proxmox, instance):
return
def _do(stdscr, text, action):
_wait_screen(stdscr, text)
try:
hint = action()
except (ProxmoxError, RecoveryError) as exc:
message(stdscr, str(exc), error=True)
return
if isinstance(hint, str) and hint:
message(stdscr, hint, error=True)
def _pull(stdscr, proxmox, instance):
_wait_screen(stdscr, "Hole das Austauschlaufwerk auf den Host ...")
try:
path = pull(proxmox, instance)
except (RecoveryError, ProxmoxError, OSError) as exc:
message(stdscr, str(exc), error=True)
return
_pages(stdscr, _plain([
"Das Austauschlaufwerk liegt jetzt hier:",
"",
" " + path,
"",
"Von dort kopieren, zum Beispiel mit:",
" pvesnap-explorer --path %s" % path,
" scp -r %s/. ziel:/pfad/" % path,
"",
"Wieder loesen mit: pvesnap-recovery release %d" % instance.vmid,
]), "Austauschlaufwerk")
def _destroy(stdscr, proxmox, instance):
if not confirm(stdscr, "%s wirklich verwerfen? Alle Aenderungen darin sind weg."
% instance.label):
return False
progress = _Progress(stdscr, "Verwerfe %s" % instance.label)
try:
destroy(proxmox, instance, progress=progress)
except (RecoveryError, ProxmoxError) as exc:
_pages(stdscr, [(line, curses.color_pair(C_WARN))
for line in _wrap(str(exc), 74)] + [("", 0)]
+ _plain(progress.lines), "Fehlgeschlagen")
return False
return True
# ---------------------------------------------------------------------------
# Uebersicht
# ---------------------------------------------------------------------------
def _overview(stdscr, proxmox):
index = 0
instances = []
reload_ = True
while True:
if reload_:
_wait_screen(stdscr, "Frage nach vorhandenen Wiederherstellungen ...")
instances = list_instances(proxmox)
reload_ = False
index = min(index, max(0, len(instances) - 1))
height, width = stdscr.getmaxyx()
stdscr.erase()
fill(stdscr, 0, " pvesnap-recovery %s - Snapshots als Maschine starten"
% __version__, curses.color_pair(C_HEADER) | curses.A_BOLD)
if not instances:
put(stdscr, 3, 3, "Zurzeit laeuft keine Wiederherstellung.", curses.A_BOLD)
put(stdscr, 5, 3, "Mit n eine neue anlegen:", 0)
put(stdscr, 6, 5, "live Snapshot abgeschottet starten, ohne Netzwerk -",
curses.color_pair(C_DIM))
put(stdscr, 7, 5, " zum Hineinschauen und Daten herausholen.",
curses.color_pair(C_DIM))
put(stdscr, 8, 5, "recovery Snapshot mit Netzwerk und gleicher Identitaet",
curses.color_pair(C_DIM))
put(stdscr, 9, 5, " wieder in Betrieb nehmen.",
curses.color_pair(C_DIM))
else:
put(stdscr, 2, 2, "%-10s %-9s %-22s %-28s %s"
% ("Maschine", "Zustand", "Node", "Herkunft", "Modus"),
curses.A_BOLD | curses.A_UNDERLINE)
rows = max(1, height - 6)
top = max(0, min(index - rows + 1, len(instances) - rows))
for row in range(min(rows, len(instances) - top)):
instance = instances[top + row]
attr = curses.color_pair(C_SEL) if top + row == index else 0
if not attr and instance.status == "running":
attr = curses.color_pair(C_OK)
text = " %-10s %-9s %-22s %-28s %s" % (
instance.label, instance.status or "?",
truncate(instance.node or "?", 22),
truncate(instance.origin, 28), instance.mode)
fill(stdscr, row + 3, text, attr)
for offset, line in enumerate(keybar(width, KEYS)):
fill(stdscr, height - 2 + offset, " " + line, curses.color_pair(C_FOOTER))
stdscr.refresh()
key = read_key(stdscr)
current = instances[index] if instances else None
if key in ("q", "Q") or is_escape(key):
return 0
if key in ("n", "N"):
_new(stdscr, proxmox)
reload_ = True
elif key in ("r", "R"):
reload_ = True
elif key == curses.KEY_UP and instances:
index = (index - 1) % len(instances)
elif key == curses.KEY_DOWN and instances:
index = (index + 1) % len(instances)
elif current is None:
continue
elif is_enter(key):
_details(stdscr, proxmox, current)
reload_ = True
elif key in ("s", "S"):
_do(stdscr, "Starte %s ..." % current.label,
lambda: start(proxmox, current))
reload_ = True
elif key in ("h", "H"):
_do(stdscr, "Fahre %s herunter ..." % current.label,
lambda: proxmox.shutdown_guest(current.guest))
reload_ = True
elif key in ("a", "A"):
_pull(stdscr, proxmox, current)
elif key in ("x", "X"):
_destroy(stdscr, proxmox, current)
reload_ = True
def _main(stdscr, _args):
curses.curs_set(0)
if hasattr(curses, "set_escdelay"):
curses.set_escdelay(25)
init_colors()
stdscr.keypad(True)
return _overview(stdscr, Proxmox())
# ---------------------------------------------------------------------------
# Befehlszeile
# ---------------------------------------------------------------------------
def _find_guest(proxmox, vmid):
guest = next((g for g in proxmox.inventory() if g.vmid == int(vmid)), None)
if guest is None:
raise RecoveryError("Gast %s gibt es nicht." % vmid)
return guest
def _find_instance(proxmox, vmid):
instance = next((i for i in list_instances(proxmox) if i.vmid == int(vmid)), None)
if instance is None:
raise RecoveryError("%s ist keine von pvesnap angelegte Wiederherstellung. "
"Vorhandene zeigt: pvesnap-recovery list" % vmid)
return instance
def _newest_snapshot(proxmox, guest):
snapshots = [s for s in list_snapshots(proxmox, guest) if s.complete]
if not snapshots:
raise RecoveryError("%s hat keine brauchbaren Snapshots." % guest.label)
return snapshots[0].name
def _spec_from_args(args, mode):
spec = Spec(mode=mode)
spec.newid = args.newid or 0
spec.node = args.node or ""
spec.net = args.net or ""
spec.resume = args.resume
spec.exchange = args.exchange or ""
spec.exchange_storage = args.exchange_storage or ""
spec.exchange_fs = args.exchange_fs
spec.exchange_dir = args.exchange_dir or ""
spec.memory = args.memory or 0
spec.cores = args.cores or 0
spec.name = args.name or ""
spec.keep_binds = args.keep_binds
spec.start = not args.no_start
if spec.exchange_dir and not spec.exchange:
spec.exchange = "bind"
return spec
def _print_summary(plan_):
print(" Quelle : %s auf %s" % (plan_.guest.label, plan_.guest.node))
print(" Snapshot : %s" % plan_.snapshot)
print(" Neu : %s auf %s" % (plan_.label, plan_.node))
for key, volid, size in plan_.disks:
print(" Klon : %-9s %s%s" % (key, volid, (" %s" % size) if size else ""))
netzwerk = {"none": "keine Netzwerkkarte",
"down": "Karte vorhanden, Leitung abgeklemmt",
"on": "AM NETZ mit den MAC-Adressen des Originals"}
print(" Netzwerk : %s" % netzwerk.get(plan_.net_mode, plan_.net_mode))
if plan_.resume:
print(" Arbeitsspei.: wird geladen (%s)"
% (human_bytes(plan_.vmstate_bytes) or "?"))
if plan_.exchange_kind:
print(" Austausch : %s" % plan_.exchange_detail)
for note in plan_.notes:
print(" Hinweis : %s" % note)
for warning in plan_.warnings:
print(" ACHTUNG : %s" % warning)
def cmd_create(proxmox, args, mode):
guest = _find_guest(proxmox, args.vmid)
snapname = args.snapshot or _newest_snapshot(proxmox, guest)
spec = _spec_from_args(args, mode)
plan_ = plan(proxmox, guest, snapname, spec)
print("Vorhaben:")
_print_summary(plan_)
if not args.yes:
if plan_.warnings:
print("\nEs gibt Warnungen - bitte oben lesen.")
answer = input("\nAnlegen? [j/N] ").strip().lower()
if answer not in ("j", "y", "ja", "yes"):
print("Abgebrochen.")
return 1
instance = create(proxmox, plan_, progress=lambda text: print(" " + text))
print("\n%s ist eingerichtet." % instance.label)
if spec.start:
print(" Starte ...")
hint = start(proxmox, instance, progress=lambda text: print(" " + text))
print(" laeuft." if not hint else " ACHTUNG: %s" % hint)
_print_access(instance)
return 0
def _print_access(instance):
print("\n Konsole im Browser:")
print(" %s" % instance.console_url())
if instance.type == "lxc":
print(" oder auf dem Host: pct enter %d" % instance.vmid)
exchange = instance.exchange or {}
if exchange.get("kind") == "bind":
print("\n Austausch: %s (im Gast unter %s)"
% (exchange.get("path"), EXCHANGE_GUEST_PATH))
elif exchange.get("volid"):
print("\n Austausch: als weitere Platte im Gast (%s, Bezeichnung PVESNAP)"
% exchange.get("fs"))
print(" im Gast einhaengen, befuellen, Gast herunterfahren, dann:")
print(" pvesnap-recovery pull %d" % instance.vmid)
print("\n Wieder verwerfen: pvesnap-recovery destroy %d" % instance.vmid)
def cmd_list(proxmox, _args):
instances = list_instances(proxmox)
if not instances:
print("Zurzeit laeuft keine Wiederherstellung.")
return 0
print("%-10s %-9s %-14s %-30s %s"
% ("Maschine", "Zustand", "Node", "Herkunft", "Modus"))
for instance in instances:
print("%-10s %-9s %-14s %-30s %s"
% (instance.label, instance.status or "?", instance.node or "?",
instance.origin, instance.mode))
return 0
def cmd_destroy(proxmox, args):
instance = _find_instance(proxmox, args.vmid)
if not args.yes:
print("%s (%s) wird restlos entfernt - alle Aenderungen darin sind weg."
% (instance.label, instance.origin))
if input("Wirklich? [j/N] ").strip().lower() not in ("j", "y", "ja", "yes"):
print("Abgebrochen.")
return 1
destroy(proxmox, instance, progress=lambda text: print(" " + text))
return 0
def cmd_pull(proxmox, args):
instance = _find_instance(proxmox, args.vmid)
path = pull(proxmox, instance)
print(path)
if not args.quiet:
print("\nDas Austauschlaufwerk liegt jetzt auf dem Host.", file=sys.stderr)
print(" pvesnap-explorer --path %s" % path, file=sys.stderr)
print(" Wieder loesen: pvesnap-recovery release %d" % instance.vmid,
file=sys.stderr)
return 0
def cmd_release(_proxmox, args):
print("geloest." if release(args.vmid) else "War nicht eingehaengt.")
return 0
def cmd_console(proxmox, args):
instance = _find_instance(proxmox, args.vmid)
print(instance.console_url())
if instance.type == "lxc":
print("pct enter %d" % instance.vmid, file=sys.stderr)
return 0
def cmd_start(proxmox, args):
hint = start(proxmox, _find_instance(proxmox, args.vmid),
progress=lambda text: print(" " + text))
print("laeuft." if not hint else "ACHTUNG: %s" % hint)
return 0
def cmd_stop(proxmox, args):
proxmox.shutdown_guest(_find_instance(proxmox, args.vmid).guest)
print("heruntergefahren.")
return 0
def _add_create_options(parser):
parser.add_argument("vmid", type=int, help="VMID des Originals")
parser.add_argument("snapshot", nargs="?",
help="Name des Snapshots (Vorgabe: der neueste)")
parser.add_argument("--newid", type=int, help="VMID der neuen Maschine")
parser.add_argument("--node", help="Node, auf dem sie laufen soll")
parser.add_argument("--net", choices=("none", "down", "on"),
help="none = keine Karte, down = Karte ohne Leitung, "
"on = voll am Netz")
parser.add_argument("--resume", dest="resume", action="store_true", default=None,
help="Arbeitsspeicher des Snapshots laden")
parser.add_argument("--no-resume", dest="resume", action="store_false",
help="kalt starten, auch wenn der Snapshot RAM enthaelt")
parser.add_argument("--exchange", metavar="GROESSE",
help="Austauschlaufwerk anlegen, z.B. 20G "
"(bei Containern genuegt --exchange ja)")
parser.add_argument("--exchange-storage", metavar="STORAGE",
help="Storage dafuer (Vorgabe: wie die erste Platte)")
parser.add_argument("--exchange-fs", default="ext4", choices=sorted(MKFS),
help="Dateisystem des Austauschlaufwerks (Vorgabe: ext4)")
parser.add_argument("--exchange-dir", metavar="PFAD",
help="nur Container: dieses Host-Verzeichnis durchreichen "
"(auch ein eingehaengtes Netzlaufwerk)")
parser.add_argument("--memory", type=int, help="Arbeitsspeicher in MB abweichend")
parser.add_argument("--cores", type=int, help="Kerne abweichend")
parser.add_argument("--name", help="Name der neuen Maschine")
parser.add_argument("--keep-binds", action="store_true",
help="durchgereichte Host-Verzeichnisse des Originals "
"uebernehmen (Vorgabe: weglassen)")
parser.add_argument("--no-start", action="store_true",
help="nur einrichten, nicht starten")
parser.add_argument("-y", "--yes", action="store_true", help="nicht nachfragen")
def build_parser():
parser = argparse.ArgumentParser(
prog="pvesnap-recovery",
formatter_class=argparse.RawDescriptionHelpFormatter,
description="Einen Proxmox-Snapshot als eigenstaendige Maschine starten.",
epilog="""Beispiele:
pvesnap-recovery
ncurses-Oberflaeche mit Uebersicht und Assistent
pvesnap-recovery live 802 --exchange 20G
neuesten Snapshot von VM 802 ohne Netzwerk starten, mit einem 20-GB-
Austauschlaufwerk fuer den Datenbank-Dump
pvesnap-recovery live 100 --exchange ja
Container 100 abgeschottet starten; ein Host-Verzeichnis erscheint
im Container unter %s
pvesnap-recovery live 100 --exchange-dir /mnt/nfs/dumps
dasselbe, aber mit einem eigenen (auch eingehaengten) Verzeichnis
pvesnap-recovery recover 802 autosnap-... --newid 9802
mit Netzwerk und gleicher Identitaet - nur wenn das Original steht!
pvesnap-recovery pull 9802
Austauschlaufwerk nach dem Herunterfahren auf den Host holen
pvesnap-recovery destroy 9802
restlos verwerfen: Maschine weg, Klone weg, Schutz der Snapshots geloest
""" % EXCHANGE_GUEST_PATH)
parser.add_argument("-v", "--verbose", action="store_true")
parser.add_argument("-V", "--version", action="version",
version="pvesnap-recovery %s" % __version__)
subs = parser.add_subparsers(dest="command", metavar="BEFEHL")
live = subs.add_parser("live", help="Snapshot abgeschottet starten (ohne Netz)")
_add_create_options(live)
recover = subs.add_parser("recover",
help="Snapshot mit Netz und gleicher Identitaet starten")
_add_create_options(recover)
subs.add_parser("list", help="vorhandene Wiederherstellungen zeigen")
for name, helptext in (("start", "hochfahren"), ("stop", "herunterfahren"),
("console", "noVNC-Adresse zeigen")):
sub = subs.add_parser(name, help=helptext)
sub.add_argument("vmid", type=int)
pull_ = subs.add_parser("pull", help="Austauschlaufwerk auf den Host holen")
pull_.add_argument("vmid", type=int)
pull_.add_argument("-q", "--quiet", action="store_true",
help="nur den Pfad ausgeben")
release_ = subs.add_parser("release", help="Austauschlaufwerk wieder loesen")
release_.add_argument("vmid", type=int)
destroy_ = subs.add_parser("destroy", help="Wiederherstellung restlos verwerfen")
destroy_.add_argument("vmid", type=int)
destroy_.add_argument("-y", "--yes", action="store_true", help="nicht nachfragen")
return parser
def main(argv=None):
args = build_parser().parse_args(argv)
logging.basicConfig(level=logging.DEBUG if args.verbose else logging.WARNING,
format="%(levelname)s %(message)s")
if not pvesh_available():
print("'pvesh' nicht gefunden - pvesnap-recovery laeuft auf dem "
"Proxmox-VE-Host.", file=sys.stderr)
return 3
if os.geteuid() != 0:
print("pvesnap-recovery braucht root-Rechte (Klone anlegen, Maschinen "
"einrichten).", file=sys.stderr)
return 4
proxmox = Proxmox()
handlers = {
"live": lambda: cmd_create(proxmox, args, "live"),
"recover": lambda: cmd_create(proxmox, args, "recovery"),
"list": lambda: cmd_list(proxmox, args),
"destroy": lambda: cmd_destroy(proxmox, args),
"pull": lambda: cmd_pull(proxmox, args),
"release": lambda: cmd_release(proxmox, args),
"console": lambda: cmd_console(proxmox, args),
"start": lambda: cmd_start(proxmox, args),
"stop": lambda: cmd_stop(proxmox, args),
}
try:
if args.command in handlers:
return handlers[args.command]()
except (RecoveryError, ProxmoxError) as exc:
print("Fehler: %s" % exc, file=sys.stderr)
return 1
except KeyboardInterrupt:
print("\nAbgebrochen.", file=sys.stderr)
return 130
# Ohne Befehl: Oberflaeche. Dabei darf nichts ins Terminal protokolliert werden.
logging_handlers = logging.getLogger().handlers[:]
if not args.verbose:
logging.getLogger().handlers = []
try:
return curses.wrapper(_main, args)
except KeyboardInterrupt:
return 130
finally:
logging.getLogger().handlers = logging_handlers
if __name__ == "__main__": # pragma: no cover
sys.exit(main())