Auswerfen und wieder einklinken, ohne die Maschine anzufassen - damit laesst
sich mehrfach nachladen und abholen, statt nur einmal am Ende.
eject_transfer() zieht die Platte ab, danach gehoert sie dem Host
insert_transfer() gibt sie zurueck in denselben Steckplatz
transfer_state() was gerade wirklich steckt
In der Detailansicht auf Taste e; bei mehreren Laufwerken mit Auswahl. Vor dem
Auswerfen wird nachgefragt, mit dem Hinweis, im Gast vorher auszuhaengen -
Proxmox meldet das Geraet zwar ab, aber ein Dateisystem, das noch beschrieben
wird, nimmt das uebel. Schlaegt es fehl, weil der Gast es festhaelt, steht das
auch so in der Meldung.
Beim Container gibt es nichts zu wechseln und das wird auch so gesagt: dort
ist das Verzeichnis durchgereicht, Host und Gast sehen dieselben Dateien
ohnehin gleichzeitig.
Dabei ein Fehler aufgefallen, den erst der Test zeigte: bei einer Maschine mit
geladenem Arbeitsspeicher steckte das Transfer-Laufwerk in der kalten
Konfiguration - der Gast haette es nie bemerkt. Dieselbe Falle wie beim
Austauschlaufwerk, dort schon behoben, hier vergessen. Beide gehen jetzt durch
dieselbe Nachsteck-Logik.
Am laufenden System durchgespielt, mit RAM-Zustand:
Start "Stecke Transfer-Laufwerk 'hotswap' als scsi2 an"
auswerfen eingesteckt=False, Laufwerk wieder frei
befuellen runde2.txt vom Host, VM lief weiter
einklinken scsi2: /dev/loop0,backup=0,size=1G
Ende beide Dateien da, VM durchgehend running
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1206 lines
48 KiB
Python
1206 lines
48 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> --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, RecoveryError, Spec, create,
|
|
eject_transfer, insert_transfer, transfer_state,
|
|
destroy, find_orphans, flatten, flatten_cost, flatten_hint,
|
|
human_bytes,
|
|
linked_volumes, list_instances, plan, pull, release,
|
|
remove_orphans, start)
|
|
from . import transfer, transfer_ui
|
|
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."),
|
|
("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", ()))
|
|
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 == "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.",
|
|
"transfers": "Vorbereitete Austauschmedien. Anlegen und befuellen "
|
|
"in der Uebersicht mit v .",
|
|
"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):
|
|
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 = {"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))
|
|
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(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_.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)))
|
|
|
|
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)))
|
|
|
|
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 | a Austausch | 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 ("a", "A"):
|
|
_pull(stdscr, proxmox, instance)
|
|
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 _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 _swap(stdscr, proxmox, instance, steckplaetze):
|
|
"""Transfer-Laufwerk im laufenden Betrieb abziehen oder wieder einklinken."""
|
|
wechselbar = [(e, drin) for e, drin in steckplaetze if e.get("kind") != "bind"]
|
|
if not wechselbar:
|
|
message(stdscr, "Hier gibt es nichts zu wechseln.", error=True)
|
|
return
|
|
|
|
if len(wechselbar) == 1:
|
|
entry, drin = wechselbar[0]
|
|
else:
|
|
gewaehlt = choose(stdscr, "Welches Laufwerk?",
|
|
[((e, d), "%-14s %s" % (e["name"],
|
|
"eingesteckt" if d else "ausgeworfen"))
|
|
for e, d in wechselbar])
|
|
if gewaehlt is None:
|
|
return
|
|
entry, drin = gewaehlt
|
|
|
|
name = entry["name"]
|
|
if drin:
|
|
if not confirm(stdscr, "%r auswerfen? Im Gast vorher aushaengen!" % name):
|
|
return
|
|
_do(stdscr, "Werfe %r aus ..." % name,
|
|
lambda: eject_transfer(proxmox, instance, name))
|
|
else:
|
|
_do(stdscr, "Klinke %r ein ..." % name,
|
|
lambda: insert_transfer(proxmox, instance, name))
|
|
|
|
|
|
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 ("a", "A"):
|
|
_pull(stdscr, proxmox, current)
|
|
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.exchange = args.exchange or ""
|
|
spec.exchange_storage = args.exchange_storage or ""
|
|
spec.exchange_fs = "exfat"
|
|
spec.transfers = list(args.transfer or [])
|
|
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)
|
|
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)
|
|
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 _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_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_flatten(proxmox, args):
|
|
instance = _find_instance(proxmox, args.vmid)
|
|
linked = linked_volumes(proxmox, instance)
|
|
if not linked:
|
|
print("%s haengt an keinem Snapshot mehr - nichts zu tun."
|
|
% instance.label)
|
|
return 0
|
|
|
|
print("%s loest sich von %s." % (instance.label, instance.origin))
|
|
for volid in linked:
|
|
print(" %s" % volid)
|
|
cost = flatten_cost(proxmox, instance)
|
|
print("\nDabei werden die Daten wirklich kopiert%s."
|
|
% ((" - rund %s" % human_bytes(cost)) if cost else ""))
|
|
if not args.yes:
|
|
if _ask("Jetzt loesen? [j/N] ") not in ("j", "y", "ja", "yes"):
|
|
print("Abgebrochen.")
|
|
return 1
|
|
|
|
done, already, other = flatten(proxmox, instance, stream=True, force=args.force,
|
|
progress=lambda text: print(" " + text))
|
|
print("\n%d Datentraeger geloest." % len(done))
|
|
for volid in already:
|
|
print(" war schon eigenstaendig: %s" % volid)
|
|
for volid in other:
|
|
print(" %s: %s" % (volid, flatten_hint(proxmox, volid)))
|
|
if not linked_volumes(proxmox, instance):
|
|
print("\n%s ist jetzt eigenstaendig. Die Snapshots des Originals lassen "
|
|
"sich wieder loeschen." % instance.label)
|
|
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_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("--transfer", action="append", metavar="NAME", default=[],
|
|
help="vorbereitetes Transfer-Laufwerk anhaengen "
|
|
"(mehrfach moeglich)")
|
|
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="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 --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)
|
|
|
|
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),
|
|
"pull": lambda: cmd_pull(proxmox, args),
|
|
"release": lambda: cmd_release(proxmox, args),
|
|
"flatten": lambda: cmd_flatten(proxmox, args),
|
|
"cleanup": lambda: cmd_cleanup(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())
|