pvesnap-recovery: Snapshots als Maschine starten
Zwei Betriebsarten:
live abgeschottet ohne Netzwerk - zum Hineinschauen ueber die
noVNC-Konsole, Dump ziehen, Daten ueber ein Austauschlaufwerk
herausholen
recovery mit Netzwerk und gleicher Identitaet (SMBIOS-UUID, vmgenid,
MAC-Adressen, Hostname) - warnt, wenn das Original noch laeuft
Die Datentraeger werden ueber PVE::Storage::vdisk_clone geklont, also mit
derselben Funktion und Cluster-Sperre, die Proxmox selbst verwendet. Auf
Ceph/ZFS/LVM-thin ist das Copy-on-Write: 32 GiB waren im Test in 0,8s
geklont, das Original bleibt unberuehrt.
Enthaelt der Snapshot den Arbeitsspeicher, wird er mitkopiert - die Maschine
laeuft dann genau dort weiter, wo sie stand, statt zu booten. Dafuer muss die
Geraeteausstattung exakt passen:
- vmgenid und smbios1 bleiben erhalten (ohne vmgenid bricht das Laden mit
"Unknown savevm section" ab)
- runningmachine/runningcpu werden uebernommen
- die Netzwerkkarte wird abgeklemmt statt entfernt
- das Austauschlaufwerk kommt erst nach dem Fortsetzen per Hotplug dazu,
sonst bemerkt der Gast es nie (nachgewiesen ueber "info virtio-status")
Proxmox meldet einen fehlgeschlagenen RAM-Ladevorgang als TASK OK und laesst
die Maschine angehalten stehen - deshalb wird das Task-Protokoll mitgelesen,
fortgesetzt und deutlich gemeldet, wenn stattdessen kalt gebootet wurde.
Austausch mit dem Host: bei VMs eine formatierte Zusatzplatte, bei
Containern ein durchgereichtes Verzeichnis - dort passt auch ein bereits
eingehaengtes Netzlaufwerk.
Verworfen wird restlos, samt Aufheben des rbd-Snapshot-Schutzes, den das
Klonen setzt - sonst koennte die Vorhaltezeit den Snapshot nie mehr loeschen.
Angefasst wird nur, was den Tag pvesnap-recovery traegt.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8577b23b0c
commit
725fbf8732
@@ -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`
|
* Läuft cluster-weit (alle Nodes) über `pvesh`
|
||||||
* Nur Python-Standardbibliothek — keine zusätzlichen Pakete nötig
|
* 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-explorer` — zwei Fenster im Terminal wie im Midnight Commander
|
||||||
* `pvesnap-web` — im Browser durchsehen und herunterladen, auch vom anderen Rechner
|
* `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` | Dienst und Konfiguration |
|
||||||
| `/usr/local/bin/pvesnap-explorer` | Zwei-Fenster-Explorer |
|
| `/usr/local/bin/pvesnap-explorer` | Zwei-Fenster-Explorer |
|
||||||
| `/usr/local/bin/pvesnap-web` | Web-Oberfläche |
|
| `/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) |
|
| `/etc/pvesnap/pvesnap.conf` | Konfiguration (wird bei Updates **nicht** überschrieben) |
|
||||||
| `/var/lib/pvesnap/state.json` | merkt sich die letzten Läufe |
|
| `/var/lib/pvesnap/state.json` | merkt sich die letzten Läufe |
|
||||||
| `/etc/systemd/system/pvesnap.service` | systemd-Unit des Snapshot-Dienstes |
|
| `/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://<node>:8006/?console=kvm&novnc=1&vmid=9802&node=<node>&resize=off&cmd=
|
||||||
|
```
|
||||||
|
|
||||||
|
Diese Adresse gibt `pvesnap-recovery` nach dem Anlegen aus, ebenso
|
||||||
|
`pvesnap-recovery console <VMID>`. 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 <VMID>`.
|
||||||
|
|
||||||
|
### 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 <VMID> [snapshot]` | abgeschottet starten (ohne Snapshot: der neueste) |
|
||||||
|
| `recover <VMID> [snapshot]` | mit Netzwerk und gleicher Identität starten |
|
||||||
|
| `--newid <ID>` | VMID der neuen Maschine (Vorgabe: nächste freie) |
|
||||||
|
| `--node <name>` | 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 <größe>` | Austauschplatte, z. B. `20G` (Container: `ja` genügt) |
|
||||||
|
| `--exchange-storage <name>` | Storage dafür (Vorgabe: wie die erste Platte) |
|
||||||
|
| `--exchange-fs <fs>` | `ext4` (Vorgabe), `ext3`, `xfs`, `vfat`, `ntfs` |
|
||||||
|
| `--exchange-dir <pfad>` | nur Container: dieses Host-Verzeichnis durchreichen |
|
||||||
|
| `--memory <MB>`, `--cores <n>` | abweichende Ausstattung (nicht mit `--resume`) |
|
||||||
|
| `--name <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 <VMID>` | noVNC-Adresse ausgeben |
|
||||||
|
| `pull <VMID>` | Austauschlaufwerk auf den Host holen |
|
||||||
|
| `release <VMID>` | es wieder lösen |
|
||||||
|
| `destroy <VMID>` | 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
|
## Aufbau
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -563,6 +753,8 @@ pvesnap/
|
|||||||
tui.py ncurses-Konfigurationseditor
|
tui.py ncurses-Konfigurationseditor
|
||||||
snapfs.py Snapshots einbinden und mounten (rbd/zfs/lvm/qcow2)
|
snapfs.py Snapshots einbinden und mounten (rbd/zfs/lvm/qcow2)
|
||||||
explorer.py Zwei-Fenster-Explorer im Terminal
|
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
|
web/ Web-Oberfläche zum Herunterladen
|
||||||
server.py HTTP-Server, ZIP im Strom, Zugangsschlüssel
|
server.py HTTP-Server, ZIP im Strom, Zugangsschlüssel
|
||||||
assets.py Stylesheet und HTML-Gerüst
|
assets.py Stylesheet und HTML-Gerüst
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ EOF
|
|||||||
install_command pvesnap pvesnap
|
install_command pvesnap pvesnap
|
||||||
install_command pvesnap-explorer pvesnap.explorer
|
install_command pvesnap-explorer pvesnap.explorer
|
||||||
install_command pvesnap-web pvesnap.web
|
install_command pvesnap-web pvesnap.web
|
||||||
|
install_command pvesnap-recovery pvesnap.recovery_ui
|
||||||
|
|
||||||
# --- Konfiguration ----------------------------------------------------
|
# --- Konfiguration ----------------------------------------------------
|
||||||
install -d -m 0755 "${CONF_DIR}" "${STATE_DIR}" "${DOC_DIR}"
|
install -d -m 0755 "${CONF_DIR}" "${STATE_DIR}" "${DOC_DIR}"
|
||||||
@@ -219,6 +220,11 @@ Fertig.
|
|||||||
Dateien aus einem Snapshot holen:
|
Dateien aus einem Snapshot holen:
|
||||||
pvesnap-explorer zwei Fenster wie im Midnight Commander
|
pvesnap-explorer zwei Fenster wie im Midnight Commander
|
||||||
pvesnap-web im Browser durchsehen und herunterladen
|
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
|
EOF
|
||||||
|
|
||||||
if [ "$WEB" -eq 1 ]; then
|
if [ "$WEB" -eq 1 ]; then
|
||||||
|
|||||||
+104
-3
@@ -207,6 +207,103 @@ class Proxmox:
|
|||||||
what = "Loeschen von %s bei %s" % (name, guest.label)
|
what = "Loeschen von %s bei %s" % (name, guest.label)
|
||||||
return self._retry(what, lambda: self._task(guest, args, what))
|
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):
|
def _task(self, guest, args, what):
|
||||||
"""Fuehrt eine Snapshot-Aktion aus und wartet, bis sie wirklich fertig ist.
|
"""Fuehrt eine Snapshot-Aktion aus und wartet, bis sie wirklich fertig ist.
|
||||||
|
|
||||||
@@ -255,13 +352,17 @@ class Proxmox:
|
|||||||
# -- Task-Verfolgung --------------------------------------------------
|
# -- Task-Verfolgung --------------------------------------------------
|
||||||
|
|
||||||
def _wait_task(self, guest, output, what):
|
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)
|
upid = self._extract_upid(output)
|
||||||
if not upid:
|
if not upid:
|
||||||
# Ohne UPID koennen wir den Task nicht verfolgen - dann warten wir
|
# Ohne UPID koennen wir den Task nicht verfolgen - dann warten wir
|
||||||
# ersatzweise, bis der Gast nicht mehr gesperrt ist. Sonst wuerde
|
# ersatzweise, bis der Gast nicht mehr gesperrt ist. Sonst wuerde
|
||||||
# die naechste Aktion in eine noch laufende hineinlaufen.
|
# die naechste Aktion in eine noch laufende hineinlaufen.
|
||||||
log.debug("%s: keine UPID in der Antwort von pvesh", what)
|
log.debug("%s: keine UPID in der Antwort von pvesh", what)
|
||||||
|
if guest is not None:
|
||||||
self._wait_unlocked(guest)
|
self._wait_unlocked(guest)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -269,13 +370,13 @@ class Proxmox:
|
|||||||
delay = 0.5
|
delay = 0.5
|
||||||
while True:
|
while True:
|
||||||
status = self._json(["get", "/nodes/%s/tasks/%s/status"
|
status = self._json(["get", "/nodes/%s/tasks/%s/status"
|
||||||
% (guest.node, upid)]) or {}
|
% (node, upid)]) or {}
|
||||||
if status.get("status") == "stopped":
|
if status.get("status") == "stopped":
|
||||||
exit_status = status.get("exitstatus") or "unbekannt"
|
exit_status = status.get("exitstatus") or "unbekannt"
|
||||||
if exit_status != "OK":
|
if exit_status != "OK":
|
||||||
# Nur die Ursache melden - wer und was, ergaenzt der Aufrufer.
|
# Nur die Ursache melden - wer und was, ergaenzt der Aufrufer.
|
||||||
raise ProxmoxError("%s%s" % (exit_status,
|
raise ProxmoxError("%s%s" % (exit_status,
|
||||||
self._task_log_tail(guest.node, upid)))
|
self._task_log_tail(node, upid)))
|
||||||
return upid
|
return upid
|
||||||
if time.time() > deadline:
|
if time.time() > deadline:
|
||||||
raise ProxmoxError("%s: Zeitueberschreitung nach %ds (Task laeuft weiter: %s)"
|
raise ProxmoxError("%s: Zeitueberschreitung nach %ds (Task laeuft weiter: %s)"
|
||||||
|
|||||||
+1207
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,953 @@
|
|||||||
|
"""pvesnap-recovery - einen Snapshot als Maschine starten.
|
||||||
|
|
||||||
|
Ohne Argumente kommt die ncurses-Oberflaeche, alles geht aber auch direkt:
|
||||||
|
|
||||||
|
pvesnap-recovery Uebersicht und Assistent
|
||||||
|
pvesnap-recovery list was gerade laeuft
|
||||||
|
pvesnap-recovery live 802 neuesten Snapshot ohne Netz starten
|
||||||
|
pvesnap-recovery live 802 <snapshot> --exchange 20G
|
||||||
|
pvesnap-recovery recover 802 <snapshot> mit Netz, gleiche Identitaet
|
||||||
|
pvesnap-recovery console 9802 noVNC-Adresse anzeigen
|
||||||
|
pvesnap-recovery pull 9802 Austauschlaufwerk auf den Host holen
|
||||||
|
pvesnap-recovery destroy 9802 restlos verwerfen
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import curses
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from . import __version__
|
||||||
|
from .curses_util import (C_DIM, C_FOOTER, C_HEADER, C_MARK, C_OK, C_SEL, C_WARN,
|
||||||
|
choose, confirm, fill, init_colors, is_enter, is_escape,
|
||||||
|
keybar, message, prompt, put, read_key)
|
||||||
|
from .proxmox import Proxmox, ProxmoxError, pvesh_available
|
||||||
|
from .recovery import (EXCHANGE_GUEST_PATH, MKFS, RecoveryError, Spec, create,
|
||||||
|
destroy, human_bytes, list_instances, plan, pull, release,
|
||||||
|
start)
|
||||||
|
from .snapfs import list_snapshots
|
||||||
|
from .util import truncate
|
||||||
|
|
||||||
|
log = logging.getLogger("pvesnap.recovery_ui")
|
||||||
|
|
||||||
|
KEYS = [
|
||||||
|
("Enter", "Details", "Detail"),
|
||||||
|
("n", "Neu", "Neu"),
|
||||||
|
("s", "Starten", "Start"),
|
||||||
|
("h", "Herunterfahren", "Stop"),
|
||||||
|
("a", "Austausch", "Austau."),
|
||||||
|
("x", "Verwerfen", "Verwerf."),
|
||||||
|
("r", "Aktualisieren", "Aktual."),
|
||||||
|
("q", "Beenden", "Ende"),
|
||||||
|
]
|
||||||
|
|
||||||
|
NET_CHOICES = (
|
||||||
|
("", "automatisch (nach Modus)"),
|
||||||
|
("none", "keine Netzwerkkarte"),
|
||||||
|
("down", "Karte vorhanden, abgeklemmt"),
|
||||||
|
("on", "voll am Netz - Vorsicht!"),
|
||||||
|
)
|
||||||
|
|
||||||
|
RESUME_CHOICES = (
|
||||||
|
(None, "wenn im Snapshot vorhanden"),
|
||||||
|
(True, "ja, Arbeitsspeicher laden"),
|
||||||
|
(False, "nein, kalt starten"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Kleine Bausteine
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _wait_screen(win, text, title="pvesnap-recovery"):
|
||||||
|
height, width = win.getmaxyx()
|
||||||
|
win.erase()
|
||||||
|
fill(win, 0, " " + title, curses.color_pair(C_HEADER) | curses.A_BOLD)
|
||||||
|
put(win, height // 2, max(2, (width - len(text)) // 2), text, curses.A_BOLD)
|
||||||
|
win.refresh()
|
||||||
|
|
||||||
|
|
||||||
|
def _pages(win, lines, title, footer="beliebige Taste = weiter"):
|
||||||
|
"""Einen Textblock anzeigen, bei Bedarf seitenweise."""
|
||||||
|
height, width = win.getmaxyx()
|
||||||
|
rows = max(1, height - 4)
|
||||||
|
top = 0
|
||||||
|
while True:
|
||||||
|
win.erase()
|
||||||
|
fill(win, 0, " " + title, curses.color_pair(C_HEADER) | curses.A_BOLD)
|
||||||
|
for row, (text, attr) in enumerate(lines[top:top + rows]):
|
||||||
|
put(win, row + 2, 2, truncate(text, width - 4), attr)
|
||||||
|
more = " (%d-%d von %d)" % (top + 1, min(top + rows, len(lines)), len(lines)) \
|
||||||
|
if len(lines) > rows else ""
|
||||||
|
fill(win, height - 1, " " + footer + more, curses.color_pair(C_FOOTER))
|
||||||
|
win.refresh()
|
||||||
|
key = read_key(win)
|
||||||
|
if key == curses.KEY_DOWN and top + rows < len(lines):
|
||||||
|
top += 1
|
||||||
|
elif key == curses.KEY_UP and top > 0:
|
||||||
|
top -= 1
|
||||||
|
elif key == curses.KEY_NPAGE and top + rows < len(lines):
|
||||||
|
top = min(len(lines) - rows, top + rows)
|
||||||
|
elif key == curses.KEY_PPAGE:
|
||||||
|
top = max(0, top - rows)
|
||||||
|
else:
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def _plain(lines):
|
||||||
|
return [(text, 0) for text in lines]
|
||||||
|
|
||||||
|
|
||||||
|
class _Progress:
|
||||||
|
"""Fortschrittsanzeige waehrend des Anlegens."""
|
||||||
|
|
||||||
|
def __init__(self, win, title):
|
||||||
|
self.win = win
|
||||||
|
self.title = title
|
||||||
|
self.lines = []
|
||||||
|
|
||||||
|
def __call__(self, text):
|
||||||
|
self.lines.append(text)
|
||||||
|
height, width = self.win.getmaxyx()
|
||||||
|
self.win.erase()
|
||||||
|
fill(self.win, 0, " " + self.title, curses.color_pair(C_HEADER) | curses.A_BOLD)
|
||||||
|
visible = self.lines[-(height - 4):]
|
||||||
|
for row, line in enumerate(visible):
|
||||||
|
attr = curses.A_BOLD if line is visible[-1] else curses.color_pair(C_DIM)
|
||||||
|
put(self.win, row + 2, 2, truncate(line, width - 4), attr)
|
||||||
|
fill(self.win, height - 1, " bitte warten ...", curses.color_pair(C_FOOTER))
|
||||||
|
self.win.refresh()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Auswahl
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _pick_guest(win, guests):
|
||||||
|
entries = [(g, "%s %-6d %-24s %-8s %s"
|
||||||
|
% ("LXC" if g.type == "lxc" else "VM ", g.vmid,
|
||||||
|
truncate(g.name or "-", 24), g.status, g.node))
|
||||||
|
for g in guests]
|
||||||
|
return choose(win, "Von welchem Gast? (Esc = zurueck)", entries)
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_snapshot(win, snapshots):
|
||||||
|
entries = []
|
||||||
|
for snap in snapshots:
|
||||||
|
when = (datetime.fromtimestamp(snap.snaptime).strftime("%d.%m.%Y %H:%M")
|
||||||
|
if snap.snaptime else "?")
|
||||||
|
ram = "RAM" if "vmstate" in (snap.description or "") else ""
|
||||||
|
entries.append((snap.name, "%-34s %s %-4s %s"
|
||||||
|
% (truncate(snap.name, 34), when, ram,
|
||||||
|
truncate((snap.description or "").replace("\n", " "), 34))))
|
||||||
|
return choose(win, "Welcher Snapshot? (Esc = zurueck)", entries)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Optionsmaske
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _fields(guest):
|
||||||
|
"""Die Eingabefelder - je nach Gasttyp etwas andere."""
|
||||||
|
rows = [
|
||||||
|
("mode", "Betriebsart", "choice",
|
||||||
|
(("live", "live - abgeschottet, ohne Netzwerk"),
|
||||||
|
("recovery", "recovery - mit Netz, gleiche Identitaet"))),
|
||||||
|
("net", "Netzwerk", "choice", NET_CHOICES),
|
||||||
|
]
|
||||||
|
if guest.type == "qemu":
|
||||||
|
rows.append(("resume", "Arbeitsspeicher", "choice", RESUME_CHOICES))
|
||||||
|
rows.append(("exchange", "Austauschlaufwerk", "text", ()))
|
||||||
|
rows.append(("exchange_fs", " Dateisystem", "choice",
|
||||||
|
tuple((f, f) for f in sorted(MKFS))))
|
||||||
|
else:
|
||||||
|
rows.append(("exchange", "Austauschverzeichnis", "choice",
|
||||||
|
(("", "keins"), ("bind", "ja, unter %s im Gast"
|
||||||
|
% EXCHANGE_GUEST_PATH))))
|
||||||
|
rows.append(("exchange_dir", " eigenes Host-Verzeichnis", "text", ()))
|
||||||
|
rows += [
|
||||||
|
("newid", "Neue VMID", "text", ()),
|
||||||
|
("node", "Node", "text", ()),
|
||||||
|
("start", "Danach starten", "choice", ((True, "ja"), (False, "nein"))),
|
||||||
|
]
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _field_text(spec, key, kind, choices):
|
||||||
|
value = getattr(spec, key)
|
||||||
|
if kind == "choice":
|
||||||
|
for candidate, label in choices:
|
||||||
|
if candidate == value:
|
||||||
|
return label
|
||||||
|
return str(value)
|
||||||
|
if key == "newid":
|
||||||
|
return str(value) if value else "automatisch (naechste freie)"
|
||||||
|
if key == "exchange":
|
||||||
|
return value or "keins"
|
||||||
|
if key == "node":
|
||||||
|
return value or "wie das Original"
|
||||||
|
if key == "exchange_dir":
|
||||||
|
return value or "(eigenes anlegen)"
|
||||||
|
return str(value or "")
|
||||||
|
|
||||||
|
|
||||||
|
def _options(win, guest, spec):
|
||||||
|
"""Maske fuer die Einstellungen. True = anlegen, False = abbrechen."""
|
||||||
|
rows = _fields(guest)
|
||||||
|
index = 0
|
||||||
|
while True:
|
||||||
|
height, width = win.getmaxyx()
|
||||||
|
win.erase()
|
||||||
|
fill(win, 0, " Wiederherstellung einrichten - %s" % guest.label,
|
||||||
|
curses.color_pair(C_HEADER) | curses.A_BOLD)
|
||||||
|
|
||||||
|
for row, (key, label, kind, choices) in enumerate(rows):
|
||||||
|
selected = row == index
|
||||||
|
attr = curses.color_pair(C_SEL) if selected else 0
|
||||||
|
text = " %-26s %s" % (label + ":", _field_text(spec, key, kind, choices))
|
||||||
|
fill(win, row + 2, text.ljust(width - 2), attr)
|
||||||
|
|
||||||
|
note = {
|
||||||
|
"mode": "live = zum Hineinschauen. recovery = die Maschine wirklich "
|
||||||
|
"wieder in Betrieb nehmen.",
|
||||||
|
"net": "Ohne Netz kommt nichts hinein und nichts hinaus - dafuer ist "
|
||||||
|
"das Austauschlaufwerk da.",
|
||||||
|
"resume": "Snapshots mit Arbeitsspeicher laufen genau dort weiter, wo "
|
||||||
|
"sie standen - ohne Crash-Recovery.",
|
||||||
|
"exchange": ("Groesse wie 20G. Leer = keins."
|
||||||
|
if guest.type == "qemu" else
|
||||||
|
"Ein Host-Verzeichnis, das im Container auftaucht."),
|
||||||
|
"exchange_fs": "Dateisystem des Austauschlaufwerks.",
|
||||||
|
"exchange_dir": "Leer = pvesnap legt selbst eines an. Hier passt auch "
|
||||||
|
"ein eingehaengtes Netzlaufwerk.",
|
||||||
|
"newid": "VMID der neuen Maschine.",
|
||||||
|
"node": "Auf welchem Node sie laufen soll.",
|
||||||
|
"start": "Sofort hochfahren.",
|
||||||
|
}.get(rows[index][0], "")
|
||||||
|
for offset, line in enumerate(_wrap(note, width - 6)[:3]):
|
||||||
|
put(win, len(rows) + 4 + offset, 3, line, curses.color_pair(C_DIM))
|
||||||
|
|
||||||
|
for offset, line in enumerate(keybar(
|
||||||
|
width, [("Pfeile", "Auswaehlen", "Wahl"),
|
||||||
|
("Enter/Leer", "Aendern", "Aendern"),
|
||||||
|
("F10", "Anlegen", "Anlegen"),
|
||||||
|
("q", "Abbrechen", "Abbr.")])):
|
||||||
|
fill(win, height - 2 + offset, " " + line, curses.color_pair(C_FOOTER))
|
||||||
|
win.refresh()
|
||||||
|
|
||||||
|
key = read_key(win)
|
||||||
|
current_key, _label, kind, choices = rows[index]
|
||||||
|
if key == curses.KEY_UP:
|
||||||
|
index = (index - 1) % len(rows)
|
||||||
|
elif key == curses.KEY_DOWN:
|
||||||
|
index = (index + 1) % len(rows)
|
||||||
|
elif key in (curses.KEY_F10,) or key == "\x06":
|
||||||
|
return True
|
||||||
|
elif is_escape(key) or key == "q":
|
||||||
|
return False
|
||||||
|
elif is_enter(key) or key == " " or key in (curses.KEY_RIGHT, curses.KEY_LEFT):
|
||||||
|
_edit(win, spec, current_key, kind, choices,
|
||||||
|
back=key == curses.KEY_LEFT)
|
||||||
|
|
||||||
|
|
||||||
|
def _wrap(text, width):
|
||||||
|
words, lines, current = str(text).split(), [], ""
|
||||||
|
for word in words:
|
||||||
|
if len(current) + len(word) + 1 > width:
|
||||||
|
lines.append(current)
|
||||||
|
current = word
|
||||||
|
else:
|
||||||
|
current = (current + " " + word).strip()
|
||||||
|
if current:
|
||||||
|
lines.append(current)
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _edit(win, spec, key, kind, choices, back=False):
|
||||||
|
if kind == "choice":
|
||||||
|
values = [c[0] for c in choices]
|
||||||
|
try:
|
||||||
|
position = values.index(getattr(spec, key))
|
||||||
|
except ValueError:
|
||||||
|
position = 0
|
||||||
|
position = (position - 1 if back else position + 1) % len(values)
|
||||||
|
setattr(spec, key, values[position])
|
||||||
|
if key == "mode":
|
||||||
|
# Die Betriebsart gibt die Vorgabe fuer das Netz vor.
|
||||||
|
spec.net = ""
|
||||||
|
return
|
||||||
|
|
||||||
|
label = {"exchange": "Groesse (z.B. 20G, leer = keins)",
|
||||||
|
"newid": "Neue VMID (leer = automatisch)",
|
||||||
|
"node": "Node (leer = wie das Original)",
|
||||||
|
"exchange_dir": "Host-Verzeichnis (leer = selbst anlegen)"}.get(key, key)
|
||||||
|
answer = prompt(win, label, str(getattr(spec, key) or ""))
|
||||||
|
if answer is None:
|
||||||
|
return
|
||||||
|
if key == "newid":
|
||||||
|
spec.newid = int(answer) if answer.isdigit() else 0
|
||||||
|
else:
|
||||||
|
setattr(spec, key, answer)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Zusammenfassung
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def summary_lines(plan_):
|
||||||
|
"""Was passieren wird - als Liste von (Text, Attribut)."""
|
||||||
|
guest, spec = plan_.guest, plan_.spec
|
||||||
|
lines = [
|
||||||
|
("Quelle: %s auf %s" % (guest.label, guest.node), 0),
|
||||||
|
("Snapshot: %s" % plan_.snapshot, 0),
|
||||||
|
("Neu: %s auf %s" % (plan_.label, plan_.node), curses.A_BOLD),
|
||||||
|
("Betriebsart: %s" % ("recovery - mit Netz, gleiche Identitaet"
|
||||||
|
if spec.mode == "recovery"
|
||||||
|
else "live - abgeschottet"), 0),
|
||||||
|
("", 0),
|
||||||
|
("Datentraeger (Copy-on-Write-Klone, das Original bleibt unberuehrt):", 0),
|
||||||
|
]
|
||||||
|
for key, volid, size in plan_.disks:
|
||||||
|
lines.append((" %-9s %s%s" % (key, volid, (" %s" % size) if size else ""), 0))
|
||||||
|
|
||||||
|
lines.append(("", 0))
|
||||||
|
netzwerk = {"none": "keine Netzwerkkarte",
|
||||||
|
"down": "Karte vorhanden, Leitung abgeklemmt",
|
||||||
|
"on": "AM NETZ - mit den MAC-Adressen des Originals"}
|
||||||
|
lines.append(("Netzwerk: %s" % netzwerk.get(plan_.net_mode, plan_.net_mode),
|
||||||
|
curses.color_pair(C_WARN) if plan_.net_mode == "on" else 0))
|
||||||
|
if plan_.nets:
|
||||||
|
for key, value in plan_.nets:
|
||||||
|
lines.append((" %-9s %s" % (key, truncate(value, 60)), 0))
|
||||||
|
|
||||||
|
if plan_.vmstate:
|
||||||
|
lines.append(("Arbeitsspeicher: %s"
|
||||||
|
% ("wird geladen (%s) - laeuft weiter statt zu booten"
|
||||||
|
% (human_bytes(plan_.vmstate_bytes) or "?")
|
||||||
|
if plan_.resume else "vorhanden, wird aber nicht geladen"),
|
||||||
|
curses.color_pair(C_OK) if plan_.resume else 0))
|
||||||
|
if plan_.exchange_kind:
|
||||||
|
lines.append(("Austausch: %s" % plan_.exchange_detail, 0))
|
||||||
|
|
||||||
|
if plan_.notes:
|
||||||
|
lines.append(("", 0))
|
||||||
|
for note in plan_.notes:
|
||||||
|
for line in _wrap("- " + note, 74):
|
||||||
|
lines.append((line, curses.color_pair(C_DIM)))
|
||||||
|
if plan_.warnings:
|
||||||
|
lines.append(("", 0))
|
||||||
|
for warning in plan_.warnings:
|
||||||
|
for line in _wrap("!! " + warning, 74):
|
||||||
|
lines.append((line, curses.color_pair(C_WARN) | curses.A_BOLD))
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Assistent
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _new(stdscr, proxmox):
|
||||||
|
_wait_screen(stdscr, "Frage Proxmox nach VMs und Containern ...")
|
||||||
|
try:
|
||||||
|
guests = proxmox.inventory(refresh=True)
|
||||||
|
except ProxmoxError as exc:
|
||||||
|
message(stdscr, str(exc), error=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
while True:
|
||||||
|
guest = _pick_guest(stdscr, guests)
|
||||||
|
if guest is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
_wait_screen(stdscr, "Lese Snapshots von %s ..." % guest.label)
|
||||||
|
try:
|
||||||
|
snapshots = [s for s in list_snapshots(proxmox, guest) if s.complete]
|
||||||
|
except ProxmoxError as exc:
|
||||||
|
message(stdscr, str(exc), error=True)
|
||||||
|
continue
|
||||||
|
if not snapshots:
|
||||||
|
message(stdscr, "%s hat keine brauchbaren Snapshots." % guest.label,
|
||||||
|
error=True)
|
||||||
|
continue
|
||||||
|
|
||||||
|
snapname = _pick_snapshot(stdscr, snapshots)
|
||||||
|
if snapname is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
spec = Spec(exchange_dir="")
|
||||||
|
while True:
|
||||||
|
if not _options(stdscr, guest, spec):
|
||||||
|
break
|
||||||
|
_wait_screen(stdscr, "Pruefe, was noetig ist ...")
|
||||||
|
try:
|
||||||
|
plan_ = plan(proxmox, guest, snapname, spec)
|
||||||
|
except (RecoveryError, ProxmoxError) as exc:
|
||||||
|
_pages(stdscr, [(line, curses.color_pair(C_WARN))
|
||||||
|
for line in _wrap(str(exc), 74)],
|
||||||
|
"Geht so nicht")
|
||||||
|
continue
|
||||||
|
|
||||||
|
key = _pages(stdscr, summary_lines(plan_),
|
||||||
|
"Zusammenfassung - noch ist nichts passiert",
|
||||||
|
"j = anlegen | beliebige andere Taste = zurueck")
|
||||||
|
if key not in ("j", "J", "y", "Y"):
|
||||||
|
continue
|
||||||
|
if plan_.warnings and not confirm(
|
||||||
|
stdscr, "Warnungen gelesen und trotzdem anlegen?"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
progress = _Progress(stdscr, "Richte %s ein" % plan_.label)
|
||||||
|
try:
|
||||||
|
instance = create(proxmox, plan_, progress=progress)
|
||||||
|
except (RecoveryError, ProxmoxError, OSError) as exc:
|
||||||
|
_pages(stdscr, [(line, curses.color_pair(C_WARN))
|
||||||
|
for line in _wrap(str(exc), 74)]
|
||||||
|
+ [("", 0)] + _plain(progress.lines),
|
||||||
|
"Fehlgeschlagen")
|
||||||
|
break
|
||||||
|
|
||||||
|
if spec.start:
|
||||||
|
progress("Starte %s ..." % instance.label)
|
||||||
|
try:
|
||||||
|
hint = start(proxmox, instance, progress=progress)
|
||||||
|
except (ProxmoxError, RecoveryError) as exc:
|
||||||
|
message(stdscr, "Angelegt, aber nicht gestartet: %s" % exc,
|
||||||
|
error=True)
|
||||||
|
else:
|
||||||
|
if hint:
|
||||||
|
_pages(stdscr, [(line, curses.color_pair(C_WARN))
|
||||||
|
for line in _wrap(hint, 74)],
|
||||||
|
"Angelegt und gestartet - mit einem Hinweis")
|
||||||
|
_details(stdscr, proxmox, instance)
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Detailansicht
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _details(stdscr, proxmox, instance):
|
||||||
|
while True:
|
||||||
|
status = proxmox.guest_status(instance.guest)
|
||||||
|
running = status.get("status") == "running"
|
||||||
|
lines = [
|
||||||
|
("Maschine: %s auf %s" % (instance.label, instance.node), curses.A_BOLD),
|
||||||
|
("Zustand: %s" % (status.get("status") or "unbekannt"),
|
||||||
|
curses.color_pair(C_OK if running else C_DIM)),
|
||||||
|
("Herkunft: %s" % instance.origin, 0),
|
||||||
|
("Betriebsart: %s" % instance.mode, 0),
|
||||||
|
("Angelegt: %s" % (datetime.fromtimestamp(instance.created)
|
||||||
|
.strftime("%d.%m.%Y %H:%M")
|
||||||
|
if instance.created else "?"), 0),
|
||||||
|
("", 0),
|
||||||
|
("Konsole im Browser:", 0),
|
||||||
|
]
|
||||||
|
for line in _wrap(instance.console_url(), 74):
|
||||||
|
lines.append((" " + line, curses.color_pair(C_MARK)))
|
||||||
|
if instance.type == "lxc":
|
||||||
|
lines.append((" oder auf dem Host: pct enter %d" % instance.vmid,
|
||||||
|
curses.color_pair(C_MARK)))
|
||||||
|
|
||||||
|
exchange = instance.exchange or {}
|
||||||
|
if exchange:
|
||||||
|
lines.append(("", 0))
|
||||||
|
if exchange.get("kind") == "bind":
|
||||||
|
lines.append(("Austausch: %s" % exchange.get("path"), 0))
|
||||||
|
lines.append((" im Gast unter %s" % EXCHANGE_GUEST_PATH, 0))
|
||||||
|
else:
|
||||||
|
lines.append(("Austausch: %s (%s), im Gast als weitere Platte"
|
||||||
|
% (exchange.get("volid"), exchange.get("fs")), 0))
|
||||||
|
lines.append((" nach dem Herunterfahren mit 'a' "
|
||||||
|
"auf den Host holen", curses.color_pair(C_DIM)))
|
||||||
|
if instance.resumed:
|
||||||
|
lines.append(("", 0))
|
||||||
|
lines.append(("Der Arbeitsspeicher des Snapshots wurde geladen - beim "
|
||||||
|
"ersten Start lief die Maschine genau weiter.",
|
||||||
|
curses.color_pair(C_DIM)))
|
||||||
|
|
||||||
|
footer = ("s Starten | h Herunterfahren | a Austausch | x Verwerfen | "
|
||||||
|
"q zurueck")
|
||||||
|
key = _pages(stdscr, lines, "Wiederherstellung %s" % instance.label, footer)
|
||||||
|
|
||||||
|
if key in ("q", "\x1b") or is_escape(key):
|
||||||
|
return
|
||||||
|
if key in ("s", "S"):
|
||||||
|
_do(stdscr, "Starte %s ..." % instance.label,
|
||||||
|
lambda: start(proxmox, instance))
|
||||||
|
elif key in ("h", "H"):
|
||||||
|
_do(stdscr, "Fahre %s herunter ..." % instance.label,
|
||||||
|
lambda: proxmox.shutdown_guest(instance.guest))
|
||||||
|
elif key in ("a", "A"):
|
||||||
|
_pull(stdscr, proxmox, instance)
|
||||||
|
elif key in ("x", "X"):
|
||||||
|
if _destroy(stdscr, proxmox, instance):
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def _do(stdscr, text, action):
|
||||||
|
_wait_screen(stdscr, text)
|
||||||
|
try:
|
||||||
|
hint = action()
|
||||||
|
except (ProxmoxError, RecoveryError) as exc:
|
||||||
|
message(stdscr, str(exc), error=True)
|
||||||
|
return
|
||||||
|
if isinstance(hint, str) and hint:
|
||||||
|
message(stdscr, hint, error=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _pull(stdscr, proxmox, instance):
|
||||||
|
_wait_screen(stdscr, "Hole das Austauschlaufwerk auf den Host ...")
|
||||||
|
try:
|
||||||
|
path = pull(proxmox, instance)
|
||||||
|
except (RecoveryError, ProxmoxError, OSError) as exc:
|
||||||
|
message(stdscr, str(exc), error=True)
|
||||||
|
return
|
||||||
|
_pages(stdscr, _plain([
|
||||||
|
"Das Austauschlaufwerk liegt jetzt hier:",
|
||||||
|
"",
|
||||||
|
" " + path,
|
||||||
|
"",
|
||||||
|
"Von dort kopieren, zum Beispiel mit:",
|
||||||
|
" pvesnap-explorer --path %s" % path,
|
||||||
|
" scp -r %s/. ziel:/pfad/" % path,
|
||||||
|
"",
|
||||||
|
"Wieder loesen mit: pvesnap-recovery release %d" % instance.vmid,
|
||||||
|
]), "Austauschlaufwerk")
|
||||||
|
|
||||||
|
|
||||||
|
def _destroy(stdscr, proxmox, instance):
|
||||||
|
if not confirm(stdscr, "%s wirklich verwerfen? Alle Aenderungen darin sind weg."
|
||||||
|
% instance.label):
|
||||||
|
return False
|
||||||
|
progress = _Progress(stdscr, "Verwerfe %s" % instance.label)
|
||||||
|
try:
|
||||||
|
destroy(proxmox, instance, progress=progress)
|
||||||
|
except (RecoveryError, ProxmoxError) as exc:
|
||||||
|
_pages(stdscr, [(line, curses.color_pair(C_WARN))
|
||||||
|
for line in _wrap(str(exc), 74)] + [("", 0)]
|
||||||
|
+ _plain(progress.lines), "Fehlgeschlagen")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Uebersicht
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _overview(stdscr, proxmox):
|
||||||
|
index = 0
|
||||||
|
instances = []
|
||||||
|
reload_ = True
|
||||||
|
while True:
|
||||||
|
if reload_:
|
||||||
|
_wait_screen(stdscr, "Frage nach vorhandenen Wiederherstellungen ...")
|
||||||
|
instances = list_instances(proxmox)
|
||||||
|
reload_ = False
|
||||||
|
index = min(index, max(0, len(instances) - 1))
|
||||||
|
|
||||||
|
height, width = stdscr.getmaxyx()
|
||||||
|
stdscr.erase()
|
||||||
|
fill(stdscr, 0, " pvesnap-recovery %s - Snapshots als Maschine starten"
|
||||||
|
% __version__, curses.color_pair(C_HEADER) | curses.A_BOLD)
|
||||||
|
|
||||||
|
if not instances:
|
||||||
|
put(stdscr, 3, 3, "Zurzeit laeuft keine Wiederherstellung.", curses.A_BOLD)
|
||||||
|
put(stdscr, 5, 3, "Mit n eine neue anlegen:", 0)
|
||||||
|
put(stdscr, 6, 5, "live Snapshot abgeschottet starten, ohne Netzwerk -",
|
||||||
|
curses.color_pair(C_DIM))
|
||||||
|
put(stdscr, 7, 5, " zum Hineinschauen und Daten herausholen.",
|
||||||
|
curses.color_pair(C_DIM))
|
||||||
|
put(stdscr, 8, 5, "recovery Snapshot mit Netzwerk und gleicher Identitaet",
|
||||||
|
curses.color_pair(C_DIM))
|
||||||
|
put(stdscr, 9, 5, " wieder in Betrieb nehmen.",
|
||||||
|
curses.color_pair(C_DIM))
|
||||||
|
else:
|
||||||
|
put(stdscr, 2, 2, "%-10s %-9s %-22s %-28s %s"
|
||||||
|
% ("Maschine", "Zustand", "Node", "Herkunft", "Modus"),
|
||||||
|
curses.A_BOLD | curses.A_UNDERLINE)
|
||||||
|
rows = max(1, height - 6)
|
||||||
|
top = max(0, min(index - rows + 1, len(instances) - rows))
|
||||||
|
for row in range(min(rows, len(instances) - top)):
|
||||||
|
instance = instances[top + row]
|
||||||
|
attr = curses.color_pair(C_SEL) if top + row == index else 0
|
||||||
|
if not attr and instance.status == "running":
|
||||||
|
attr = curses.color_pair(C_OK)
|
||||||
|
text = " %-10s %-9s %-22s %-28s %s" % (
|
||||||
|
instance.label, instance.status or "?",
|
||||||
|
truncate(instance.node or "?", 22),
|
||||||
|
truncate(instance.origin, 28), instance.mode)
|
||||||
|
fill(stdscr, row + 3, text, attr)
|
||||||
|
|
||||||
|
for offset, line in enumerate(keybar(width, KEYS)):
|
||||||
|
fill(stdscr, height - 2 + offset, " " + line, curses.color_pair(C_FOOTER))
|
||||||
|
stdscr.refresh()
|
||||||
|
|
||||||
|
key = read_key(stdscr)
|
||||||
|
current = instances[index] if instances else None
|
||||||
|
|
||||||
|
if key in ("q", "Q") or is_escape(key):
|
||||||
|
return 0
|
||||||
|
if key in ("n", "N"):
|
||||||
|
_new(stdscr, proxmox)
|
||||||
|
reload_ = True
|
||||||
|
elif key in ("r", "R"):
|
||||||
|
reload_ = True
|
||||||
|
elif key == curses.KEY_UP and instances:
|
||||||
|
index = (index - 1) % len(instances)
|
||||||
|
elif key == curses.KEY_DOWN and instances:
|
||||||
|
index = (index + 1) % len(instances)
|
||||||
|
elif current is None:
|
||||||
|
continue
|
||||||
|
elif is_enter(key):
|
||||||
|
_details(stdscr, proxmox, current)
|
||||||
|
reload_ = True
|
||||||
|
elif key in ("s", "S"):
|
||||||
|
_do(stdscr, "Starte %s ..." % current.label,
|
||||||
|
lambda: start(proxmox, current))
|
||||||
|
reload_ = True
|
||||||
|
elif key in ("h", "H"):
|
||||||
|
_do(stdscr, "Fahre %s herunter ..." % current.label,
|
||||||
|
lambda: proxmox.shutdown_guest(current.guest))
|
||||||
|
reload_ = True
|
||||||
|
elif key in ("a", "A"):
|
||||||
|
_pull(stdscr, proxmox, current)
|
||||||
|
elif key in ("x", "X"):
|
||||||
|
_destroy(stdscr, proxmox, current)
|
||||||
|
reload_ = True
|
||||||
|
|
||||||
|
|
||||||
|
def _main(stdscr, _args):
|
||||||
|
curses.curs_set(0)
|
||||||
|
if hasattr(curses, "set_escdelay"):
|
||||||
|
curses.set_escdelay(25)
|
||||||
|
init_colors()
|
||||||
|
stdscr.keypad(True)
|
||||||
|
return _overview(stdscr, Proxmox())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Befehlszeile
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _find_guest(proxmox, vmid):
|
||||||
|
guest = next((g for g in proxmox.inventory() if g.vmid == int(vmid)), None)
|
||||||
|
if guest is None:
|
||||||
|
raise RecoveryError("Gast %s gibt es nicht." % vmid)
|
||||||
|
return guest
|
||||||
|
|
||||||
|
|
||||||
|
def _find_instance(proxmox, vmid):
|
||||||
|
instance = next((i for i in list_instances(proxmox) if i.vmid == int(vmid)), None)
|
||||||
|
if instance is None:
|
||||||
|
raise RecoveryError("%s ist keine von pvesnap angelegte Wiederherstellung. "
|
||||||
|
"Vorhandene zeigt: pvesnap-recovery list" % vmid)
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
def _newest_snapshot(proxmox, guest):
|
||||||
|
snapshots = [s for s in list_snapshots(proxmox, guest) if s.complete]
|
||||||
|
if not snapshots:
|
||||||
|
raise RecoveryError("%s hat keine brauchbaren Snapshots." % guest.label)
|
||||||
|
return snapshots[0].name
|
||||||
|
|
||||||
|
|
||||||
|
def _spec_from_args(args, mode):
|
||||||
|
spec = Spec(mode=mode)
|
||||||
|
spec.newid = args.newid or 0
|
||||||
|
spec.node = args.node or ""
|
||||||
|
spec.net = args.net or ""
|
||||||
|
spec.resume = args.resume
|
||||||
|
spec.exchange = args.exchange or ""
|
||||||
|
spec.exchange_storage = args.exchange_storage or ""
|
||||||
|
spec.exchange_fs = args.exchange_fs
|
||||||
|
spec.exchange_dir = args.exchange_dir or ""
|
||||||
|
spec.memory = args.memory or 0
|
||||||
|
spec.cores = args.cores or 0
|
||||||
|
spec.name = args.name or ""
|
||||||
|
spec.keep_binds = args.keep_binds
|
||||||
|
spec.start = not args.no_start
|
||||||
|
if spec.exchange_dir and not spec.exchange:
|
||||||
|
spec.exchange = "bind"
|
||||||
|
return spec
|
||||||
|
|
||||||
|
|
||||||
|
def _print_summary(plan_):
|
||||||
|
print(" Quelle : %s auf %s" % (plan_.guest.label, plan_.guest.node))
|
||||||
|
print(" Snapshot : %s" % plan_.snapshot)
|
||||||
|
print(" Neu : %s auf %s" % (plan_.label, plan_.node))
|
||||||
|
for key, volid, size in plan_.disks:
|
||||||
|
print(" Klon : %-9s %s%s" % (key, volid, (" %s" % size) if size else ""))
|
||||||
|
netzwerk = {"none": "keine Netzwerkkarte",
|
||||||
|
"down": "Karte vorhanden, Leitung abgeklemmt",
|
||||||
|
"on": "AM NETZ mit den MAC-Adressen des Originals"}
|
||||||
|
print(" Netzwerk : %s" % netzwerk.get(plan_.net_mode, plan_.net_mode))
|
||||||
|
if plan_.resume:
|
||||||
|
print(" Arbeitsspei.: wird geladen (%s)"
|
||||||
|
% (human_bytes(plan_.vmstate_bytes) or "?"))
|
||||||
|
if plan_.exchange_kind:
|
||||||
|
print(" Austausch : %s" % plan_.exchange_detail)
|
||||||
|
for note in plan_.notes:
|
||||||
|
print(" Hinweis : %s" % note)
|
||||||
|
for warning in plan_.warnings:
|
||||||
|
print(" ACHTUNG : %s" % warning)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_create(proxmox, args, mode):
|
||||||
|
guest = _find_guest(proxmox, args.vmid)
|
||||||
|
snapname = args.snapshot or _newest_snapshot(proxmox, guest)
|
||||||
|
spec = _spec_from_args(args, mode)
|
||||||
|
plan_ = plan(proxmox, guest, snapname, spec)
|
||||||
|
|
||||||
|
print("Vorhaben:")
|
||||||
|
_print_summary(plan_)
|
||||||
|
if not args.yes:
|
||||||
|
if plan_.warnings:
|
||||||
|
print("\nEs gibt Warnungen - bitte oben lesen.")
|
||||||
|
answer = input("\nAnlegen? [j/N] ").strip().lower()
|
||||||
|
if answer not in ("j", "y", "ja", "yes"):
|
||||||
|
print("Abgebrochen.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
instance = create(proxmox, plan_, progress=lambda text: print(" " + text))
|
||||||
|
print("\n%s ist eingerichtet." % instance.label)
|
||||||
|
if spec.start:
|
||||||
|
print(" Starte ...")
|
||||||
|
hint = start(proxmox, instance, progress=lambda text: print(" " + text))
|
||||||
|
print(" laeuft." if not hint else " ACHTUNG: %s" % hint)
|
||||||
|
_print_access(instance)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _print_access(instance):
|
||||||
|
print("\n Konsole im Browser:")
|
||||||
|
print(" %s" % instance.console_url())
|
||||||
|
if instance.type == "lxc":
|
||||||
|
print(" oder auf dem Host: pct enter %d" % instance.vmid)
|
||||||
|
exchange = instance.exchange or {}
|
||||||
|
if exchange.get("kind") == "bind":
|
||||||
|
print("\n Austausch: %s (im Gast unter %s)"
|
||||||
|
% (exchange.get("path"), EXCHANGE_GUEST_PATH))
|
||||||
|
elif exchange.get("volid"):
|
||||||
|
print("\n Austausch: als weitere Platte im Gast (%s, Bezeichnung PVESNAP)"
|
||||||
|
% exchange.get("fs"))
|
||||||
|
print(" im Gast einhaengen, befuellen, Gast herunterfahren, dann:")
|
||||||
|
print(" pvesnap-recovery pull %d" % instance.vmid)
|
||||||
|
print("\n Wieder verwerfen: pvesnap-recovery destroy %d" % instance.vmid)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_list(proxmox, _args):
|
||||||
|
instances = list_instances(proxmox)
|
||||||
|
if not instances:
|
||||||
|
print("Zurzeit laeuft keine Wiederherstellung.")
|
||||||
|
return 0
|
||||||
|
print("%-10s %-9s %-14s %-30s %s"
|
||||||
|
% ("Maschine", "Zustand", "Node", "Herkunft", "Modus"))
|
||||||
|
for instance in instances:
|
||||||
|
print("%-10s %-9s %-14s %-30s %s"
|
||||||
|
% (instance.label, instance.status or "?", instance.node or "?",
|
||||||
|
instance.origin, instance.mode))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_destroy(proxmox, args):
|
||||||
|
instance = _find_instance(proxmox, args.vmid)
|
||||||
|
if not args.yes:
|
||||||
|
print("%s (%s) wird restlos entfernt - alle Aenderungen darin sind weg."
|
||||||
|
% (instance.label, instance.origin))
|
||||||
|
if input("Wirklich? [j/N] ").strip().lower() not in ("j", "y", "ja", "yes"):
|
||||||
|
print("Abgebrochen.")
|
||||||
|
return 1
|
||||||
|
destroy(proxmox, instance, progress=lambda text: print(" " + text))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_pull(proxmox, args):
|
||||||
|
instance = _find_instance(proxmox, args.vmid)
|
||||||
|
path = pull(proxmox, instance)
|
||||||
|
print(path)
|
||||||
|
if not args.quiet:
|
||||||
|
print("\nDas Austauschlaufwerk liegt jetzt auf dem Host.", file=sys.stderr)
|
||||||
|
print(" pvesnap-explorer --path %s" % path, file=sys.stderr)
|
||||||
|
print(" Wieder loesen: pvesnap-recovery release %d" % instance.vmid,
|
||||||
|
file=sys.stderr)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_release(_proxmox, args):
|
||||||
|
print("geloest." if release(args.vmid) else "War nicht eingehaengt.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_console(proxmox, args):
|
||||||
|
instance = _find_instance(proxmox, args.vmid)
|
||||||
|
print(instance.console_url())
|
||||||
|
if instance.type == "lxc":
|
||||||
|
print("pct enter %d" % instance.vmid, file=sys.stderr)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_start(proxmox, args):
|
||||||
|
hint = start(proxmox, _find_instance(proxmox, args.vmid),
|
||||||
|
progress=lambda text: print(" " + text))
|
||||||
|
print("laeuft." if not hint else "ACHTUNG: %s" % hint)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_stop(proxmox, args):
|
||||||
|
proxmox.shutdown_guest(_find_instance(proxmox, args.vmid).guest)
|
||||||
|
print("heruntergefahren.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _add_create_options(parser):
|
||||||
|
parser.add_argument("vmid", type=int, help="VMID des Originals")
|
||||||
|
parser.add_argument("snapshot", nargs="?",
|
||||||
|
help="Name des Snapshots (Vorgabe: der neueste)")
|
||||||
|
parser.add_argument("--newid", type=int, help="VMID der neuen Maschine")
|
||||||
|
parser.add_argument("--node", help="Node, auf dem sie laufen soll")
|
||||||
|
parser.add_argument("--net", choices=("none", "down", "on"),
|
||||||
|
help="none = keine Karte, down = Karte ohne Leitung, "
|
||||||
|
"on = voll am Netz")
|
||||||
|
parser.add_argument("--resume", dest="resume", action="store_true", default=None,
|
||||||
|
help="Arbeitsspeicher des Snapshots laden")
|
||||||
|
parser.add_argument("--no-resume", dest="resume", action="store_false",
|
||||||
|
help="kalt starten, auch wenn der Snapshot RAM enthaelt")
|
||||||
|
parser.add_argument("--exchange", metavar="GROESSE",
|
||||||
|
help="Austauschlaufwerk anlegen, z.B. 20G "
|
||||||
|
"(bei Containern genuegt --exchange ja)")
|
||||||
|
parser.add_argument("--exchange-storage", metavar="STORAGE",
|
||||||
|
help="Storage dafuer (Vorgabe: wie die erste Platte)")
|
||||||
|
parser.add_argument("--exchange-fs", default="ext4", choices=sorted(MKFS),
|
||||||
|
help="Dateisystem des Austauschlaufwerks (Vorgabe: ext4)")
|
||||||
|
parser.add_argument("--exchange-dir", metavar="PFAD",
|
||||||
|
help="nur Container: dieses Host-Verzeichnis durchreichen "
|
||||||
|
"(auch ein eingehaengtes Netzlaufwerk)")
|
||||||
|
parser.add_argument("--memory", type=int, help="Arbeitsspeicher in MB abweichend")
|
||||||
|
parser.add_argument("--cores", type=int, help="Kerne abweichend")
|
||||||
|
parser.add_argument("--name", help="Name der neuen Maschine")
|
||||||
|
parser.add_argument("--keep-binds", action="store_true",
|
||||||
|
help="durchgereichte Host-Verzeichnisse des Originals "
|
||||||
|
"uebernehmen (Vorgabe: weglassen)")
|
||||||
|
parser.add_argument("--no-start", action="store_true",
|
||||||
|
help="nur einrichten, nicht starten")
|
||||||
|
parser.add_argument("-y", "--yes", action="store_true", help="nicht nachfragen")
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="pvesnap-recovery",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
description="Einen Proxmox-Snapshot als eigenstaendige Maschine starten.",
|
||||||
|
epilog="""Beispiele:
|
||||||
|
pvesnap-recovery
|
||||||
|
ncurses-Oberflaeche mit Uebersicht und Assistent
|
||||||
|
|
||||||
|
pvesnap-recovery live 802 --exchange 20G
|
||||||
|
neuesten Snapshot von VM 802 ohne Netzwerk starten, mit einem 20-GB-
|
||||||
|
Austauschlaufwerk fuer den Datenbank-Dump
|
||||||
|
|
||||||
|
pvesnap-recovery live 100 --exchange ja
|
||||||
|
Container 100 abgeschottet starten; ein Host-Verzeichnis erscheint
|
||||||
|
im Container unter %s
|
||||||
|
|
||||||
|
pvesnap-recovery live 100 --exchange-dir /mnt/nfs/dumps
|
||||||
|
dasselbe, aber mit einem eigenen (auch eingehaengten) Verzeichnis
|
||||||
|
|
||||||
|
pvesnap-recovery recover 802 autosnap-... --newid 9802
|
||||||
|
mit Netzwerk und gleicher Identitaet - nur wenn das Original steht!
|
||||||
|
|
||||||
|
pvesnap-recovery pull 9802
|
||||||
|
Austauschlaufwerk nach dem Herunterfahren auf den Host holen
|
||||||
|
|
||||||
|
pvesnap-recovery destroy 9802
|
||||||
|
restlos verwerfen: Maschine weg, Klone weg, Schutz der Snapshots geloest
|
||||||
|
""" % EXCHANGE_GUEST_PATH)
|
||||||
|
parser.add_argument("-v", "--verbose", action="store_true")
|
||||||
|
parser.add_argument("-V", "--version", action="version",
|
||||||
|
version="pvesnap-recovery %s" % __version__)
|
||||||
|
|
||||||
|
subs = parser.add_subparsers(dest="command", metavar="BEFEHL")
|
||||||
|
|
||||||
|
live = subs.add_parser("live", help="Snapshot abgeschottet starten (ohne Netz)")
|
||||||
|
_add_create_options(live)
|
||||||
|
|
||||||
|
recover = subs.add_parser("recover",
|
||||||
|
help="Snapshot mit Netz und gleicher Identitaet starten")
|
||||||
|
_add_create_options(recover)
|
||||||
|
|
||||||
|
subs.add_parser("list", help="vorhandene Wiederherstellungen zeigen")
|
||||||
|
|
||||||
|
for name, helptext in (("start", "hochfahren"), ("stop", "herunterfahren"),
|
||||||
|
("console", "noVNC-Adresse zeigen")):
|
||||||
|
sub = subs.add_parser(name, help=helptext)
|
||||||
|
sub.add_argument("vmid", type=int)
|
||||||
|
|
||||||
|
pull_ = subs.add_parser("pull", help="Austauschlaufwerk auf den Host holen")
|
||||||
|
pull_.add_argument("vmid", type=int)
|
||||||
|
pull_.add_argument("-q", "--quiet", action="store_true",
|
||||||
|
help="nur den Pfad ausgeben")
|
||||||
|
|
||||||
|
release_ = subs.add_parser("release", help="Austauschlaufwerk wieder loesen")
|
||||||
|
release_.add_argument("vmid", type=int)
|
||||||
|
|
||||||
|
destroy_ = subs.add_parser("destroy", help="Wiederherstellung restlos verwerfen")
|
||||||
|
destroy_.add_argument("vmid", type=int)
|
||||||
|
destroy_.add_argument("-y", "--yes", action="store_true", help="nicht nachfragen")
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
args = build_parser().parse_args(argv)
|
||||||
|
logging.basicConfig(level=logging.DEBUG if args.verbose else logging.WARNING,
|
||||||
|
format="%(levelname)s %(message)s")
|
||||||
|
|
||||||
|
if not pvesh_available():
|
||||||
|
print("'pvesh' nicht gefunden - pvesnap-recovery laeuft auf dem "
|
||||||
|
"Proxmox-VE-Host.", file=sys.stderr)
|
||||||
|
return 3
|
||||||
|
if os.geteuid() != 0:
|
||||||
|
print("pvesnap-recovery braucht root-Rechte (Klone anlegen, Maschinen "
|
||||||
|
"einrichten).", file=sys.stderr)
|
||||||
|
return 4
|
||||||
|
|
||||||
|
proxmox = Proxmox()
|
||||||
|
handlers = {
|
||||||
|
"live": lambda: cmd_create(proxmox, args, "live"),
|
||||||
|
"recover": lambda: cmd_create(proxmox, args, "recovery"),
|
||||||
|
"list": lambda: cmd_list(proxmox, args),
|
||||||
|
"destroy": lambda: cmd_destroy(proxmox, args),
|
||||||
|
"pull": lambda: cmd_pull(proxmox, args),
|
||||||
|
"release": lambda: cmd_release(proxmox, args),
|
||||||
|
"console": lambda: cmd_console(proxmox, args),
|
||||||
|
"start": lambda: cmd_start(proxmox, args),
|
||||||
|
"stop": lambda: cmd_stop(proxmox, args),
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
if args.command in handlers:
|
||||||
|
return handlers[args.command]()
|
||||||
|
except (RecoveryError, ProxmoxError) as exc:
|
||||||
|
print("Fehler: %s" % exc, file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nAbgebrochen.", file=sys.stderr)
|
||||||
|
return 130
|
||||||
|
|
||||||
|
# Ohne Befehl: Oberflaeche. Dabei darf nichts ins Terminal protokolliert werden.
|
||||||
|
logging_handlers = logging.getLogger().handlers[:]
|
||||||
|
if not args.verbose:
|
||||||
|
logging.getLogger().handlers = []
|
||||||
|
try:
|
||||||
|
return curses.wrapper(_main, args)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
return 130
|
||||||
|
finally:
|
||||||
|
logging.getLogger().handlers = logging_handlers
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
sys.exit(main())
|
||||||
+22
-1
@@ -49,6 +49,27 @@ if [ "$YES" -eq 0 ]; then
|
|||||||
esac
|
esac
|
||||||
fi
|
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 <VMID>"
|
||||||
|
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
|
for unit in pvesnap.service pvesnap-web.service; do
|
||||||
if systemctl list-unit-files 2>/dev/null | grep -q "^${unit}"; then
|
if systemctl list-unit-files 2>/dev/null | grep -q "^${unit}"; then
|
||||||
info "Stoppe und deaktiviere ${unit}"
|
info "Stoppe und deaktiviere ${unit}"
|
||||||
@@ -72,7 +93,7 @@ done
|
|||||||
systemctl daemon-reload 2>/dev/null || true
|
systemctl daemon-reload 2>/dev/null || true
|
||||||
systemctl reset-failed pvesnap.service pvesnap-web.service 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
|
if [ -e "$command" ]; then
|
||||||
info "Entferne $command"
|
info "Entferne $command"
|
||||||
rm -f "$command"
|
rm -f "$command"
|
||||||
|
|||||||
Reference in New Issue
Block a user