Files
proxmox-snapshot-service/pvesnap/recovery_ui.py
T
duffyduckandClaude Opus 5 1f668568d1 Verweise mit IP statt Hostnamen
Auf dem Proxmox-Host loest der Node-Name auf, am Arbeitsplatz meist nicht -
und dann ist der noVNC-Verweis wertlos. Die Adresse kommt jetzt aus
/etc/pve/.members (lokale Datei, kein API-Aufruf); ist dort nichts zu finden,
bleibt es beim Namen.

    vorher   https://pvetest01:8006/?console=kvm&novnc=1&vmid=102&node=pvetest01
    nachher  https://172.0.4.1:8006/?console=kvm&novnc=1&vmid=102&node=pvetest01

Der Node bleibt als Name im Verweis stehen - so erwartet Proxmox ihn.

Dasselbe betraf zwei weitere Stellen: den scp-Hinweis beim SPICE-Befehl und
den proxy-Eintrag in der .vv-Datei. Letzterer trug ebenfalls den Hostnamen,
womit auch remote-viewer nicht durchgekommen waere. Die TLS-Pruefung laeuft
ueber "host-subject" und bleibt davon unberuehrt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 19:25:42 +02:00

1121 lines
45 KiB
Python

"""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> --transfer dumps
pvesnap-recovery recover 802 <snapshot> mit Netz, gleiche Identitaet
pvesnap-recovery console 9802 noVNC-Adresse anzeigen
pvesnap-recovery Taste v: Transfer-Laufwerke
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 . import transfer, transfer_ui
from .recovery import (RecoveryError, Spec, create, destroy, eject_transfer,
find_orphans, flatten, flatten_cost, flatten_hint,
human_bytes, insert_transfer, linked_volumes,
list_instances, node_address, plan, remove_orphans,
spice_file, start, transfer_state)
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"),
("x", "Verwerfen", "Verwerf."),
("v", "Laufwerke", "Laufw."),
("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(("transfers", "Transfer-Laufwerke", "transfers", ()))
if guest.type == "qemu":
rows.append(("spice", "SPICE-Anzeige", "choice",
((False, "nein - nur noVNC im Browser"),
(True, "ja - zusaetzlich remote-viewer"))))
rows.append(("usb", "USB-Weiterleitung", "choice",
((0, "keine"), (1, "1 Anschluss"), (2, "2 Anschluesse"),
(4, "4 Anschluesse"))))
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 == "transfers":
return transfer_ui.summary(value)
if key == "node":
return value or "wie das Original"
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.",
"transfers": "Vorbereitete Austauschmedien. Mit v anlegen und "
"befuellen, mit Enter auswaehlen.",
"spice": "Zweiter Zugang ueber remote-viewer. Achtung: vertraegt "
"sich nicht mit geladenem Arbeitsspeicher.",
"usb": "Ein USB-Geraet vom eigenen Rechner in den Gast reichen - "
"etwa einen Dongle. Laeuft ueber SPICE, nicht ueber das Netz.",
"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"),
("v", "Laufwerke verwalten", "Laufw."),
("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 in ("v", "V"):
# Von hier aus verwalten statt zwei Ebenen zurueckzugehen: wer
# merkt, dass ihm ein Laufwerk fehlt, merkt es genau an dieser
# Stelle. Danach steht der Balken auf der Auswahlzeile.
transfer_ui.screen(win, browse=_browse_transfer)
index = next((i for i, r in enumerate(rows) if r[2] == "transfers"),
index)
elif 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):
if kind == "transfers":
spec.transfers = transfer_ui.choose(win, spec.transfers)
else:
_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 = {"newid": "Neue VMID (leer = automatisch)",
"node": "Node (leer = wie das Original)"}.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_.spice:
lines.append(("Anzeige: noVNC und SPICE (vga: qxl)",
curses.color_pair(C_OK)))
if plan_.usb:
lines.append(("USB: %d Weiterleitung(en) ueber SPICE" % plan_.usb,
curses.color_pair(C_OK)))
if plan_.iso:
lines.append(("CD: %s" % plan_.iso, 0))
for volume in plan_.transfers:
lines.append(("Transfer: %-14s %8s im Gast als %s"
% (volume.name, human_bytes(volume.size), volume.label),
curses.color_pair(C_OK)))
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)))
for entry in plan_.warnings + plan_.critical:
lines.append(("", 0))
for line in _wrap("!! " + entry, 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()
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_.critical:
# Beim Namen nennen, worum es geht - "Warnungen gelesen?"
# klickt man weg, ohne hinzusehen.
if not confirm(stdscr, truncate(plan_.critical[0], 60)
+ " Trotzdem?"):
continue
elif 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)))
else:
lines.append((" SPICE (remote-viewer): pvesnap-recovery spice %d"
% instance.vmid, curses.color_pair(C_MARK)))
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)))
steckplaetze = transfer_state(proxmox, instance)
if steckplaetze:
lines.append(("", 0))
lines.append(("Transfer-Laufwerke:", 0))
for entry, drin in steckplaetze:
if entry.get("kind") == "bind":
zustand = "durchgereicht (Host und Gast gleichzeitig)"
farbe = C_OK
else:
zustand = ("eingesteckt als %s" % entry["key"]) if drin \
else "ausgeworfen - gehoert dem Host"
farbe = C_OK if drin else C_MARK
lines.append((" %-14s %s" % (entry["name"], zustand),
curses.color_pair(farbe)))
if any(e.get("kind") != "bind" for e, _ in steckplaetze):
lines.append((" e = auswerfen / einklinken, im laufenden "
"Betrieb", curses.color_pair(C_DIM)))
try:
linked = linked_volumes(proxmox, instance)
except (RecoveryError, ProxmoxError):
linked = []
lines.append(("", 0))
if linked:
lines.append(("Datentraeger: Linked Clone - haengt am Quell-Snapshot",
curses.color_pair(C_MARK)))
lines.append((" Belegt nur, was seither geschrieben wurde. Der "
"Quell-Snapshot ist dafuer unloeschbar.",
curses.color_pair(C_DIM)))
lines.append((" Fuer den Dauerbetrieb mit f loesen (kopiert die "
"Daten wirklich).", curses.color_pair(C_DIM)))
elif instance.volumes:
lines.append(("Datentraeger: eigenstaendig - haengt an keinem Snapshot",
curses.color_pair(C_OK)))
footer = ("s Start | h Stop | e Auswerfen | f Loesen | "
"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 ("e", "E"):
_swap(stdscr, proxmox, instance, steckplaetze)
elif key in ("f", "F"):
_flatten(stdscr, proxmox, instance, linked)
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 _flatten(stdscr, proxmox, instance, linked):
if not linked:
message(stdscr, "%s haengt an keinem Snapshot mehr." % instance.label)
return
_wait_screen(stdscr, "Ermittle, wieviel kopiert werden muss ...")
try:
cost = flatten_cost(proxmox, instance)
except (RecoveryError, ProxmoxError):
cost = 0
if not confirm(stdscr, "%s kopieren und dauerhaft loesen?"
% (human_bytes(cost) if cost else "%d Datentraeger" % len(linked))):
return
progress = _Progress(stdscr, "Loese %s vom Quell-Snapshot" % instance.label)
try:
done, _already, other = flatten(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
lines = ["%d Datentraeger geloest." % len(done), ""]
for volid in other:
lines += _wrap("%s: %s" % (volid, flatten_hint(proxmox, volid)), 74)
if not linked_volumes(proxmox, instance):
lines += ["", "%s ist jetzt eigenstaendig. Die Snapshots des Originals "
"lassen sich wieder loeschen." % instance.label]
_pages(stdscr, _plain(lines), "Fertig")
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 ("v", "V"):
transfer_ui.screen(stdscr, browse=_browse_transfer)
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 ("x", "X"):
_destroy(stdscr, proxmox, current)
reload_ = True
def _browse_transfer(win, path, volume):
"""Den Zwei-Fenster-Commander auf ein Transfer-Laufwerk loslassen.
Links das Laufwerk, rechts der lokale Rechner - also genau der Weg, um
Werkzeuge hineinzukopieren, bevor die Maschine startet, und den Dump
hinterher wieder herauszuholen.
"""
from .explorer import Explorer, Pane
links = Pane(path=path, root=path, readonly=False,
title="Transfer: %s (%s)" % (volume.name, volume.label))
rechts = Pane(path=os.getcwd(), root="/", readonly=False,
title="Lokaler Rechner")
Explorer(links, rechts,
subtitle="Transfer-Laufwerk %s - beide Seiten beschreibbar"
% volume.name).run(win)
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.transfers = list(args.transfer or [])
spec.spice = bool(getattr(args, "spice", False))
spec.usb = int(getattr(args, "usb", 0) or 0)
spec.iso = getattr(args, "iso", "") 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
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 "?"))
for volume in plan_.transfers:
print(" Transfer : %-14s %8s im Gast als %s"
% (volume.name, human_bytes(volume.size), volume.label))
for note in plan_.notes:
print(" Hinweis : %s" % note)
for warning in plan_.warnings:
print(" ACHTUNG : %s" % warning)
for reason in plan_.critical:
print(" GEFAHR : %s" % reason)
def _ask(question):
"""Frage am Terminal. Ohne Eingabemoeglichkeit gilt: nein.
Wird die Ausgabe in ein Skript geleitet, liefert input() ein EOFError -
das darf keinen Stacktrace geben, sondern muss als Absage zaehlen.
"""
try:
return input(question).strip().lower()
except (EOFError, KeyboardInterrupt):
print()
return ""
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_)
# Gefaehrliches laesst sich nicht mit -y wegwinken. -y heisst "keine
# Routinefragen", nicht "frag mich auch dann nicht, wenn es weh tut".
if plan_.critical and not args.force:
# Sonst ueberholt die Fehlerausgabe die gepufferte Zusammenfassung,
# sobald jemand die Ausgabe umleitet - und die Begruendung stuende
# ueber dem, worauf sie sich bezieht.
sys.stdout.flush()
print("\nDas geht so nicht:", file=sys.stderr)
for reason in plan_.critical:
print(" - %s" % reason, file=sys.stderr)
if args.yes:
print("\n-y reicht dafuer nicht. Entweder das Original stoppen oder\n"
"bewusst --force setzen.", file=sys.stderr)
return 2
print("\nWer das trotzdem will, tippt jetzt das Wort ja aus.")
if _ask("Trotzdem anlegen? [ja/nein] ") not in ("ja", "yes"):
print("Abgebrochen.")
return 1
elif not args.yes:
if plan_.warnings:
print("\nEs gibt Warnungen - bitte oben lesen.")
answer = _ask("\nAnlegen? [j/N] ")
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)
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 _ask("Wirklich? [j/N] ") not in ("j", "y", "ja", "yes"):
print("Abgebrochen.")
return 1
destroy(proxmox, instance, progress=lambda text: print(" " + text))
return 0
def cmd_cleanup(proxmox, args):
orphans = find_orphans(proxmox)
if not orphans:
print("Nichts liegengeblieben.")
return 0
clones = [e for e in orphans if e["parent"]]
plain = [e for e in orphans if not e["parent"]]
print("Datentraeger, zu denen es keinen Gast mehr gibt:\n")
for entry in clones:
print(" %-26s %8s Klon von %s"
% (entry["volid"], human_bytes(entry["bytes"]), entry["parent"]))
for entry in plain:
print(" %-26s %8s kein Klon"
% (entry["volid"], human_bytes(entry["bytes"])))
wanted = orphans if args.all else clones
if not wanted:
print("\nNur Datentraeger ohne Elternteil gefunden. Die werden nicht von\n"
"selbst entfernt - mit --all auch diese.")
return 0
print("\nEntfernt werden %d Datentraeger%s."
% (len(wanted), "" if args.all else " (nur Klone; --all nimmt auch die anderen)"))
print("Der Schutz der Quell-Snapshots wird dabei geloest, sofern kein\n"
"anderer Klon mehr daran haengt.")
if not args.yes:
if _ask("\nEntfernen? [j/N] ") not in ("j", "y", "ja", "yes"):
print("Abgebrochen.")
return 1
removed, failed = remove_orphans(proxmox, wanted,
progress=lambda text: print(" " + text))
print("\n%d entfernt." % len(removed))
for problem in failed:
print(" fehlgeschlagen: %s" % problem, file=sys.stderr)
return 1 if failed else 0
def cmd_spice(proxmox, args):
instance = _find_instance(proxmox, args.vmid)
text = spice_file(proxmox, instance)
if args.output:
with open(args.output, "w", encoding="utf-8") as handle:
handle.write(text)
os.chmod(args.output, 0o600)
print(args.output)
print("\n Auf dem eigenen Rechner oeffnen:", file=sys.stderr)
print(" scp root@%s:%s . && remote-viewer %s"
% (node_address(instance.node), args.output,
os.path.basename(args.output)), file=sys.stderr)
else:
sys.stdout.write(text)
print("\n Umleiten und mit remote-viewer oeffnen:", file=sys.stderr)
print(" pvesnap-recovery spice %d -o vm.vv" % instance.vmid,
file=sys.stderr)
print(" Das Kennwort darin ist ein Einmal-Kennwort und laeuft bald ab.",
file=sys.stderr)
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("--spice", action="store_true",
help="SPICE-Anzeige einschalten (vga: qxl) - vertraegt "
"sich nicht mit geladenem Arbeitsspeicher")
parser.add_argument("--usb", type=int, default=0, metavar="ANZAHL",
help="so viele USB-Weiterleitungen ueber SPICE, z.B. "
"fuer einen Dongle (geht auch mit RAM-Zustand)")
parser.add_argument("--iso", metavar="VOLID",
help="Abbild als CD einlegen, z.B. local:iso/virtio-win.iso")
parser.add_argument("--transfer", action="append", metavar="NAME", default=[],
help="vorbereitetes Transfer-Laufwerk anhaengen "
"(mehrfach moeglich)")
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="Routinefragen ueberspringen (deckt --force nicht ab)")
parser.add_argument("--force", action="store_true",
help="auch dann anlegen, wenn das Original noch laeuft - "
"gleiche MAC und IP zweimal im Netz")
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 --transfer dumps
neuesten Snapshot von VM 802 ohne Netzwerk starten, mit dem
vorbereiteten Transfer-Laufwerk "dumps"
pvesnap-recovery live 100 --transfer dumps --transfer werkzeuge
Container 100 abgeschottet starten, mit zwei Laufwerken
pvesnap-recovery
Taste v: Transfer-Laufwerke anlegen und befuellen (Commander)
Taste e: im laufenden Betrieb auswerfen und wieder einklinken
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
""")
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)
spice = subs.add_parser("spice",
help="SPICE-Verbindungsdatei fuer remote-viewer")
spice.add_argument("vmid", type=int)
spice.add_argument("-o", "--output", metavar="DATEI",
help="hineinschreiben statt auf die Ausgabe")
flatten_ = subs.add_parser(
"flatten", help="vom Quell-Snapshot loesen - fuer den Dauerbetrieb")
flatten_.add_argument("vmid", type=int)
flatten_.add_argument("-y", "--yes", action="store_true", help="nicht nachfragen")
flatten_.add_argument("--force", action="store_true",
help="auch starten, wenn der Storage dafuer zu voll ist")
cleanup = subs.add_parser(
"cleanup", help="Klone entfernen, zu denen es keinen Gast mehr gibt")
cleanup.add_argument("--all", action="store_true",
help="auch Datentraeger ohne Elternteil entfernen")
cleanup.add_argument("-y", "--yes", action="store_true", help="nicht nachfragen")
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),
"flatten": lambda: cmd_flatten(proxmox, args),
"cleanup": lambda: cmd_cleanup(proxmox, args),
"console": lambda: cmd_console(proxmox, args),
"spice": lambda: cmd_spice(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())