diff --git a/README.md b/README.md index 9a848c4..af873b6 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,12 @@ löscht. Alles wird über **eine INI-Datei** gesteuert, die sich auch bequem in * Läuft cluster-weit (alle Nodes) über `pvesh` * Nur Python-Standardbibliothek — keine zusätzlichen Pakete nötig -Dazu zwei Werkzeuge, um wieder **an die Daten im Snapshot** zu kommen: +Dazu drei Werkzeuge, um wieder **an die Daten im Snapshot** zu kommen: * `pvesnap-explorer` — zwei Fenster im Terminal wie im Midnight Commander * `pvesnap-web` — im Browser durchsehen und herunterladen, auch vom anderen Rechner +* `pvesnap-recovery` — den Snapshot als **laufende Maschine** starten, abgeschottet + ohne Netzwerk oder als vollwertige Wiederherstellung mit gleicher Identität --- @@ -46,6 +48,7 @@ Das Skript legt an: | `/usr/local/bin/pvesnap` | Dienst und Konfiguration | | `/usr/local/bin/pvesnap-explorer` | Zwei-Fenster-Explorer | | `/usr/local/bin/pvesnap-web` | Web-Oberfläche | +| `/usr/local/bin/pvesnap-recovery` | Snapshot als Maschine starten | | `/etc/pvesnap/pvesnap.conf` | Konfiguration (wird bei Updates **nicht** überschrieben) | | `/var/lib/pvesnap/state.json` | merkt sich die letzten Läufe | | `/etc/systemd/system/pvesnap.service` | systemd-Unit des Snapshot-Dienstes | @@ -546,6 +549,193 @@ Snapshot wieder ausgehängt. --- +## Einen Snapshot als Maschine starten — `pvesnap-recovery` + +Manchmal reicht es nicht, einzelne Dateien herauszukopieren. Eine Datenbank zum +Beispiel liegt im Snapshot als Haufen halbfertiger Dateien — brauchbar wird sie +erst, wenn der Datenbankserver läuft und selbst einen Dump schreibt. Genau dafür +ist `pvesnap-recovery` da: Es startet den Snapshot als **eigenständige Maschine**, +ohne das Original anzufassen. + +Zwei Betriebsarten: + +| | **live** | **recovery** | +|---|---|---| +| Netzwerk | keins | volles Netz | +| Identität | bleibt (UUID, MACs, Hostname) | bleibt (UUID, MACs, Hostname) | +| Wofür | hineinschauen, Dump ziehen, Daten holen | die Maschine wirklich wieder in Betrieb nehmen | +| Risiko | keins — sie kann nichts erreichen | hoch, solange das Original läuft | + +Bedient wird das Ganze mit `pvesnap-recovery` (ncurses-Oberfläche mit Übersicht +und Assistent) oder direkt über Parameter. + +### Warum das schnell geht + +Die Datenträger werden nicht kopiert, sondern **geklont**. Auf Ceph/RBD, ZFS und +LVM-thin ist das ein Copy-on-Write-Klon: Er ist in Sekunden fertig, egal wie groß +die Platte ist, und belegt anfangs keinen zusätzlichen Platz. Eine 32-GB-Platte +war im Test nach **0,8 Sekunden** geklont. + +Dafür wird `PVE::Storage::vdisk_clone` benutzt — dieselbe Funktion, die auch +Proxmox selbst für Klone verwendet, samt Cluster-Sperre auf dem Storage. Es wird +also nichts nachgebaut, was Proxmox schon kann. + +> Auf Datei-Storages (`dir`, `nfs`, `cifs`) geht das nicht — dort lässt sich aus +> einem qcow2-internen Snapshot kein Klon ziehen. Für diese Storages bleibt der +> Weg über `pvesnap-explorer`. + +### Der Arbeitsspeicher kommt mit + +Enthält der Snapshot den Arbeitsspeicher (`vmstate`, in der Konfiguration +`snapshot_vmstate = yes`), wird der mitgenommen. Die Maschine **bootet dann +nicht**, sondern läuft genau dort weiter, wo sie beim Snapshot stand: + +* kein Crash-Recovery, kein `fsck`, kein Journal-Rollback +* Datenbanken sind bereits offen und konsistent +* die Uhr im Gast steht auf dem Snapshot-Zeitpunkt + +Im Test war das nachweisbar: Die Konsole zeigte 10:38 — die Uhrzeit des +Snapshots — während auf dem Host längst 11:24 war. + +Damit das klappt, muss die Geräteausstattung exakt zum gespeicherten Zustand +passen. `pvesnap-recovery` sorgt selbst dafür: + +* `vmgenid` und `smbios1` bleiben erhalten (fehlen sie, bricht das Laden mit + `Unknown savevm section or instance 'vmgenid'` ab) +* `runningmachine` und `runningcpu` aus dem Snapshot werden übernommen +* die Netzwerkkarte bleibt **vorhanden**, wird aber abgeklemmt (`link_down=1`) + statt entfernt — herausnehmen würde den Zustand unbrauchbar machen +* das Austauschlaufwerk wird erst **nach** dem Fortsetzen angesteckt (Hotplug), + weil der Gast in einem Zustand aufwacht, in dem es die Platte noch nicht gab + +Ein Detail, das Proxmox nicht selbst meldet: Schlägt das Laden des +Arbeitsspeichers fehl, quittiert Proxmox den Start trotzdem mit `TASK OK` und +lässt die Maschine angehalten stehen. `pvesnap-recovery` liest das Task-Protokoll +mit, setzt die Maschine fort und sagt deutlich, wenn statt des RAM-Standes kalt +gebootet wurde. + +Mit `--no-resume` lässt sich der Arbeitsspeicher bewusst weglassen. + +### Daten aus einer Maschine ohne Netzwerk holen + +Ohne Netz kommt nichts heraus — dafür gibt es das **Austauschlaufwerk**. + +**Bei virtuellen Maschinen** wird eine leere, formatierte Zusatzplatte angelegt +(Bezeichnung `PVESNAP`, mit einer kurzen `LIESMICH.txt` darauf): + +```bash +# Snapshot abgeschottet starten, mit 20 GB Austauschplatte +pvesnap-recovery live 802 --exchange 20G + +# im Gast (über die noVNC-Konsole): +mount /dev/disk/by-label/PVESNAP /mnt +mysqldump --all-databases > /mnt/dump.sql +umount /mnt && poweroff + +# auf dem Host: +pvesnap-recovery pull 9802 # gibt den Pfad aus, z. B. /run/pvesnap/exchange/9802 +pvesnap-recovery release 9802 # wieder lösen +pvesnap-recovery destroy 9802 # alles verwerfen +``` + +**Bei Containern** ist es einfacher: Ein Host-Verzeichnis wird direkt +durchgereicht und erscheint im Container unter `/mnt/pvesnap`. Was der Container +hineinschreibt, liegt sofort auf dem Host — kein Ein- und Aushängen nötig. + +```bash +pvesnap-recovery live 100 --exchange ja +pct exec 9100 -- sh -c 'pg_dumpall > /mnt/pvesnap/dump.sql' +ls /var/lib/pvesnap/exchange/9100/ +``` + +Und hier passt auch die Idee mit dem **Netzlaufwerk**: Ist auf dem Host ohnehin +schon eine NFS- oder CIFS-Freigabe eingehängt, lässt sie sich direkt +durchreichen — der Container schreibt dann quasi aufs Netzlaufwerk, obwohl er +selbst kein Netzwerk hat: + +```bash +pvesnap-recovery live 100 --exchange-dir /mnt/nfs/dumps +``` + +Bei virtuellen Maschinen geht das nicht: Ein Host-Verzeichnis lässt sich nicht in +eine VM hineinreichen. Dort bleibt es bei der Austauschplatte. + +### An die Konsole kommen + +``` +https://:8006/?console=kvm&novnc=1&vmid=9802&node=&resize=off&cmd= +``` + +Diese Adresse gibt `pvesnap-recovery` nach dem Anlegen aus, ebenso +`pvesnap-recovery console `. Es ist die ganz normale noVNC-Konsole von +Proxmox — die Maschine taucht auch in der Weboberfläche auf, markiert mit dem Tag +`pvesnap-recovery`. Bei Containern geht zusätzlich `pct enter `. + +### Wiederherstellung mit Netzwerk + +```bash +pvesnap-recovery recover 802 autosnap-g5min-20260731-103800 --newid 9802 +``` + +Hier bleibt alles erhalten, was die Maschine ausmacht: SMBIOS-UUID, `vmgenid`, +MAC-Adressen, bei Containern der Hostname. Für alles im Netz ist sie dieselbe +Maschine — inklusive Lizenzbindungen, AD-Mitgliedschaft und DHCP-Reservierungen. + +**Genau deshalb ist sie gefährlich.** `pvesnap-recovery` prüft vorher, ob das +Original läuft, und warnt unmissverständlich. Zwei Maschinen mit derselben MAC +und derselben IP im selben Netz geben Chaos. Das Original also **vorher +stoppen** — oder mit `--net down` starten und die Karte erst zuschalten, wenn +klar ist, dass die Luft rein ist. + +### Parameter + +| Parameter | Bedeutung | +|---|---| +| `live [snapshot]` | abgeschottet starten (ohne Snapshot: der neueste) | +| `recover [snapshot]` | mit Netzwerk und gleicher Identität starten | +| `--newid ` | VMID der neuen Maschine (Vorgabe: nächste freie) | +| `--node ` | auf welchem Node sie laufen soll | +| `--net none\|down\|on` | keine Karte / Karte ohne Leitung / voll am Netz | +| `--resume` / `--no-resume` | Arbeitsspeicher laden bzw. bewusst kalt starten | +| `--exchange ` | Austauschplatte, z. B. `20G` (Container: `ja` genügt) | +| `--exchange-storage ` | Storage dafür (Vorgabe: wie die erste Platte) | +| `--exchange-fs ` | `ext4` (Vorgabe), `ext3`, `xfs`, `vfat`, `ntfs` | +| `--exchange-dir ` | nur Container: dieses Host-Verzeichnis durchreichen | +| `--memory `, `--cores ` | abweichende Ausstattung (nicht mit `--resume`) | +| `--name ` | Name der neuen Maschine | +| `--keep-binds` | durchgereichte Host-Verzeichnisse des Originals übernehmen | +| `--no-start` | nur einrichten, nicht starten | +| `-y`, `--yes` | nicht nachfragen | + +| Befehl | Bedeutung | +|---|---| +| `pvesnap-recovery` | ncurses-Oberfläche mit Übersicht und Assistent | +| `list` | vorhandene Wiederherstellungen anzeigen | +| `start` / `stop` | hoch- bzw. herunterfahren | +| `console ` | noVNC-Adresse ausgeben | +| `pull ` | Austauschlaufwerk auf den Host holen | +| `release ` | es wieder lösen | +| `destroy ` | restlos verwerfen | + +### Was beim Verwerfen passiert + +`destroy` räumt vollständig auf: Maschine stoppen, Gast samt aller Klone +entfernen, Austauschverzeichnis löschen — und den **Schutz der Quell-Snapshots +wieder aufheben**. + +Dieser letzte Punkt ist wichtig: Ceph verlangt für einen Klon, dass der +Quell-Snapshot geschützt ist (`rbd snap protect`), und Proxmox setzt das beim +Klonen selbst. Bliebe der Schutz stehen, könnte die Vorhaltezeit diesen Snapshot +später nicht mehr löschen — im Protokoll stünde dann immer wieder +`snapshot is protected`. Solange eine Wiederherstellung existiert, ist ihr +Quell-Snapshot also bewusst unlöschbar; danach nicht mehr. + +Zur Sicherheit fasst `destroy` nur Maschinen an, die den Tag `pvesnap-recovery` +tragen. Eine von Hand angelegte VM lässt sich damit nicht versehentlich löschen. +`uninstall.sh` warnt ebenfalls, wenn noch Wiederherstellungen offen sind. + +--- + ## Aufbau ``` @@ -563,6 +753,8 @@ pvesnap/ tui.py ncurses-Konfigurationseditor snapfs.py Snapshots einbinden und mounten (rbd/zfs/lvm/qcow2) explorer.py Zwei-Fenster-Explorer im Terminal + recovery.py Snapshot als Maschine starten (Klone, Konfiguration, Aufräumen) + recovery_ui.py ncurses-Oberfläche und Kommandozeile dazu web/ Web-Oberfläche zum Herunterladen server.py HTTP-Server, ZIP im Strom, Zugangsschlüssel assets.py Stylesheet und HTML-Gerüst diff --git a/install.sh b/install.sh index 09f20e3..4f9ca88 100755 --- a/install.sh +++ b/install.sh @@ -129,6 +129,7 @@ EOF install_command pvesnap pvesnap install_command pvesnap-explorer pvesnap.explorer install_command pvesnap-web pvesnap.web +install_command pvesnap-recovery pvesnap.recovery_ui # --- Konfiguration ---------------------------------------------------- install -d -m 0755 "${CONF_DIR}" "${STATE_DIR}" "${DOC_DIR}" @@ -219,6 +220,11 @@ Fertig. Dateien aus einem Snapshot holen: pvesnap-explorer zwei Fenster wie im Midnight Commander pvesnap-web im Browser durchsehen und herunterladen + + Einen Snapshot als Maschine starten: + pvesnap-recovery Uebersicht und Assistent + pvesnap-recovery live 100 --exchange ja + abgeschottet ohne Netz, mit Austauschverzeichnis EOF if [ "$WEB" -eq 1 ]; then diff --git a/pvesnap/proxmox.py b/pvesnap/proxmox.py index 14300bd..406153e 100644 --- a/pvesnap/proxmox.py +++ b/pvesnap/proxmox.py @@ -207,6 +207,103 @@ class Proxmox: what = "Loeschen von %s bei %s" % (name, guest.label) return self._retry(what, lambda: self._task(guest, args, what)) + # -- Gaeste lesen und steuern ----------------------------------------- + + def guest_config(self, guest, snapshot=None): + """Konfiguration eines Gastes - wahlweise die zu einem Snapshot.""" + tail = ("snapshot/%s/config" % snapshot) if snapshot else "config" + data = self._json(["get", "/nodes/%s/%s/%d/%s" + % (guest.node, guest.type, guest.vmid, tail)]) + if not isinstance(data, dict): + raise ProxmoxError("Konfiguration von %s nicht lesbar" % guest.label) + return data + + def guest_status(self, guest): + try: + data = self._json(["get", "/nodes/%s/%s/%d/status/current" + % (guest.node, guest.type, guest.vmid)]) + except ProxmoxError: + return {} + return data if isinstance(data, dict) else {} + + def guest_exists(self, vmid): + return any(g.vmid == int(vmid) for g in self.inventory(refresh=True)) + + def next_vmid(self, wanted=None): + """Freie VMID. Mit `wanted` wird geprueft, ob genau die frei ist.""" + args = ["get", "/cluster/nextid"] + if wanted: + args += ["--vmid", str(int(wanted))] + return int(self._json(args)) + + def vmid_free(self, vmid): + try: + self.next_vmid(vmid) + return True + except (ProxmoxError, TypeError, ValueError): + return False + + def start_guest(self, guest): + return self._guest_task(guest, "create", "status/start", + "Starten von %s" % guest.label) + + def shutdown_guest(self, guest, timeout=180, force=True): + """Sauberes Herunterfahren; `force` schaltet nach Ablauf hart ab.""" + extra = ["--timeout", str(int(timeout))] + if force: + extra += ["--forceStop", "1"] + return self._guest_task(guest, "create", "status/shutdown", + "Herunterfahren von %s" % guest.label, extra, + timeout=timeout + 60) + + def stop_guest(self, guest): + return self._guest_task(guest, "create", "status/stop", + "Ausschalten von %s" % guest.label) + + def set_guest_config(self, guest, values): + """Konfigurationswerte setzen. Laeuft der Gast, steckt Proxmox + hotplug-faehige Geraete dabei gleich an.""" + args = ["set", "/nodes/%s/%s/%d/config" % (guest.node, guest.type, guest.vmid)] + for key, value in values.items(): + args += ["--%s" % key, str(value)] + if self.dry_run: + log.info("[TESTLAUF] wuerde setzen: %s", " ".join(args)) + return None + stdout, _ = self._run(args, timeout=self.task_timeout) + return self._wait_upid(guest.node, stdout, "Aendern von %s" % guest.label, + guest) + + def resume_guest(self, guest): + return self._guest_task(guest, "create", "status/resume", + "Fortsetzen von %s" % guest.label) + + def task_log(self, node, upid, limit=200): + """Das Protokoll eines Tasks als Liste von Zeilen.""" + try: + entries = self._json(["get", "/nodes/%s/tasks/%s/log" % (node, upid), + "--limit", str(int(limit))]) or [] + except ProxmoxError: + return [] + return [str(entry.get("t") or "") for entry in entries] + + def destroy_guest(self, guest, purge=True): + extra = ["--purge", "1"] if purge else [] + if guest.type == "qemu": + extra += ["--destroy-unreferenced-disks", "1"] + return self._guest_task(guest, "delete", "", "Entfernen von %s" % guest.label, + extra) + + def _guest_task(self, guest, verb, sub, what, extra=None, timeout=None): + path = "/nodes/%s/%s/%d" % (guest.node, guest.type, guest.vmid) + if sub: + path += "/" + sub + args = [verb, path] + (extra or []) + if self.dry_run: + log.info("[TESTLAUF] wuerde ausfuehren: %s", " ".join(args)) + return None + stdout, _ = self._run(args, timeout=timeout or self.task_timeout) + return self._wait_upid(guest.node, stdout, what, guest) + def _task(self, guest, args, what): """Fuehrt eine Snapshot-Aktion aus und wartet, bis sie wirklich fertig ist. @@ -255,27 +352,31 @@ class Proxmox: # -- Task-Verfolgung -------------------------------------------------- def _wait_task(self, guest, output, what): - """pvesh liefert bei Snapshot-Aktionen eine UPID; darauf warten wir.""" + return self._wait_upid(guest.node, output, what, guest) + + def _wait_upid(self, node, output, what, guest=None): + """pvesh liefert bei laengeren Aktionen eine UPID; darauf warten wir.""" upid = self._extract_upid(output) if not upid: # Ohne UPID koennen wir den Task nicht verfolgen - dann warten wir # ersatzweise, bis der Gast nicht mehr gesperrt ist. Sonst wuerde # die naechste Aktion in eine noch laufende hineinlaufen. log.debug("%s: keine UPID in der Antwort von pvesh", what) - self._wait_unlocked(guest) + if guest is not None: + self._wait_unlocked(guest) return None deadline = time.time() + self.task_timeout delay = 0.5 while True: status = self._json(["get", "/nodes/%s/tasks/%s/status" - % (guest.node, upid)]) or {} + % (node, upid)]) or {} if status.get("status") == "stopped": exit_status = status.get("exitstatus") or "unbekannt" if exit_status != "OK": # Nur die Ursache melden - wer und was, ergaenzt der Aufrufer. raise ProxmoxError("%s%s" % (exit_status, - self._task_log_tail(guest.node, upid))) + self._task_log_tail(node, upid))) return upid if time.time() > deadline: raise ProxmoxError("%s: Zeitueberschreitung nach %ds (Task laeuft weiter: %s)" diff --git a/pvesnap/recovery.py b/pvesnap/recovery.py new file mode 100644 index 0000000..08ce13a --- /dev/null +++ b/pvesnap/recovery.py @@ -0,0 +1,1207 @@ +"""Einen Snapshot als eigenstaendige Maschine starten. + +Zwei Betriebsarten: + + live Der Snapshot laeuft abgeschottet - ohne Netzwerk. Gedacht zum + Hineinschauen: ueber die noVNC-Konsole arbeiten, einen + Datenbank-Dump ziehen und ihn ueber ein Austauschlaufwerk + wieder herausholen. + + recovery Der Snapshot laeuft mit Netzwerk und behaelt MAC-Adressen, + SMBIOS-UUID und bei Containern den Hostnamen. Fuer alles + drumherum ist er dieselbe Maschine - entsprechend gefaehrlich, + solange das Original noch laeuft. + +Die Datentraeger werden nicht kopiert, sondern geklont. Auf Ceph, ZFS und +LVM-thin ist das ein Copy-on-Write-Klon: er dauert Sekunden, egal wie gross +die Platte ist, und das Original wird dabei nicht angefasst. Dafuer wird +`PVE::Storage::vdisk_clone` verwendet - dieselbe Funktion, die auch Proxmox +selbst benutzt, samt Cluster-Sperre auf dem Storage. + +Enthaelt der Snapshot den Arbeitsspeicher (vmstate, also "mit RAM"), wird der +mitgenommen: die Maschine laeuft dann genau dort weiter, wo sie beim Snapshot +stand, statt wie nach einem Stromausfall zu booten. Fuer einen sauberen +Datenbank-Dump ist das der entscheidende Unterschied. +""" + +from __future__ import annotations + +import json +import logging +import os +import re +import shutil +import subprocess +import time +from dataclasses import dataclass, field + +from .proxmox import Guest, ProxmoxError + +log = logging.getLogger("pvesnap.recovery") + +TAG = "pvesnap-recovery" +STATE_DIR = "/var/lib/pvesnap" +REGISTRY = os.path.join(STATE_DIR, "recovery.json") +EXCHANGE_ROOT = os.path.join(STATE_DIR, "exchange") +EXCHANGE_MOUNT = "/run/pvesnap/exchange" +CONF_ROOT = "/etc/pve/nodes" + +QEMU_DISK_KEY = re.compile(r"^(ide|sata|scsi|virtio)\d+$") +QEMU_EXTRA_DISKS = ("efidisk0", "tpmstate0") +LXC_DISK_KEY = re.compile(r"^(rootfs|mp\d+)$") +NET_KEY = re.compile(r"^net\d+$") + +# Nichts uebernehmen, was zum Snapshot oder zur Einbettung des Originals +# gehoert. vmstate/runningmachine/runningcpu werden spaeter gezielt gesetzt. +# +# vmgenid steht bewusst NICHT hier: die Kennung gehoert zur Identitaet der +# Maschine ("als waere es dieselbe") und steckt ausserdem als eigenes Geraet +# im gespeicherten Arbeitsspeicher. Fehlt sie, bricht das Laden des RAM-Standes +# mit "Unknown savevm section or instance 'vmgenid'" ab. +DROP_KEYS = { + "parent", "snaptime", "digest", "snapstate", "pending", "lock", "template", + "description", "tags", "onboot", "startup", "protection", "hookscript", + "replicate", "vmstate", "runningmachine", "runningcpu", +} + +DEFAULT_EXCHANGE_SIZE = "10G" +DEFAULT_EXCHANGE_FS = "ext4" +EXCHANGE_LABEL = "PVESNAP" +EXCHANGE_GUEST_PATH = "/mnt/pvesnap" + +MKFS = { + "ext4": ["mkfs.ext4", "-q", "-m", "0", "-L", EXCHANGE_LABEL], + "ext3": ["mkfs.ext3", "-q", "-m", "0", "-L", EXCHANGE_LABEL], + "xfs": ["mkfs.xfs", "-q", "-L", EXCHANGE_LABEL], + "vfat": ["mkfs.vfat", "-n", EXCHANGE_LABEL], + "ntfs": ["mkfs.ntfs", "-Q", "-F", "-L", EXCHANGE_LABEL], +} + +README = """Austauschlaufwerk von pvesnap-recovery + +Dieses Laufwerk gehoert nicht zur urspruenglichen Maschine. Es ist leer +angelegt worden, damit Daten aus diesem Gast wieder herauskommen - der Gast +hat ja kein Netzwerk. + + 1. Hier hineinschreiben, z.B. + mysqldump --all-databases > /mnt/pvesnap/dump.sql + 2. Gast herunterfahren + 3. Auf dem Proxmox-Host: pvesnap-recovery pull + +Danach liegen die Dateien auf dem Host und lassen sich mit +pvesnap-explorer oder ganz normal per scp abholen. +""" + + +class RecoveryError(Exception): + """Eine Wiederherstellung liess sich nicht einrichten.""" + + +# --------------------------------------------------------------------------- +# Kommandos +# --------------------------------------------------------------------------- + +def run(args, timeout=300, check=True, quiet=False): + if not quiet: + log.debug("exec: %s", " ".join(str(a) for a in args)) + try: + proc = subprocess.run([str(a) for a in args], stdout=subprocess.PIPE, + stderr=subprocess.PIPE, timeout=timeout) + except FileNotFoundError: + raise RecoveryError("Kommando nicht gefunden: %s" % args[0]) + except subprocess.TimeoutExpired: + raise RecoveryError("Zeitueberschreitung bei: %s" % " ".join(str(a) for a in args)) + stdout = proc.stdout.decode("utf-8", "replace").strip() + stderr = proc.stderr.decode("utf-8", "replace").strip() + if check and proc.returncode != 0: + detail = (stderr or stdout or "Fehler").strip().splitlines() + raise RecoveryError("%s: %s" % (args[0], detail[-1] if detail else "Fehler")) + return stdout + + +def have(command): + return shutil.which(command) is not None + + +# --------------------------------------------------------------------------- +# Storage-Ebene - Proxmox ihre eigene Arbeit machen lassen +# --------------------------------------------------------------------------- + +# Statt rbd/zfs/lvcreate selbst nachzubauen, wird die Storage-Schicht von +# Proxmox aufgerufen. Die kennt jeden Storage-Typ, benennt die Volumes richtig +# (vm--disk-N) und nimmt die Cluster-Sperre - ein Nachbau waere in jedem +# Punkt schlechter. +_PERL = r""" +use strict; +use warnings; +use PVE::Storage; + +my ($op, @rest) = @ARGV; +my $cfg = PVE::Storage::config(); + +if ($op eq 'clone') { + my ($volid, $vmid, $snap) = @rest; + print "RESULT ", PVE::Storage::vdisk_clone($cfg, $volid, $vmid, $snap), "\n"; +} elsif ($op eq 'alloc') { + my ($storeid, $vmid, $fmt, $size) = @rest; + print "RESULT ", + PVE::Storage::vdisk_alloc($cfg, $storeid, $vmid, $fmt, undef, $size), "\n"; +} elsif ($op eq 'free') { + PVE::Storage::vdisk_free($cfg, $rest[0]); + print "RESULT ok\n"; +} elsif ($op eq 'path') { + my ($path) = PVE::Storage::path($cfg, $rest[0]); + print "RESULT $path\n"; +} elsif ($op eq 'activate') { + PVE::Storage::activate_volumes($cfg, [@rest]); + print "RESULT ok\n"; +} elsif ($op eq 'deactivate') { + PVE::Storage::deactivate_volumes($cfg, [@rest]); + print "RESULT ok\n"; +} elsif ($op eq 'size') { + my ($size, $format) = PVE::Storage::volume_size_info($cfg, $rest[0], 10); + print "RESULT $size $format\n"; +} else { + die "unbekannte Operation: $op\n"; +} +""" + + +def storage_op(op, *args, timeout=600): + """Ruft die Storage-Schicht von Proxmox auf und liefert deren Ergebnis.""" + output = run(["perl", "-e", _PERL, str(op)] + [str(a) for a in args], + timeout=timeout) + for line in output.splitlines(): + if line.startswith("RESULT "): + return line[len("RESULT "):].strip() + raise RecoveryError("Unerwartete Antwort der Storage-Schicht bei '%s': %s" + % (op, output[:200] or "(leer)")) + + +def clone_volume(volid, newid, snapname): + """Copy-on-Write-Klon eines Snapshot-Datentraegers.""" + return storage_op("clone", volid, newid, snapname) + + +def alloc_volume(storeid, newid, size_kb, fmt="raw"): + return storage_op("alloc", storeid, newid, fmt, int(size_kb)) + + +def free_volume(volid): + storage_op("free", volid, timeout=900) + + +def volume_path(volid): + return storage_op("path", volid, timeout=120) + + +def volume_size(volid): + parts = storage_op("size", volid, timeout=120).split() + return int(parts[0]) if parts and parts[0].isdigit() else 0 + + +def unprotect_snapshot(proxmox, volid, snapname): + """Den Schutz wieder loesen, den das Klonen auf Ceph gesetzt hat. + + RBD verlangt fuer einen Klon, dass der Quell-Snapshot geschuetzt ist - + Proxmox setzt das beim Klonen selbst. Bleibt der Schutz stehen, kann die + Vorhaltezeit den Snapshot spaeter nicht mehr loeschen. Deshalb wird er + beim Verwerfen der Wiederherstellung wieder entfernt. + """ + if ":" not in volid: + return False + storage, volname = volid.split(":", 1) + try: + info = proxmox._json(["get", "/storage/%s" % storage]) or {} + except ProxmoxError: + return False + if info.get("type") != "rbd" or not have("rbd"): + return False + + pool = info.get("pool") or "rbd" + namespace = info.get("namespace") + image = "%s/%s%s" % (pool, (namespace + "/") if namespace else "", volname) + args = ["rbd"] + if info.get("monhost"): + args += ["-m", str(info["monhost"]).replace(" ", ",")] + args += ["--id", info.get("username") or "admin"] + keyring = "/etc/pve/priv/ceph/%s.keyring" % storage + if os.path.exists(keyring): + args += ["--keyring", keyring] + + result = run(args + ["snap", "unprotect", image, "--snap", snapname], + check=False, timeout=120) + log.debug("unprotect %s@%s: %s", image, snapname, result or "ok") + return True + + +# --------------------------------------------------------------------------- +# Groessenangaben +# --------------------------------------------------------------------------- + +_SIZE = re.compile(r"^\s*(\d+(?:[.,]\d+)?)\s*([KMGT])?i?B?\s*$", re.I) +_FACTOR = {"K": 1, "M": 1024, "G": 1024 ** 2, "T": 1024 ** 3} + + +def parse_size_kb(text, default_unit="G"): + """'10G' -> Kilobyte. vdisk_alloc rechnet in KiB.""" + match = _SIZE.match(str(text or "")) + if not match: + raise RecoveryError("Groessenangabe nicht verstanden: %r " + "(erwartet z.B. 10G, 512M)" % text) + value = float(match.group(1).replace(",", ".")) + unit = (match.group(2) or default_unit).upper() + return max(1, int(value * _FACTOR[unit])) + + +def human_bytes(count): + value = float(count or 0) + for unit in ("B", "K", "M", "G", "T", "P"): + if value < 1024 or unit == "P": + return "%d%s" % (value, unit) if unit == "B" else "%.1f%s" % (value, unit) + value /= 1024 + return "%.1fP" % value + + +# --------------------------------------------------------------------------- +# Datenmodell +# --------------------------------------------------------------------------- + +@dataclass +class Spec: + """Was gewuenscht ist.""" + mode: str = "live" # live | recovery + newid: int = 0 # 0 = naechste freie + node: str = "" # leer = Node des Originals + net: str = "" # leer = Vorgabe des Modus; none|down|on + resume: object = None # None = RAM-Zustand nehmen, wenn vorhanden + exchange: str = "" # Groesse, z.B. "10G"; leer = keins + exchange_storage: str = "" + exchange_fs: str = DEFAULT_EXCHANGE_FS + exchange_dir: str = "" # nur LXC: vorhandenes Host-Verzeichnis + memory: int = 0 # 0 = wie im Snapshot + cores: int = 0 + name: str = "" + keep_binds: bool = False + start: bool = True + + @property + def isolated(self): + return self.mode != "recovery" + + +@dataclass +class Plan: + """Was passieren wird - ohne dass schon etwas passiert ist.""" + guest: object + snapshot: str + spec: Spec + newid: int = 0 + node: str = "" + disks: list = field(default_factory=list) # [(key, volid, groesse)] + nets: list = field(default_factory=list) # [(key, wert)] + net_mode: str = "none" + vmstate: str = "" + vmstate_bytes: int = 0 + resume: bool = False + exchange_kind: str = "" # "disk" | "bind" | "" + exchange_detail: str = "" + warnings: list = field(default_factory=list) + notes: list = field(default_factory=list) + dropped: list = field(default_factory=list) + + @property + def label(self): + return "%s %d" % ("LXC" if self.guest.type == "lxc" else "VM", self.newid) + + +@dataclass +class Instance: + """Eine eingerichtete Wiederherstellung.""" + vmid: int + type: str = "qemu" + node: str = "" + name: str = "" + source: int = 0 + source_node: str = "" + snapshot: str = "" + mode: str = "live" + created: int = 0 + volumes: list = field(default_factory=list) + protected: list = field(default_factory=list) # [[volid, snapname]] + exchange: dict = field(default_factory=dict) + resumed: bool = False + status: str = "" + + @property + def guest(self): + return Guest(vmid=self.vmid, name=self.name, type=self.type, node=self.node) + + @property + def label(self): + return "%s %d" % ("LXC" if self.type == "lxc" else "VM", self.vmid) + + @property + def origin(self): + return "%s %d @ %s" % ("LXC" if self.type == "lxc" else "VM", + self.source, self.snapshot) + + def console_url(self, host=None): + kind = "lxc" if self.type == "lxc" else "kvm" + return ("https://%s:8006/?console=%s&novnc=1&vmid=%d&node=%s&resize=off&cmd=" + % (host or self.node, kind, self.vmid, self.node)) + + def to_dict(self): + return {k: getattr(self, k) for k in + ("vmid", "type", "node", "name", "source", "source_node", "snapshot", + "mode", "created", "volumes", "protected", "exchange", "resumed")} + + @classmethod + def from_dict(cls, data): + known = {k: data.get(k) for k in + ("vmid", "type", "node", "name", "source", "source_node", "snapshot", + "mode", "created", "volumes", "protected", "exchange", "resumed") + if data.get(k) is not None} + known["vmid"] = int(known.get("vmid") or 0) + return cls(**known) + + +# --------------------------------------------------------------------------- +# Merkliste +# --------------------------------------------------------------------------- + +def _registry_load(): + try: + with open(REGISTRY, "r", encoding="utf-8") as handle: + data = json.load(handle) + return [Instance.from_dict(entry) for entry in data] if isinstance(data, list) else [] + except (OSError, ValueError, TypeError): + return [] + + +def _registry_save(instances): + try: + os.makedirs(STATE_DIR, exist_ok=True) + tmp = REGISTRY + ".tmp" + with open(tmp, "w", encoding="utf-8") as handle: + json.dump([i.to_dict() for i in instances], handle, indent=1) + os.replace(tmp, REGISTRY) + except OSError as exc: + log.warning("Merkliste %s nicht schreibbar: %s", REGISTRY, exc) + + +def _registry_add(instance): + entries = [i for i in _registry_load() if i.vmid != instance.vmid] + entries.append(instance) + _registry_save(entries) + + +def _registry_remove(vmid): + _registry_save([i for i in _registry_load() if i.vmid != int(vmid)]) + + +# --------------------------------------------------------------------------- +# Konfigurationsdatei des neuen Gastes +# --------------------------------------------------------------------------- + +def config_path(node, guest_type, vmid): + sub = "lxc" if guest_type == "lxc" else "qemu-server" + return os.path.join(CONF_ROOT, node, sub, "%d.conf" % vmid) + + +def _write_config(path, config, description=""): + """Konfiguration anlegen - und dabei die VMID belegen. + + O_EXCL: existiert die Datei schon, gehoert die VMID jemand anderem. Auf + /etc/pve ist das die zuverlaessigste Reservierung, die es gibt. + Die Beschreibung steht bei Proxmox als '#'-Zeilen am Dateianfang. + """ + lines = [] + for line in str(description or "").rstrip("\n").split("\n"): + if description: + lines.append("#" + line) + for key in sorted(config): + value = config[key] + if value is None or value == "": + continue + lines.append("%s: %s" % (key, value)) + text = "\n".join(lines) + "\n" + + directory = os.path.dirname(path) + if not os.path.isdir(directory): + raise RecoveryError("Verzeichnis %s gibt es nicht - Node-Name richtig?" + % directory) + try: + handle = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o640) + except FileExistsError: + raise RecoveryError("%s gibt es bereits - die VMID ist belegt." % path) + except OSError as exc: + raise RecoveryError("%s nicht anlegbar: %s" % (path, exc)) + try: + os.write(handle, text.encode("utf-8")) + finally: + os.close(handle) + + +def _replace_volid(value, new_volid): + parts = str(value).split(",") + parts[0] = new_volid + return ",".join(parts) + + +def _volid_of(value): + first = str(value).split(",", 1)[0].strip() + return first if ":" in first and first not in ("none", "cdrom") else "" + + +def _is_cdrom(value): + return "media=cdrom" in str(value) + + +def _set_option(value, option, wanted): + """Eine Option in einem Proxmox-Wertfeld setzen oder ersetzen.""" + parts = [p for p in str(value).split(",") if not p.startswith(option + "=")] + parts.append("%s=%s" % (option, wanted)) + return ",".join(parts) + + +# --------------------------------------------------------------------------- +# Planen +# --------------------------------------------------------------------------- + +def _disk_keys(config, guest_type): + if guest_type == "lxc": + return [k for k in sorted(config) if LXC_DISK_KEY.match(k)] + keys = [k for k in sorted(config) if QEMU_DISK_KEY.match(k)] + keys += [k for k in QEMU_EXTRA_DISKS if k in config] + return keys + + +def plan(proxmox, guest, snapname, spec): + """Alles pruefen und zusammenstellen, ohne etwas zu veraendern.""" + config = proxmox.guest_config(guest, snapshot=snapname) + result = Plan(guest=guest, snapshot=snapname, spec=spec) + + # -- Ziel-VMID und Node ------------------------------------------------ + if spec.newid: + if not proxmox.vmid_free(spec.newid): + raise RecoveryError("VMID %d ist bereits vergeben." % spec.newid) + result.newid = int(spec.newid) + else: + result.newid = proxmox.next_vmid() + result.node = spec.node or guest.node + + # -- Datentraeger ------------------------------------------------------ + for key in _disk_keys(config, guest.type): + value = str(config[key]) + if _is_cdrom(value): + continue + volid = _volid_of(value) + if not volid: + if guest.type == "lxc" and "mp=" in value: + result.dropped.append("%s (%s)" % (key, value.split(",")[0])) + continue + size = "" + for part in value.split(",")[1:]: + if part.startswith("size="): + size = part[5:] + result.disks.append((key, volid, size)) + if not result.disks: + raise RecoveryError("Der Snapshot enthaelt keine Datentraeger, die sich " + "klonen liessen.") + + # Storages muessen die Klone tragen koennen und vom Ziel-Node erreichbar sein. + for _key, volid, _size in result.disks: + storage = volid.split(":", 1)[0] + info = _storage_info(proxmox, storage) + nodes = [n.strip() for n in str(info.get("nodes") or "").split(",") if n.strip()] + if nodes and result.node not in nodes: + raise RecoveryError("Storage '%s' ist auf Node '%s' nicht verfuegbar." + % (storage, result.node)) + if not info.get("shared") and result.node != guest.node: + raise RecoveryError("Storage '%s' ist nicht geteilt - die Wiederherstellung " + "muss auf '%s' laufen." % (storage, guest.node)) + + # -- Arbeitsspeicher --------------------------------------------------- + state_volid = str(config.get("vmstate") or "") + if state_volid and guest.type == "qemu": + result.vmstate = state_volid + want = spec.resume if spec.resume is not None else True + result.resume = bool(want) + if result.resume: + try: + result.vmstate_bytes = volume_size(state_volid) + except RecoveryError: + result.vmstate_bytes = 0 + elif spec.resume: + result.notes.append("Der Snapshot enthaelt keinen Arbeitsspeicher - die " + "Maschine bootet kalt (wie nach einem Stromausfall).") + + # -- Netzwerk ---------------------------------------------------------- + result.nets = [(k, str(config[k])) for k in sorted(config) if NET_KEY.match(k)] + if spec.net: + result.net_mode = spec.net + else: + result.net_mode = "on" if not spec.isolated else "none" + + if result.resume and result.net_mode == "none" and result.nets: + # Ein gespeicherter RAM-Zustand laesst sich nur in eine Maschine mit + # genau denselben Geraeten laden. Die Netzwerkkarte muss also bleiben - + # abgeklemmt wird stattdessen die Leitung. + result.net_mode = "down" + result.notes.append("Netzwerkkarte bleibt vorhanden, aber abgeklemmt " + "(link_down): mit geladenem Arbeitsspeicher darf sich " + "die Geraeteausstattung nicht aendern.") + + if result.net_mode == "on" and result.nets: + result.warnings.append( + "Die Wiederherstellung geht MIT Netzwerk online - mit denselben " + "MAC-Adressen wie das Original.") + status = proxmox.guest_status(guest) + if status.get("status") == "running": + result.warnings.append( + "%s LAEUFT gerade. Zwei Maschinen mit gleicher MAC-Adresse, " + "gleicher IP und gleicher Identitaet im selben Netz geben Chaos - " + "erst das Original stoppen." % guest.label) + + # -- Austauschlaufwerk ------------------------------------------------- + if spec.exchange_dir: + if guest.type != "lxc": + raise RecoveryError("--exchange-dir gibt es nur fuer Container. Bei " + "virtuellen Maschinen laesst sich kein Host-" + "Verzeichnis durchreichen - dort --exchange " + "verwenden.") + if not os.path.isdir(spec.exchange_dir): + raise RecoveryError("Verzeichnis gibt es nicht: %s" % spec.exchange_dir) + result.exchange_kind = "bind" + result.exchange_detail = "%s -> %s (im Gast)" % (spec.exchange_dir, + EXCHANGE_GUEST_PATH) + elif spec.exchange: + if guest.type == "lxc": + result.exchange_kind = "bind" + result.exchange_detail = "%s/%d -> %s (im Gast)" % ( + EXCHANGE_ROOT, result.newid, EXCHANGE_GUEST_PATH) + else: + size_kb = parse_size_kb(spec.exchange) + storage = spec.exchange_storage or result.disks[0][1].split(":", 1)[0] + if spec.exchange_fs not in MKFS: + raise RecoveryError("Dateisystem %r kenne ich nicht (%s)" + % (spec.exchange_fs, ", ".join(sorted(MKFS)))) + if not have(MKFS[spec.exchange_fs][0]): + raise RecoveryError("%s ist nicht installiert - anderes Dateisystem " + "waehlen oder Paket nachinstallieren." + % MKFS[spec.exchange_fs][0]) + result.exchange_kind = "disk" + result.exchange_detail = "%s auf %s, %s, im Gast als weitere Platte" % ( + human_bytes(size_kb * 1024), storage, spec.exchange_fs) + + # -- Hinweise ---------------------------------------------------------- + if result.dropped and not spec.keep_binds: + result.notes.append("Nicht uebernommen werden Einbindungen von Host-" + "Verzeichnissen: %s" % ", ".join(result.dropped)) + if spec.memory and result.resume: + raise RecoveryError("Ein geladener Arbeitsspeicher laesst sich nicht " + "vergroessern oder verkleinern - entweder --memory " + "weglassen oder --no-resume verwenden.") + if result.resume: + result.notes.append("Der Arbeitsspeicher (%s) wird kopiert; die Maschine " + "laeuft danach genau dort weiter, wo sie beim Snapshot " + "stand." % (human_bytes(result.vmstate_bytes) or "?")) + result.notes.append("Proxmox gibt den kopierten Arbeitsspeicher nach dem " + "ersten Start wieder frei - ein spaeterer Neustart " + "bootet dann kalt.") + return result + + +def _storage_info(proxmox, name): + data = proxmox._json(["get", "/storage/%s" % name]) + if not isinstance(data, dict): + raise RecoveryError("Storage %r nicht gefunden" % name) + return data + + +# --------------------------------------------------------------------------- +# Einrichten +# --------------------------------------------------------------------------- + +def create(proxmox, plan_, progress=None): + """Den Plan umsetzen. Bei einem Fehler wird alles wieder abgeraeumt.""" + def step(text): + log.info("%s", text) + if progress: + progress(text) + + guest = plan_.guest + spec = plan_.spec + config = proxmox.guest_config(guest, snapshot=plan_.snapshot) + + created = [] # Volumes, die wir angelegt haben + protected = [] # [volid, snapname] deren Schutz wir loesen muessen + made_dirs = [] + conf_file = "" + + try: + new_config = {} + for key, value in config.items(): + if key in DROP_KEYS or NET_KEY.match(key): + continue + if QEMU_DISK_KEY.match(key) or LXC_DISK_KEY.match(key) \ + or key in QEMU_EXTRA_DISKS: + continue + new_config[key] = value + + # -- Datentraeger klonen ------------------------------------------ + for index, (key, volid, _size) in enumerate(plan_.disks, 1): + step("Klone %s (%d/%d): %s" % (key, index, len(plan_.disks), volid)) + clone = clone_volume(volid, plan_.newid, plan_.snapshot) + created.append(clone) + protected.append([volid, plan_.snapshot]) + new_config[key] = _replace_volid(config[key], clone) + + # CD-Laufwerke und nicht klonbare Einbindungen + for key in _disk_keys(config, guest.type): + if key in new_config: + continue + value = str(config[key]) + if _is_cdrom(value): + new_config[key] = value + elif guest.type == "lxc" and not _volid_of(value): + if spec.keep_binds: + new_config[key] = value + + # -- Arbeitsspeicher ---------------------------------------------- + if plan_.resume and plan_.vmstate: + step("Kopiere Arbeitsspeicher (%s) - das dauert einen Moment" + % (human_bytes(plan_.vmstate_bytes) or "?")) + copy = _copy_state(plan_.vmstate, plan_.newid) + created.append(copy) + new_config["vmstate"] = copy + for key in ("runningmachine", "runningcpu"): + if config.get(key): + new_config[key] = config[key] + + # -- Netzwerk ------------------------------------------------------ + for key, value in plan_.nets: + if plan_.net_mode == "none": + continue + if plan_.net_mode == "down": + value = _set_option(value, "link_down", "1") + new_config[key] = value + + # -- Austauschlaufwerk --------------------------------------------- + exchange = {} + if plan_.exchange_kind == "disk": + storage = spec.exchange_storage or plan_.disks[0][1].split(":", 1)[0] + size_kb = parse_size_kb(spec.exchange) + step("Lege Austauschlaufwerk an (%s, %s)" + % (human_bytes(size_kb * 1024), spec.exchange_fs)) + volid = alloc_volume(storage, plan_.newid, size_kb) + created.append(volid) + _format_exchange(volid, spec.exchange_fs) + slot = _free_disk_slot(new_config) + drive = "%s,backup=0" % volid + exchange = {"kind": "disk", "volid": volid, "key": slot, + "fs": spec.exchange_fs, "drive": drive, + "pending": bool(plan_.resume)} + # Mit geladenem Arbeitsspeicher wacht der Gast in einem Zustand auf, + # in dem es diese Platte nicht gab - er wuerde sie nie bemerken. + # Deshalb kommt sie erst nach dem Fortsetzen dazu, per Hotplug. + if not plan_.resume: + new_config[slot] = drive + elif plan_.exchange_kind == "bind": + host_dir = spec.exchange_dir or os.path.join(EXCHANGE_ROOT, + str(plan_.newid)) + if not spec.exchange_dir: + os.makedirs(host_dir, exist_ok=True) + made_dirs.append(host_dir) + _prepare_bind_dir(host_dir, str(config.get("unprivileged")) == "1") + step("Reiche %s in den Container durch" % host_dir) + slot = _free_mp_slot(new_config) + new_config[slot] = "%s,mp=%s" % (host_dir, EXCHANGE_GUEST_PATH) + exchange = {"kind": "bind", "path": host_dir, "key": slot, + "own": not spec.exchange_dir} + + # -- restliche Anpassungen ----------------------------------------- + if spec.memory: + new_config["memory"] = spec.memory + if spec.cores: + new_config["cores"] = spec.cores + new_config["tags"] = TAG + if guest.type == "qemu": + new_config["name"] = spec.name or _default_name(guest, plan_) + + description = _description(guest, plan_) + + # -- Konfiguration schreiben --------------------------------------- + conf_file = config_path(plan_.node, guest.type, plan_.newid) + step("Lege %s an" % conf_file) + _write_config(conf_file, new_config, description) + + instance = Instance( + vmid=plan_.newid, type=guest.type, node=plan_.node, + name=str(new_config.get("name") or new_config.get("hostname") or ""), + source=guest.vmid, source_node=guest.node, snapshot=plan_.snapshot, + mode=spec.mode, created=int(time.time()), volumes=created, + protected=protected, exchange=exchange, resumed=plan_.resume) + _registry_add(instance) + return instance + + except Exception as exc: + step("Fehler - raeume wieder ab: %s" % exc) + if conf_file and os.path.exists(conf_file): + try: + os.unlink(conf_file) + except OSError: + pass + for volid in reversed(created): + try: + free_volume(volid) + except RecoveryError as cleanup_exc: + log.warning("Klon %s blieb liegen: %s", volid, cleanup_exc) + for volid, snapname in protected: + try: + unprotect_snapshot(proxmox, volid, snapname) + except RecoveryError: + pass + for path in made_dirs: + shutil.rmtree(path, ignore_errors=True) + raise + + +# Woran zu erkennen ist, dass der Arbeitsspeicher nicht angekommen ist. +# Proxmox meldet den Startvorgang trotzdem mit "TASK OK" - die Maschine bleibt +# dann aber angehalten stehen, ohne dass irgendwo ein Fehler sichtbar waere. +_STATE_FAILED = ("Error while loading VM state", "Unknown savevm section", + "Make sure that your current VM setup matches") + + +def start(proxmox, instance, progress=None): + """Hochfahren - und nachsehen, ob dabei herauskam, was gewollt war. + + Liefert einen Hinweistext, wenn etwas anders lief als geplant, sonst "". + """ + def step(text): + log.info("%s", text) + if progress: + progress(text) + + upid = proxmox.start_guest(instance.guest) + if instance.type != "qemu": + return "" + + note = "" + if instance.resumed and upid: + broken = [line.strip() for line in proxmox.task_log(instance.node, upid) + if any(marker in line for marker in _STATE_FAILED)] + if broken: + note = ("Der Arbeitsspeicher liess sich nicht laden - die Maschine " + "startet stattdessen kalt vom Datentraeger. Proxmox meldet: %s" + % broken[0]) + + # Nach dem Laden eines RAM-Standes steht die Maschine angehalten da und + # muss noch fortgesetzt werden. + for _ in range(15): + qmp = (proxmox.guest_status(instance.guest).get("qmpstatus") or "") + if qmp == "running": + return _attach_pending(proxmox, instance, step) or note + if qmp in ("paused", "prelaunch"): + step("Setze %s fort" % instance.label) + try: + proxmox.resume_guest(instance.guest) + except ProxmoxError as exc: + return "%s laesst sich nicht fortsetzen: %s" % (instance.label, exc) + time.sleep(1.0) + if (proxmox.guest_status(instance.guest).get("qmpstatus") or "") == "running": + return _attach_pending(proxmox, instance, step) or note + time.sleep(1.0) + return note or ("%s ist gestartet, laeuft aber nicht. Warum, steht im " + "Task-Protokoll von Proxmox." % instance.label) + + +def _attach_pending(proxmox, instance, step=None): + """Das Austauschlaufwerk an die schon laufende Maschine anstecken.""" + exchange = instance.exchange or {} + if not exchange.get("pending"): + return "" + key, drive = exchange.get("key"), exchange.get("drive") + if not key or not drive: + return "" + if step: + step("Stecke Austauschlaufwerk als %s an" % key) + try: + proxmox.set_guest_config(instance.guest, {key: drive}) + config = proxmox.guest_config(instance.guest) + except ProxmoxError as exc: + return "Das Austauschlaufwerk liess sich nicht anstecken: %s" % exc + + if key not in config: + # Proxmox hat es nur vorgemerkt - im Gast taucht es dann nicht auf. + return ("Das Austauschlaufwerk ist eingetragen, aber nicht angesteckt " + "worden (Hotplug fuer Platten ist bei dieser Maschine aus). " + "Es erscheint erst nach einem Neustart des Gastes.") + exchange["pending"] = False + _registry_add(instance) + return "" + + +def _default_name(guest, plan_): + base = re.sub(r"[^A-Za-z0-9-]", "-", guest.name or "vm%d" % guest.vmid)[:40] + return ("%s-wdh" % base).strip("-") + + +def _description(guest, plan_): + return "\n".join([ + "%s - von pvesnap angelegt, nicht die Originalmaschine." % TAG, + "", + "Quelle: %s auf %s" % (guest.label, guest.node), + "Snapshot: %s" % plan_.snapshot, + "Modus: %s" % ("recovery (mit Netzwerk)" if plan_.net_mode == "on" + else "live (ohne Netzwerk)"), + "Angelegt: %s" % time.strftime("%Y-%m-%d %H:%M:%S"), + "", + "Verwerfen: pvesnap-recovery destroy %d" % plan_.newid, + ]) + + +def _free_disk_slot(config): + for index in range(1, 31): + for prefix in ("scsi", "virtio", "sata"): + key = "%s%d" % (prefix, index) + if key not in config and any(k.startswith(prefix) for k in config): + return key + for index in range(1, 31): + if "scsi%d" % index not in config: + return "scsi%d" % index + raise RecoveryError("Kein freier Platz fuer ein Austauschlaufwerk.") + + +def _free_mp_slot(config): + for index in range(0, 256): + if "mp%d" % index not in config: + return "mp%d" % index + raise RecoveryError("Kein freier Einhaengepunkt mehr frei.") + + +def _prepare_bind_dir(path, unprivileged): + """Damit der Container hineinschreiben darf.""" + try: + if unprivileged: + os.chown(path, 100000, 100000) # Standard-Verschiebung von PVE + os.chmod(path, 0o777) + except OSError as exc: + log.warning("Rechte auf %s nicht setzbar: %s", path, exc) + + +# --------------------------------------------------------------------------- +# Arbeitsspeicher kopieren +# --------------------------------------------------------------------------- + +def _copy_state(volid, newid): + """Den RAM-Stand des Snapshots duplizieren. + + Kopiert wird, nicht geklont: Proxmox gibt den Arbeitsspeicher nach dem + Laden selbst wieder frei - das darf auf keinen Fall den Snapshot des + Originals treffen. + """ + if not have("qemu-img"): + raise RecoveryError("'qemu-img' fehlt (apt install qemu-utils) - ohne das " + "laesst sich der Arbeitsspeicher nicht uebernehmen.") + storage = volid.split(":", 1)[0] + size = volume_size(volid) + if size <= 0: + raise RecoveryError("Groesse von %s nicht ermittelbar" % volid) + + target = alloc_volume(storage, newid, max(1, (size + 1023) // 1024)) + try: + storage_op("activate", volid, target, timeout=300) + source_path = volume_path(volid) + target_path = volume_path(target) + run(["qemu-img", "convert", "-O", "raw", "-n", source_path, target_path], + timeout=7200) + except Exception: + try: + free_volume(target) + except RecoveryError: + pass + raise + return target + + +# --------------------------------------------------------------------------- +# Austauschlaufwerk formatieren +# --------------------------------------------------------------------------- + +def _format_exchange(volid, fstype): + with _block_device(volid) as device: + run(MKFS[fstype] + [device], timeout=900) + _place_readme(device, fstype) + + +class _block_device: + """Ein Volume als echtes Blockgeraet - noetig, weil `pvesm path` bei Ceph + nur eine qemu-Adresse (rbd:pool/image:...) liefert, kein /dev/...""" + + def __init__(self, volid): + self.volid = volid + self.device = "" + self.mapped = False + + def __enter__(self): + storage_op("activate", self.volid, timeout=300) + path = volume_path(self.volid) + if path.startswith("rbd:"): + self.device = self._map_rbd(path) + self.mapped = True + else: + self.device = path + if not os.path.exists(self.device): + raise RecoveryError("%s ist nicht als Geraet aufgetaucht" % self.device) + return self.device + + def __exit__(self, *_exc): + if self.mapped and self.device: + run(["rbd", "unmap", self.device], check=False, timeout=120) + return False + + @staticmethod + def _map_rbd(path): + # rbd:data/vm-9802-disk-1:conf=/etc/pve/ceph.conf:id=admin:keyring=... + fields = path[4:].split(":") + image = fields[0] + args = ["rbd", "map", image] + for field_ in fields[1:]: + key, _, value = field_.partition("=") + if key == "id": + args += ["--id", value] + elif key == "keyring": + args += ["--keyring", value] + elif key == "conf": + args += ["--conf", value] + elif key == "mon_host": + args += ["-m", value.replace(";", ",")] + output = run(args, timeout=180) + for line in output.splitlines(): + if line.strip().startswith("/dev/"): + return line.strip() + raise RecoveryError("rbd map lieferte keinen Geraetenamen: %s" % output) + + +def _place_readme(device, fstype): + """Kurze Anleitung auf das leere Laufwerk legen.""" + mountpoint = os.path.join(EXCHANGE_MOUNT, "format-%d" % os.getpid()) + try: + os.makedirs(mountpoint, exist_ok=True) + run(["mount", "-t", fstype, device, mountpoint], timeout=120) + except (RecoveryError, OSError) as exc: + log.debug("Anleitung nicht ablegbar: %s", exc) + return + try: + with open(os.path.join(mountpoint, "LIESMICH.txt"), "w", + encoding="utf-8") as handle: + handle.write(README) + except OSError as exc: + log.debug("LIESMICH.txt nicht schreibbar: %s", exc) + finally: + run(["umount", mountpoint], check=False, timeout=120) + try: + os.rmdir(mountpoint) + except OSError: + pass + + +# --------------------------------------------------------------------------- +# Uebersicht, Verwerfen, Austausch abholen +# --------------------------------------------------------------------------- + +def list_instances(proxmox, refresh=True): + """Bekannte Wiederherstellungen - Merkliste und Cluster zusammengefuehrt. + + Der Cluster wird mitgelesen, damit auch etwas auftaucht, das nach einem + verlorenen Zustandsspeicher nur noch an seiner Markierung erkennbar ist. + """ + known = {i.vmid: i for i in _registry_load()} + try: + guests = proxmox.inventory(refresh=refresh) + except ProxmoxError as exc: + log.warning("Cluster nicht abfragbar: %s", exc) + guests = [] + + alive = set() + for guest in guests: + if TAG not in guest.tags: + continue + alive.add(guest.vmid) + instance = known.get(guest.vmid) + if instance is None: + instance = Instance(vmid=guest.vmid, type=guest.type, node=guest.node, + name=guest.name, mode="?", snapshot="?") + known[guest.vmid] = instance + instance.node = guest.node or instance.node + instance.name = guest.name or instance.name + instance.type = guest.type + instance.status = guest.status + + for vmid, instance in known.items(): + if vmid not in alive and guests: + instance.status = "weg" + + return sorted(known.values(), key=lambda i: i.vmid) + + +def forget(vmid): + _registry_remove(vmid) + + +def destroy(proxmox, instance, progress=None, keep_snapshot_protection=False): + """Eine Wiederherstellung restlos entfernen.""" + def step(text): + log.info("%s", text) + if progress: + progress(text) + + guest = instance.guest + exists = proxmox.guest_exists(instance.vmid) + + if exists: + # Absicherung: nur anfassen, was unsere Markierung traegt. + config = proxmox.guest_config(guest) + tags = [t.strip().lower() + for t in str(config.get("tags") or "").replace(",", ";").split(";")] + if TAG not in tags: + raise RecoveryError( + "%s traegt die Markierung '%s' nicht - das ist keine von pvesnap " + "angelegte Wiederherstellung und wird nicht angefasst." + % (instance.label, TAG)) + + status = proxmox.guest_status(guest) + if status.get("status") == "running": + step("Schalte %s aus" % instance.label) + try: + proxmox.stop_guest(guest) + except ProxmoxError as exc: + raise RecoveryError("%s laesst sich nicht stoppen: %s" + % (instance.label, exc)) + + step("Entferne %s samt Klonen" % instance.label) + try: + proxmox.destroy_guest(guest) + except ProxmoxError as exc: + raise RecoveryError("%s laesst sich nicht entfernen: %s" + % (instance.label, exc)) + else: + # Der Gast ist weg, seine Klone koennen es trotzdem noch geben. + for volid in instance.volumes: + try: + step("Entferne uebrig gebliebenen Klon %s" % volid) + free_volume(volid) + except RecoveryError as exc: + log.warning("%s: %s", volid, exc) + + if not keep_snapshot_protection: + for entry in instance.protected: + try: + volid, snapname = entry[0], entry[1] + except (IndexError, TypeError): + continue + step("Hebe Schutz von %s@%s auf" % (volid, snapname)) + try: + unprotect_snapshot(proxmox, volid, snapname) + except RecoveryError as exc: + log.warning("Schutz von %s@%s blieb: %s", volid, snapname, exc) + + exchange = instance.exchange or {} + if exchange.get("kind") == "bind" and exchange.get("own") and exchange.get("path"): + step("Entferne Austauschverzeichnis %s" % exchange["path"]) + shutil.rmtree(exchange["path"], ignore_errors=True) + + _registry_remove(instance.vmid) + step("Fertig.") + + +def pull(proxmox, instance): + """Austauschlaufwerk auf dem Host verfuegbar machen; liefert den Pfad.""" + exchange = instance.exchange or {} + if not exchange: + raise RecoveryError("%s hat kein Austauschlaufwerk." % instance.label) + + if exchange.get("kind") == "bind": + path = exchange.get("path") or "" + if not os.path.isdir(path): + raise RecoveryError("Austauschverzeichnis gibt es nicht: %s" % path) + return path + + volid = exchange.get("volid") + if not volid: + raise RecoveryError("Zu %s ist kein Austauschlaufwerk vermerkt." + % instance.label) + + status = proxmox.guest_status(instance.guest) + if status.get("status") == "running": + raise RecoveryError("%s laeuft noch. Erst herunterfahren - sonst schreiben " + "Gast und Host gleichzeitig auf dasselbe Dateisystem." + % instance.label) + + mountpoint = os.path.join(EXCHANGE_MOUNT, str(instance.vmid)) + if os.path.ismount(mountpoint): + return mountpoint + os.makedirs(mountpoint, exist_ok=True) + + holder = _block_device(volid) + device = holder.__enter__() + try: + fstype = exchange.get("fs") or "auto" + run(["mount", "-o", "ro", "-t", fstype, device, mountpoint], timeout=120) + except RecoveryError: + holder.__exit__() + raise + _remember_mount(instance.vmid, mountpoint, device, holder.mapped) + return mountpoint + + +def release(vmid): + """Ein mit pull() eingehaengtes Austauschlaufwerk wieder loesen.""" + entry = _mounts().pop(str(vmid), None) + _save_mounts(_mounts_without(vmid)) + if not entry: + return False + run(["umount", entry["mountpoint"]], check=False, timeout=120) + if entry.get("mapped"): + run(["rbd", "unmap", entry["device"]], check=False, timeout=120) + try: + os.rmdir(entry["mountpoint"]) + except OSError: + pass + return True + + +_MOUNTS = os.path.join("/run/pvesnap", "recovery-mounts.json") + + +def _mounts(): + try: + with open(_MOUNTS, "r", encoding="utf-8") as handle: + data = json.load(handle) + return data if isinstance(data, dict) else {} + except (OSError, ValueError): + return {} + + +def _mounts_without(vmid): + data = _mounts() + data.pop(str(vmid), None) + return data + + +def _save_mounts(data): + try: + os.makedirs(os.path.dirname(_MOUNTS), exist_ok=True) + with open(_MOUNTS, "w", encoding="utf-8") as handle: + json.dump(data, handle, indent=1) + except OSError as exc: + log.debug("Mount-Merkliste nicht schreibbar: %s", exc) + + +def _remember_mount(vmid, mountpoint, device, mapped): + data = _mounts() + data[str(vmid)] = {"mountpoint": mountpoint, "device": device, "mapped": mapped} + _save_mounts(data) diff --git a/pvesnap/recovery_ui.py b/pvesnap/recovery_ui.py new file mode 100644 index 0000000..79aacf1 --- /dev/null +++ b/pvesnap/recovery_ui.py @@ -0,0 +1,953 @@ +"""pvesnap-recovery - einen Snapshot als Maschine starten. + +Ohne Argumente kommt die ncurses-Oberflaeche, alles geht aber auch direkt: + + pvesnap-recovery Uebersicht und Assistent + pvesnap-recovery list was gerade laeuft + pvesnap-recovery live 802 neuesten Snapshot ohne Netz starten + pvesnap-recovery live 802 --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, MKFS, RecoveryError, Spec, create, + destroy, human_bytes, list_instances, plan, pull, release, + start) +from .snapfs import list_snapshots +from .util import truncate + +log = logging.getLogger("pvesnap.recovery_ui") + +KEYS = [ + ("Enter", "Details", "Detail"), + ("n", "Neu", "Neu"), + ("s", "Starten", "Start"), + ("h", "Herunterfahren", "Stop"), + ("a", "Austausch", "Austau."), + ("x", "Verwerfen", "Verwerf."), + ("r", "Aktualisieren", "Aktual."), + ("q", "Beenden", "Ende"), +] + +NET_CHOICES = ( + ("", "automatisch (nach Modus)"), + ("none", "keine Netzwerkkarte"), + ("down", "Karte vorhanden, abgeklemmt"), + ("on", "voll am Netz - Vorsicht!"), +) + +RESUME_CHOICES = ( + (None, "wenn im Snapshot vorhanden"), + (True, "ja, Arbeitsspeicher laden"), + (False, "nein, kalt starten"), +) + + +# --------------------------------------------------------------------------- +# Kleine Bausteine +# --------------------------------------------------------------------------- + +def _wait_screen(win, text, title="pvesnap-recovery"): + height, width = win.getmaxyx() + win.erase() + fill(win, 0, " " + title, curses.color_pair(C_HEADER) | curses.A_BOLD) + put(win, height // 2, max(2, (width - len(text)) // 2), text, curses.A_BOLD) + win.refresh() + + +def _pages(win, lines, title, footer="beliebige Taste = weiter"): + """Einen Textblock anzeigen, bei Bedarf seitenweise.""" + height, width = win.getmaxyx() + rows = max(1, height - 4) + top = 0 + while True: + win.erase() + fill(win, 0, " " + title, curses.color_pair(C_HEADER) | curses.A_BOLD) + for row, (text, attr) in enumerate(lines[top:top + rows]): + put(win, row + 2, 2, truncate(text, width - 4), attr) + more = " (%d-%d von %d)" % (top + 1, min(top + rows, len(lines)), len(lines)) \ + if len(lines) > rows else "" + fill(win, height - 1, " " + footer + more, curses.color_pair(C_FOOTER)) + win.refresh() + key = read_key(win) + if key == curses.KEY_DOWN and top + rows < len(lines): + top += 1 + elif key == curses.KEY_UP and top > 0: + top -= 1 + elif key == curses.KEY_NPAGE and top + rows < len(lines): + top = min(len(lines) - rows, top + rows) + elif key == curses.KEY_PPAGE: + top = max(0, top - rows) + else: + return key + + +def _plain(lines): + return [(text, 0) for text in lines] + + +class _Progress: + """Fortschrittsanzeige waehrend des Anlegens.""" + + def __init__(self, win, title): + self.win = win + self.title = title + self.lines = [] + + def __call__(self, text): + self.lines.append(text) + height, width = self.win.getmaxyx() + self.win.erase() + fill(self.win, 0, " " + self.title, curses.color_pair(C_HEADER) | curses.A_BOLD) + visible = self.lines[-(height - 4):] + for row, line in enumerate(visible): + attr = curses.A_BOLD if line is visible[-1] else curses.color_pair(C_DIM) + put(self.win, row + 2, 2, truncate(line, width - 4), attr) + fill(self.win, height - 1, " bitte warten ...", curses.color_pair(C_FOOTER)) + self.win.refresh() + + +# --------------------------------------------------------------------------- +# Auswahl +# --------------------------------------------------------------------------- + +def _pick_guest(win, guests): + entries = [(g, "%s %-6d %-24s %-8s %s" + % ("LXC" if g.type == "lxc" else "VM ", g.vmid, + truncate(g.name or "-", 24), g.status, g.node)) + for g in guests] + return choose(win, "Von welchem Gast? (Esc = zurueck)", entries) + + +def _pick_snapshot(win, snapshots): + entries = [] + for snap in snapshots: + when = (datetime.fromtimestamp(snap.snaptime).strftime("%d.%m.%Y %H:%M") + if snap.snaptime else "?") + ram = "RAM" if "vmstate" in (snap.description or "") else "" + entries.append((snap.name, "%-34s %s %-4s %s" + % (truncate(snap.name, 34), when, ram, + truncate((snap.description or "").replace("\n", " "), 34)))) + return choose(win, "Welcher Snapshot? (Esc = zurueck)", entries) + + +# --------------------------------------------------------------------------- +# Optionsmaske +# --------------------------------------------------------------------------- + +def _fields(guest): + """Die Eingabefelder - je nach Gasttyp etwas andere.""" + rows = [ + ("mode", "Betriebsart", "choice", + (("live", "live - abgeschottet, ohne Netzwerk"), + ("recovery", "recovery - mit Netz, gleiche Identitaet"))), + ("net", "Netzwerk", "choice", NET_CHOICES), + ] + if guest.type == "qemu": + rows.append(("resume", "Arbeitsspeicher", "choice", RESUME_CHOICES)) + rows.append(("exchange", "Austauschlaufwerk", "text", ())) + rows.append(("exchange_fs", " Dateisystem", "choice", + tuple((f, f) for f in sorted(MKFS)))) + else: + rows.append(("exchange", "Austauschverzeichnis", "choice", + (("", "keins"), ("bind", "ja, unter %s im Gast" + % EXCHANGE_GUEST_PATH)))) + rows.append(("exchange_dir", " eigenes Host-Verzeichnis", "text", ())) + rows += [ + ("newid", "Neue VMID", "text", ()), + ("node", "Node", "text", ()), + ("start", "Danach starten", "choice", ((True, "ja"), (False, "nein"))), + ] + return rows + + +def _field_text(spec, key, kind, choices): + value = getattr(spec, key) + if kind == "choice": + for candidate, label in choices: + if candidate == value: + return label + return str(value) + if key == "newid": + return str(value) if value else "automatisch (naechste freie)" + if key == "exchange": + return value or "keins" + if key == "node": + return value or "wie das Original" + if key == "exchange_dir": + return value or "(eigenes anlegen)" + return str(value or "") + + +def _options(win, guest, spec): + """Maske fuer die Einstellungen. True = anlegen, False = abbrechen.""" + rows = _fields(guest) + index = 0 + while True: + height, width = win.getmaxyx() + win.erase() + fill(win, 0, " Wiederherstellung einrichten - %s" % guest.label, + curses.color_pair(C_HEADER) | curses.A_BOLD) + + for row, (key, label, kind, choices) in enumerate(rows): + selected = row == index + attr = curses.color_pair(C_SEL) if selected else 0 + text = " %-26s %s" % (label + ":", _field_text(spec, key, kind, choices)) + fill(win, row + 2, text.ljust(width - 2), attr) + + note = { + "mode": "live = zum Hineinschauen. recovery = die Maschine wirklich " + "wieder in Betrieb nehmen.", + "net": "Ohne Netz kommt nichts hinein und nichts hinaus - dafuer ist " + "das Austauschlaufwerk da.", + "resume": "Snapshots mit Arbeitsspeicher laufen genau dort weiter, wo " + "sie standen - ohne Crash-Recovery.", + "exchange": ("Groesse wie 20G. Leer = keins." + if guest.type == "qemu" else + "Ein Host-Verzeichnis, das im Container auftaucht."), + "exchange_fs": "Dateisystem des Austauschlaufwerks.", + "exchange_dir": "Leer = pvesnap legt selbst eines an. Hier passt auch " + "ein eingehaengtes Netzlaufwerk.", + "newid": "VMID der neuen Maschine.", + "node": "Auf welchem Node sie laufen soll.", + "start": "Sofort hochfahren.", + }.get(rows[index][0], "") + for offset, line in enumerate(_wrap(note, width - 6)[:3]): + put(win, len(rows) + 4 + offset, 3, line, curses.color_pair(C_DIM)) + + for offset, line in enumerate(keybar( + width, [("Pfeile", "Auswaehlen", "Wahl"), + ("Enter/Leer", "Aendern", "Aendern"), + ("F10", "Anlegen", "Anlegen"), + ("q", "Abbrechen", "Abbr.")])): + fill(win, height - 2 + offset, " " + line, curses.color_pair(C_FOOTER)) + win.refresh() + + key = read_key(win) + current_key, _label, kind, choices = rows[index] + if key == curses.KEY_UP: + index = (index - 1) % len(rows) + elif key == curses.KEY_DOWN: + index = (index + 1) % len(rows) + elif key in (curses.KEY_F10,) or key == "\x06": + return True + elif is_escape(key) or key == "q": + return False + elif is_enter(key) or key == " " or key in (curses.KEY_RIGHT, curses.KEY_LEFT): + _edit(win, spec, current_key, kind, choices, + back=key == curses.KEY_LEFT) + + +def _wrap(text, width): + words, lines, current = str(text).split(), [], "" + for word in words: + if len(current) + len(word) + 1 > width: + lines.append(current) + current = word + else: + current = (current + " " + word).strip() + if current: + lines.append(current) + return lines + + +def _edit(win, spec, key, kind, choices, back=False): + if kind == "choice": + values = [c[0] for c in choices] + try: + position = values.index(getattr(spec, key)) + except ValueError: + position = 0 + position = (position - 1 if back else position + 1) % len(values) + setattr(spec, key, values[position]) + if key == "mode": + # Die Betriebsart gibt die Vorgabe fuer das Netz vor. + spec.net = "" + return + + label = {"exchange": "Groesse (z.B. 20G, leer = keins)", + "newid": "Neue VMID (leer = automatisch)", + "node": "Node (leer = wie das Original)", + "exchange_dir": "Host-Verzeichnis (leer = selbst anlegen)"}.get(key, key) + answer = prompt(win, label, str(getattr(spec, key) or "")) + if answer is None: + return + if key == "newid": + spec.newid = int(answer) if answer.isdigit() else 0 + else: + setattr(spec, key, answer) + + +# --------------------------------------------------------------------------- +# Zusammenfassung +# --------------------------------------------------------------------------- + +def summary_lines(plan_): + """Was passieren wird - als Liste von (Text, Attribut).""" + guest, spec = plan_.guest, plan_.spec + lines = [ + ("Quelle: %s auf %s" % (guest.label, guest.node), 0), + ("Snapshot: %s" % plan_.snapshot, 0), + ("Neu: %s auf %s" % (plan_.label, plan_.node), curses.A_BOLD), + ("Betriebsart: %s" % ("recovery - mit Netz, gleiche Identitaet" + if spec.mode == "recovery" + else "live - abgeschottet"), 0), + ("", 0), + ("Datentraeger (Copy-on-Write-Klone, das Original bleibt unberuehrt):", 0), + ] + for key, volid, size in plan_.disks: + lines.append((" %-9s %s%s" % (key, volid, (" %s" % size) if size else ""), 0)) + + lines.append(("", 0)) + netzwerk = {"none": "keine Netzwerkkarte", + "down": "Karte vorhanden, Leitung abgeklemmt", + "on": "AM NETZ - mit den MAC-Adressen des Originals"} + lines.append(("Netzwerk: %s" % netzwerk.get(plan_.net_mode, plan_.net_mode), + curses.color_pair(C_WARN) if plan_.net_mode == "on" else 0)) + if plan_.nets: + for key, value in plan_.nets: + lines.append((" %-9s %s" % (key, truncate(value, 60)), 0)) + + if plan_.vmstate: + lines.append(("Arbeitsspeicher: %s" + % ("wird geladen (%s) - laeuft weiter statt zu booten" + % (human_bytes(plan_.vmstate_bytes) or "?") + if plan_.resume else "vorhanden, wird aber nicht geladen"), + curses.color_pair(C_OK) if plan_.resume else 0)) + if plan_.exchange_kind: + lines.append(("Austausch: %s" % plan_.exchange_detail, 0)) + + if plan_.notes: + lines.append(("", 0)) + for note in plan_.notes: + for line in _wrap("- " + note, 74): + lines.append((line, curses.color_pair(C_DIM))) + if plan_.warnings: + lines.append(("", 0)) + for warning in plan_.warnings: + for line in _wrap("!! " + warning, 74): + lines.append((line, curses.color_pair(C_WARN) | curses.A_BOLD)) + return lines + + +# --------------------------------------------------------------------------- +# Assistent +# --------------------------------------------------------------------------- + +def _new(stdscr, proxmox): + _wait_screen(stdscr, "Frage Proxmox nach VMs und Containern ...") + try: + guests = proxmox.inventory(refresh=True) + except ProxmoxError as exc: + message(stdscr, str(exc), error=True) + return + + while True: + guest = _pick_guest(stdscr, guests) + if guest is None: + return + + _wait_screen(stdscr, "Lese Snapshots von %s ..." % guest.label) + try: + snapshots = [s for s in list_snapshots(proxmox, guest) if s.complete] + except ProxmoxError as exc: + message(stdscr, str(exc), error=True) + continue + if not snapshots: + message(stdscr, "%s hat keine brauchbaren Snapshots." % guest.label, + error=True) + continue + + snapname = _pick_snapshot(stdscr, snapshots) + if snapname is None: + continue + + spec = Spec(exchange_dir="") + while True: + if not _options(stdscr, guest, spec): + break + _wait_screen(stdscr, "Pruefe, was noetig ist ...") + try: + plan_ = plan(proxmox, guest, snapname, spec) + except (RecoveryError, ProxmoxError) as exc: + _pages(stdscr, [(line, curses.color_pair(C_WARN)) + for line in _wrap(str(exc), 74)], + "Geht so nicht") + continue + + key = _pages(stdscr, summary_lines(plan_), + "Zusammenfassung - noch ist nichts passiert", + "j = anlegen | beliebige andere Taste = zurueck") + if key not in ("j", "J", "y", "Y"): + continue + if plan_.warnings and not confirm( + stdscr, "Warnungen gelesen und trotzdem anlegen?"): + continue + + progress = _Progress(stdscr, "Richte %s ein" % plan_.label) + try: + instance = create(proxmox, plan_, progress=progress) + except (RecoveryError, ProxmoxError, OSError) as exc: + _pages(stdscr, [(line, curses.color_pair(C_WARN)) + for line in _wrap(str(exc), 74)] + + [("", 0)] + _plain(progress.lines), + "Fehlgeschlagen") + break + + if spec.start: + progress("Starte %s ..." % instance.label) + try: + hint = start(proxmox, instance, progress=progress) + except (ProxmoxError, RecoveryError) as exc: + message(stdscr, "Angelegt, aber nicht gestartet: %s" % exc, + error=True) + else: + if hint: + _pages(stdscr, [(line, curses.color_pair(C_WARN)) + for line in _wrap(hint, 74)], + "Angelegt und gestartet - mit einem Hinweis") + _details(stdscr, proxmox, instance) + return + + +# --------------------------------------------------------------------------- +# Detailansicht +# --------------------------------------------------------------------------- + +def _details(stdscr, proxmox, instance): + while True: + status = proxmox.guest_status(instance.guest) + running = status.get("status") == "running" + lines = [ + ("Maschine: %s auf %s" % (instance.label, instance.node), curses.A_BOLD), + ("Zustand: %s" % (status.get("status") or "unbekannt"), + curses.color_pair(C_OK if running else C_DIM)), + ("Herkunft: %s" % instance.origin, 0), + ("Betriebsart: %s" % instance.mode, 0), + ("Angelegt: %s" % (datetime.fromtimestamp(instance.created) + .strftime("%d.%m.%Y %H:%M") + if instance.created else "?"), 0), + ("", 0), + ("Konsole im Browser:", 0), + ] + for line in _wrap(instance.console_url(), 74): + lines.append((" " + line, curses.color_pair(C_MARK))) + if instance.type == "lxc": + lines.append((" oder auf dem Host: pct enter %d" % instance.vmid, + curses.color_pair(C_MARK))) + + exchange = instance.exchange or {} + if exchange: + lines.append(("", 0)) + if exchange.get("kind") == "bind": + lines.append(("Austausch: %s" % exchange.get("path"), 0)) + lines.append((" im Gast unter %s" % EXCHANGE_GUEST_PATH, 0)) + else: + lines.append(("Austausch: %s (%s), im Gast als weitere Platte" + % (exchange.get("volid"), exchange.get("fs")), 0)) + lines.append((" nach dem Herunterfahren mit 'a' " + "auf den Host holen", curses.color_pair(C_DIM))) + if instance.resumed: + lines.append(("", 0)) + lines.append(("Der Arbeitsspeicher des Snapshots wurde geladen - beim " + "ersten Start lief die Maschine genau weiter.", + curses.color_pair(C_DIM))) + + footer = ("s Starten | h Herunterfahren | a Austausch | x Verwerfen | " + "q zurueck") + key = _pages(stdscr, lines, "Wiederherstellung %s" % instance.label, footer) + + if key in ("q", "\x1b") or is_escape(key): + return + if key in ("s", "S"): + _do(stdscr, "Starte %s ..." % instance.label, + lambda: start(proxmox, instance)) + elif key in ("h", "H"): + _do(stdscr, "Fahre %s herunter ..." % instance.label, + lambda: proxmox.shutdown_guest(instance.guest)) + elif key in ("a", "A"): + _pull(stdscr, proxmox, instance) + elif key in ("x", "X"): + if _destroy(stdscr, proxmox, instance): + return + + +def _do(stdscr, text, action): + _wait_screen(stdscr, text) + try: + hint = action() + except (ProxmoxError, RecoveryError) as exc: + message(stdscr, str(exc), error=True) + return + if isinstance(hint, str) and hint: + message(stdscr, hint, error=True) + + +def _pull(stdscr, proxmox, instance): + _wait_screen(stdscr, "Hole das Austauschlaufwerk auf den Host ...") + try: + path = pull(proxmox, instance) + except (RecoveryError, ProxmoxError, OSError) as exc: + message(stdscr, str(exc), error=True) + return + _pages(stdscr, _plain([ + "Das Austauschlaufwerk liegt jetzt hier:", + "", + " " + path, + "", + "Von dort kopieren, zum Beispiel mit:", + " pvesnap-explorer --path %s" % path, + " scp -r %s/. ziel:/pfad/" % path, + "", + "Wieder loesen mit: pvesnap-recovery release %d" % instance.vmid, + ]), "Austauschlaufwerk") + + +def _destroy(stdscr, proxmox, instance): + if not confirm(stdscr, "%s wirklich verwerfen? Alle Aenderungen darin sind weg." + % instance.label): + return False + progress = _Progress(stdscr, "Verwerfe %s" % instance.label) + try: + destroy(proxmox, instance, progress=progress) + except (RecoveryError, ProxmoxError) as exc: + _pages(stdscr, [(line, curses.color_pair(C_WARN)) + for line in _wrap(str(exc), 74)] + [("", 0)] + + _plain(progress.lines), "Fehlgeschlagen") + return False + return True + + +# --------------------------------------------------------------------------- +# Uebersicht +# --------------------------------------------------------------------------- + +def _overview(stdscr, proxmox): + index = 0 + instances = [] + reload_ = True + while True: + if reload_: + _wait_screen(stdscr, "Frage nach vorhandenen Wiederherstellungen ...") + instances = list_instances(proxmox) + reload_ = False + index = min(index, max(0, len(instances) - 1)) + + height, width = stdscr.getmaxyx() + stdscr.erase() + fill(stdscr, 0, " pvesnap-recovery %s - Snapshots als Maschine starten" + % __version__, curses.color_pair(C_HEADER) | curses.A_BOLD) + + if not instances: + put(stdscr, 3, 3, "Zurzeit laeuft keine Wiederherstellung.", curses.A_BOLD) + put(stdscr, 5, 3, "Mit n eine neue anlegen:", 0) + put(stdscr, 6, 5, "live Snapshot abgeschottet starten, ohne Netzwerk -", + curses.color_pair(C_DIM)) + put(stdscr, 7, 5, " zum Hineinschauen und Daten herausholen.", + curses.color_pair(C_DIM)) + put(stdscr, 8, 5, "recovery Snapshot mit Netzwerk und gleicher Identitaet", + curses.color_pair(C_DIM)) + put(stdscr, 9, 5, " wieder in Betrieb nehmen.", + curses.color_pair(C_DIM)) + else: + put(stdscr, 2, 2, "%-10s %-9s %-22s %-28s %s" + % ("Maschine", "Zustand", "Node", "Herkunft", "Modus"), + curses.A_BOLD | curses.A_UNDERLINE) + rows = max(1, height - 6) + top = max(0, min(index - rows + 1, len(instances) - rows)) + for row in range(min(rows, len(instances) - top)): + instance = instances[top + row] + attr = curses.color_pair(C_SEL) if top + row == index else 0 + if not attr and instance.status == "running": + attr = curses.color_pair(C_OK) + text = " %-10s %-9s %-22s %-28s %s" % ( + instance.label, instance.status or "?", + truncate(instance.node or "?", 22), + truncate(instance.origin, 28), instance.mode) + fill(stdscr, row + 3, text, attr) + + for offset, line in enumerate(keybar(width, KEYS)): + fill(stdscr, height - 2 + offset, " " + line, curses.color_pair(C_FOOTER)) + stdscr.refresh() + + key = read_key(stdscr) + current = instances[index] if instances else None + + if key in ("q", "Q") or is_escape(key): + return 0 + if key in ("n", "N"): + _new(stdscr, proxmox) + reload_ = True + elif key in ("r", "R"): + reload_ = True + elif key == curses.KEY_UP and instances: + index = (index - 1) % len(instances) + elif key == curses.KEY_DOWN and instances: + index = (index + 1) % len(instances) + elif current is None: + continue + elif is_enter(key): + _details(stdscr, proxmox, current) + reload_ = True + elif key in ("s", "S"): + _do(stdscr, "Starte %s ..." % current.label, + lambda: start(proxmox, current)) + reload_ = True + elif key in ("h", "H"): + _do(stdscr, "Fahre %s herunter ..." % current.label, + lambda: proxmox.shutdown_guest(current.guest)) + reload_ = True + elif key in ("a", "A"): + _pull(stdscr, proxmox, current) + elif key in ("x", "X"): + _destroy(stdscr, proxmox, current) + reload_ = True + + +def _main(stdscr, _args): + curses.curs_set(0) + if hasattr(curses, "set_escdelay"): + curses.set_escdelay(25) + init_colors() + stdscr.keypad(True) + return _overview(stdscr, Proxmox()) + + +# --------------------------------------------------------------------------- +# Befehlszeile +# --------------------------------------------------------------------------- + +def _find_guest(proxmox, vmid): + guest = next((g for g in proxmox.inventory() if g.vmid == int(vmid)), None) + if guest is None: + raise RecoveryError("Gast %s gibt es nicht." % vmid) + return guest + + +def _find_instance(proxmox, vmid): + instance = next((i for i in list_instances(proxmox) if i.vmid == int(vmid)), None) + if instance is None: + raise RecoveryError("%s ist keine von pvesnap angelegte Wiederherstellung. " + "Vorhandene zeigt: pvesnap-recovery list" % vmid) + return instance + + +def _newest_snapshot(proxmox, guest): + snapshots = [s for s in list_snapshots(proxmox, guest) if s.complete] + if not snapshots: + raise RecoveryError("%s hat keine brauchbaren Snapshots." % guest.label) + return snapshots[0].name + + +def _spec_from_args(args, mode): + spec = Spec(mode=mode) + spec.newid = args.newid or 0 + spec.node = args.node or "" + spec.net = args.net or "" + spec.resume = args.resume + spec.exchange = args.exchange or "" + spec.exchange_storage = args.exchange_storage or "" + spec.exchange_fs = args.exchange_fs + spec.exchange_dir = args.exchange_dir or "" + spec.memory = args.memory or 0 + spec.cores = args.cores or 0 + spec.name = args.name or "" + spec.keep_binds = args.keep_binds + spec.start = not args.no_start + if spec.exchange_dir and not spec.exchange: + spec.exchange = "bind" + return spec + + +def _print_summary(plan_): + print(" Quelle : %s auf %s" % (plan_.guest.label, plan_.guest.node)) + print(" Snapshot : %s" % plan_.snapshot) + print(" Neu : %s auf %s" % (plan_.label, plan_.node)) + for key, volid, size in plan_.disks: + print(" Klon : %-9s %s%s" % (key, volid, (" %s" % size) if size else "")) + netzwerk = {"none": "keine Netzwerkkarte", + "down": "Karte vorhanden, Leitung abgeklemmt", + "on": "AM NETZ mit den MAC-Adressen des Originals"} + print(" Netzwerk : %s" % netzwerk.get(plan_.net_mode, plan_.net_mode)) + if plan_.resume: + print(" Arbeitsspei.: wird geladen (%s)" + % (human_bytes(plan_.vmstate_bytes) or "?")) + if plan_.exchange_kind: + print(" Austausch : %s" % plan_.exchange_detail) + for note in plan_.notes: + print(" Hinweis : %s" % note) + for warning in plan_.warnings: + print(" ACHTUNG : %s" % warning) + + +def cmd_create(proxmox, args, mode): + guest = _find_guest(proxmox, args.vmid) + snapname = args.snapshot or _newest_snapshot(proxmox, guest) + spec = _spec_from_args(args, mode) + plan_ = plan(proxmox, guest, snapname, spec) + + print("Vorhaben:") + _print_summary(plan_) + if not args.yes: + if plan_.warnings: + print("\nEs gibt Warnungen - bitte oben lesen.") + answer = input("\nAnlegen? [j/N] ").strip().lower() + if answer not in ("j", "y", "ja", "yes"): + print("Abgebrochen.") + return 1 + + instance = create(proxmox, plan_, progress=lambda text: print(" " + text)) + print("\n%s ist eingerichtet." % instance.label) + if spec.start: + print(" Starte ...") + hint = start(proxmox, instance, progress=lambda text: print(" " + text)) + print(" laeuft." if not hint else " ACHTUNG: %s" % hint) + _print_access(instance) + return 0 + + +def _print_access(instance): + print("\n Konsole im Browser:") + print(" %s" % instance.console_url()) + if instance.type == "lxc": + print(" oder auf dem Host: pct enter %d" % instance.vmid) + exchange = instance.exchange or {} + if exchange.get("kind") == "bind": + print("\n Austausch: %s (im Gast unter %s)" + % (exchange.get("path"), EXCHANGE_GUEST_PATH)) + elif exchange.get("volid"): + print("\n Austausch: als weitere Platte im Gast (%s, Bezeichnung PVESNAP)" + % exchange.get("fs")) + print(" im Gast einhaengen, befuellen, Gast herunterfahren, dann:") + print(" pvesnap-recovery pull %d" % instance.vmid) + print("\n Wieder verwerfen: pvesnap-recovery destroy %d" % instance.vmid) + + +def cmd_list(proxmox, _args): + instances = list_instances(proxmox) + if not instances: + print("Zurzeit laeuft keine Wiederherstellung.") + return 0 + print("%-10s %-9s %-14s %-30s %s" + % ("Maschine", "Zustand", "Node", "Herkunft", "Modus")) + for instance in instances: + print("%-10s %-9s %-14s %-30s %s" + % (instance.label, instance.status or "?", instance.node or "?", + instance.origin, instance.mode)) + return 0 + + +def cmd_destroy(proxmox, args): + instance = _find_instance(proxmox, args.vmid) + if not args.yes: + print("%s (%s) wird restlos entfernt - alle Aenderungen darin sind weg." + % (instance.label, instance.origin)) + if input("Wirklich? [j/N] ").strip().lower() not in ("j", "y", "ja", "yes"): + print("Abgebrochen.") + return 1 + destroy(proxmox, instance, progress=lambda text: print(" " + text)) + return 0 + + +def cmd_pull(proxmox, args): + instance = _find_instance(proxmox, args.vmid) + path = pull(proxmox, instance) + print(path) + if not args.quiet: + print("\nDas Austauschlaufwerk liegt jetzt auf dem Host.", file=sys.stderr) + print(" pvesnap-explorer --path %s" % path, file=sys.stderr) + print(" Wieder loesen: pvesnap-recovery release %d" % instance.vmid, + file=sys.stderr) + return 0 + + +def cmd_release(_proxmox, args): + print("geloest." if release(args.vmid) else "War nicht eingehaengt.") + return 0 + + +def cmd_console(proxmox, args): + instance = _find_instance(proxmox, args.vmid) + print(instance.console_url()) + if instance.type == "lxc": + print("pct enter %d" % instance.vmid, file=sys.stderr) + return 0 + + +def cmd_start(proxmox, args): + hint = start(proxmox, _find_instance(proxmox, args.vmid), + progress=lambda text: print(" " + text)) + print("laeuft." if not hint else "ACHTUNG: %s" % hint) + return 0 + + +def cmd_stop(proxmox, args): + proxmox.shutdown_guest(_find_instance(proxmox, args.vmid).guest) + print("heruntergefahren.") + return 0 + + +def _add_create_options(parser): + parser.add_argument("vmid", type=int, help="VMID des Originals") + parser.add_argument("snapshot", nargs="?", + help="Name des Snapshots (Vorgabe: der neueste)") + parser.add_argument("--newid", type=int, help="VMID der neuen Maschine") + parser.add_argument("--node", help="Node, auf dem sie laufen soll") + parser.add_argument("--net", choices=("none", "down", "on"), + help="none = keine Karte, down = Karte ohne Leitung, " + "on = voll am Netz") + parser.add_argument("--resume", dest="resume", action="store_true", default=None, + help="Arbeitsspeicher des Snapshots laden") + parser.add_argument("--no-resume", dest="resume", action="store_false", + help="kalt starten, auch wenn der Snapshot RAM enthaelt") + parser.add_argument("--exchange", metavar="GROESSE", + help="Austauschlaufwerk anlegen, z.B. 20G " + "(bei Containern genuegt --exchange ja)") + parser.add_argument("--exchange-storage", metavar="STORAGE", + help="Storage dafuer (Vorgabe: wie die erste Platte)") + parser.add_argument("--exchange-fs", default="ext4", choices=sorted(MKFS), + help="Dateisystem des Austauschlaufwerks (Vorgabe: ext4)") + parser.add_argument("--exchange-dir", metavar="PFAD", + help="nur Container: dieses Host-Verzeichnis durchreichen " + "(auch ein eingehaengtes Netzlaufwerk)") + parser.add_argument("--memory", type=int, help="Arbeitsspeicher in MB abweichend") + parser.add_argument("--cores", type=int, help="Kerne abweichend") + parser.add_argument("--name", help="Name der neuen Maschine") + parser.add_argument("--keep-binds", action="store_true", + help="durchgereichte Host-Verzeichnisse des Originals " + "uebernehmen (Vorgabe: weglassen)") + parser.add_argument("--no-start", action="store_true", + help="nur einrichten, nicht starten") + parser.add_argument("-y", "--yes", action="store_true", help="nicht nachfragen") + + +def build_parser(): + parser = argparse.ArgumentParser( + prog="pvesnap-recovery", + formatter_class=argparse.RawDescriptionHelpFormatter, + description="Einen Proxmox-Snapshot als eigenstaendige Maschine starten.", + epilog="""Beispiele: + pvesnap-recovery + ncurses-Oberflaeche mit Uebersicht und Assistent + + pvesnap-recovery live 802 --exchange 20G + neuesten Snapshot von VM 802 ohne Netzwerk starten, mit einem 20-GB- + Austauschlaufwerk fuer den Datenbank-Dump + + pvesnap-recovery live 100 --exchange ja + Container 100 abgeschottet starten; ein Host-Verzeichnis erscheint + im Container unter %s + + pvesnap-recovery live 100 --exchange-dir /mnt/nfs/dumps + dasselbe, aber mit einem eigenen (auch eingehaengten) Verzeichnis + + pvesnap-recovery recover 802 autosnap-... --newid 9802 + mit Netzwerk und gleicher Identitaet - nur wenn das Original steht! + + pvesnap-recovery pull 9802 + Austauschlaufwerk nach dem Herunterfahren auf den Host holen + + pvesnap-recovery destroy 9802 + restlos verwerfen: Maschine weg, Klone weg, Schutz der Snapshots geloest +""" % EXCHANGE_GUEST_PATH) + parser.add_argument("-v", "--verbose", action="store_true") + parser.add_argument("-V", "--version", action="version", + version="pvesnap-recovery %s" % __version__) + + subs = parser.add_subparsers(dest="command", metavar="BEFEHL") + + live = subs.add_parser("live", help="Snapshot abgeschottet starten (ohne Netz)") + _add_create_options(live) + + recover = subs.add_parser("recover", + help="Snapshot mit Netz und gleicher Identitaet starten") + _add_create_options(recover) + + subs.add_parser("list", help="vorhandene Wiederherstellungen zeigen") + + for name, helptext in (("start", "hochfahren"), ("stop", "herunterfahren"), + ("console", "noVNC-Adresse zeigen")): + sub = subs.add_parser(name, help=helptext) + sub.add_argument("vmid", type=int) + + pull_ = subs.add_parser("pull", help="Austauschlaufwerk auf den Host holen") + pull_.add_argument("vmid", type=int) + pull_.add_argument("-q", "--quiet", action="store_true", + help="nur den Pfad ausgeben") + + release_ = subs.add_parser("release", help="Austauschlaufwerk wieder loesen") + release_.add_argument("vmid", type=int) + + destroy_ = subs.add_parser("destroy", help="Wiederherstellung restlos verwerfen") + destroy_.add_argument("vmid", type=int) + destroy_.add_argument("-y", "--yes", action="store_true", help="nicht nachfragen") + return parser + + +def main(argv=None): + args = build_parser().parse_args(argv) + logging.basicConfig(level=logging.DEBUG if args.verbose else logging.WARNING, + format="%(levelname)s %(message)s") + + if not pvesh_available(): + print("'pvesh' nicht gefunden - pvesnap-recovery laeuft auf dem " + "Proxmox-VE-Host.", file=sys.stderr) + return 3 + if os.geteuid() != 0: + print("pvesnap-recovery braucht root-Rechte (Klone anlegen, Maschinen " + "einrichten).", file=sys.stderr) + return 4 + + proxmox = Proxmox() + handlers = { + "live": lambda: cmd_create(proxmox, args, "live"), + "recover": lambda: cmd_create(proxmox, args, "recovery"), + "list": lambda: cmd_list(proxmox, args), + "destroy": lambda: cmd_destroy(proxmox, args), + "pull": lambda: cmd_pull(proxmox, args), + "release": lambda: cmd_release(proxmox, args), + "console": lambda: cmd_console(proxmox, args), + "start": lambda: cmd_start(proxmox, args), + "stop": lambda: cmd_stop(proxmox, args), + } + + try: + if args.command in handlers: + return handlers[args.command]() + except (RecoveryError, ProxmoxError) as exc: + print("Fehler: %s" % exc, file=sys.stderr) + return 1 + except KeyboardInterrupt: + print("\nAbgebrochen.", file=sys.stderr) + return 130 + + # Ohne Befehl: Oberflaeche. Dabei darf nichts ins Terminal protokolliert werden. + logging_handlers = logging.getLogger().handlers[:] + if not args.verbose: + logging.getLogger().handlers = [] + try: + return curses.wrapper(_main, args) + except KeyboardInterrupt: + return 130 + finally: + logging.getLogger().handlers = logging_handlers + + +if __name__ == "__main__": # pragma: no cover + sys.exit(main()) diff --git a/uninstall.sh b/uninstall.sh index 8ec839a..10963ff 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -49,6 +49,27 @@ if [ "$YES" -eq 0 ]; then esac fi +# Laufende Wiederherstellungen sind echte VMs, deren Datentraeger an Snapshots +# haengen. Verschwindet pvesnap, laesst sich beides nicht mehr sauber aufloesen. +if [ -x "${BIN}-recovery" ]; then + RUNNING="$("${BIN}-recovery" list 2>/dev/null | tail -n +2 || true)" + if [ -n "$RUNNING" ]; then + warn "Es gibt noch Wiederherstellungen von pvesnap-recovery:" + printf '%s\n' "$RUNNING" >&2 + warn "Bitte zuerst verwerfen: pvesnap-recovery destroy " + warn "Sonst bleiben Klone und geschuetzte Snapshots zurueck, die die" + warn "Vorhaltezeit spaeter nicht mehr loeschen kann." + if [ "$YES" -eq 0 ]; then + printf 'Trotzdem weitermachen? [j/N] ' + read -r answer + case "$answer" in + j|J|y|Y) ;; + *) echo "Abgebrochen."; exit 0 ;; + esac + fi + fi +fi + for unit in pvesnap.service pvesnap-web.service; do if systemctl list-unit-files 2>/dev/null | grep -q "^${unit}"; then info "Stoppe und deaktiviere ${unit}" @@ -72,7 +93,7 @@ done systemctl daemon-reload 2>/dev/null || true systemctl reset-failed pvesnap.service pvesnap-web.service 2>/dev/null || true -for command in "$BIN" "${BIN}-explorer" "${BIN}-web"; do +for command in "$BIN" "${BIN}-explorer" "${BIN}-web" "${BIN}-recovery"; do if [ -e "$command" ]; then info "Entferne $command" rm -f "$command"