"""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 --exchange 20G pvesnap-recovery recover 802 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())