From f2de07ecfd357355e80959a9599f51fd3737603a Mon Sep 17 00:00:00 2001 From: duffyduck Date: Mon, 3 Aug 2026 11:52:35 +0200 Subject: [PATCH] Das eingebaute Austauschlaufwerk faellt weg Es tat dasselbe wie die Transfer-Laufwerke, nur schlechter: namenlos, nicht vorher befuellbar, nicht wiederverwendbar, und es starb mit der Maschine. Zwei Mechanismen fuer dieselbe Sache sind schlechter als einer. Entfernt: --exchange, --exchange-storage, --exchange-fs, --exchange-dir, die Unterbefehle pull und release, das Formatieren und Einhaengen auf dem Host samt Mount-Merkliste. Was davon gebraucht wurde, steckt jetzt in transfer.py - dort aber benannt, dauerhaft und im laufenden Betrieb wechselbar. Aeltere Eintraege in der Merkliste stoeren nicht: ihr Austauschlaufwerk ist ein gewoehnliches Volume der Maschine und wird beim Verwerfen ohnehin von Proxmox mitgeloescht. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 101 +++++++------ pvesnap/recovery.py | 311 +---------------------------------------- pvesnap/recovery_ui.py | 195 +++----------------------- 3 files changed, 82 insertions(+), 525 deletions(-) diff --git a/README.md b/README.md index 170e870..696d68f 100644 --- a/README.md +++ b/README.md @@ -770,7 +770,7 @@ passen. `pvesnap-recovery` sorgt selbst dafür: * `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), +* Transfer-Laufwerke werden 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 @@ -781,49 +781,67 @@ gebootet wurde. Mit `--no-resume` lässt sich der Arbeitsspeicher bewusst weglassen. -### Daten aus einer Maschine ohne Netzwerk holen +### Daten hinein und wieder heraus — Transfer-Laufwerke -Ohne Netz kommt nichts heraus — dafür gibt es das **Austauschlaufwerk**. +Ein wiederhergestellter Snapshot hat kein Netzwerk. Dafür gibt es +**Transfer-Laufwerke**: benannte Abbilddateien unter +`/var/lib/pvesnap/transfer/`, die du **in Ruhe vorher** anlegst und befüllst +und im Notfall nur noch aus einer Liste auswählst. -**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 +``` +pvesnap-recovery + v Übersicht der Laufwerke + n "dumps", 20G ← einmalig + Enter Commander: Werkzeuge hineinkopieren + q q + n Wiederherstellung, Snapshot wählen + Transfer-Laufwerke: [dumps] ← Leertaste + F10 läuft + ...arbeiten... im Gast: Datenträger DUMPS + e auswerfen, am Host auslesen, wieder einklinken + h herunterfahren + v Enter Commander: Ergebnis abholen ``` -**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. +Kein einziger Parameter. Auf der Kommandozeile ginge es auch: +`--transfer dumps`, mehrfach möglich. -```bash -pvesnap-recovery live 100 --exchange ja -pct exec 9100 -- sh -c 'pg_dumpall > /mnt/pvesnap/dump.sql' -ls /var/lib/pvesnap/exchange/9100/ +| Eigenschaft | | +|---|---| +| Dateisystem | **exFAT** — Windows und Linux nativ, keine 4-GB-Grenze je Datei | +| Partition | GPT mit `msftdata`, damit Windows sofort einen Laufwerksbuchstaben vergibt | +| Bezeichnung | der Name in Großbuchstaben, z. B. `DUMPS` (max. 11 Zeichen) | +| Lebensdauer | überlebt jede Wiederherstellung — auch das Löschen der VM in der Weboberfläche | + +**Warum eine Datei und kein Proxmox-Volume?** Ein Volume würde beim Entfernen +der Maschine mitgelöscht, auch aus der Weboberfläche heraus — die mühsam +vorbereitete Werkzeugsammlung wäre weg. Pfade überspringt PVE beim Zerstören +ausdrücklich (`return if $volid =~ m|^/|`). Angehängt wird über ein +Loop-Gerät, weil Proxmox als Pfad nur `/dev/…` akzeptiert. + +**Host oder Gast, nie beides.** Zwei unabhängige Einhängungen desselben +Blockgeräts zerlegen das Dateisystem. Die Verwaltung lässt das nicht zu: +Anhängen wird abgelehnt, solange der Host es hält; Aushängen, solange ein Gast +darauf arbeitet; Löschen, solange es überhaupt in Benutzung ist. + +**Bei Containern** ist es kein Blockgerät, sondern ein durchgereichtes +Verzeichnis. Host und Container sehen dieselben Dateien **gleichzeitig** — +dort gibt es nichts auszuwerfen und nichts zu warten. + +### Im laufenden Betrieb wechseln + +Taste `e` in der Detailansicht zieht das Laufwerk bei laufender Maschine ab +und gibt es wieder hinein — beliebig oft: + +``` +auswerfen → gehört wieder dem Host, einhängen und befüllen +einklinken → zurück in denselben Steckplatz, VM läuft durchgehend ``` -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. +> **Im Gast vorher aushängen.** Unter Linux `umount`, unter Windows +> „Auswerfen" im Explorer. Proxmox meldet das Gerät zwar ordentlich ab, aber +> ein Dateisystem, auf das gerade geschrieben wird, nimmt das übel. Hält der +> Gast es fest, schlägt das Auswerfen mit einer entsprechenden Meldung fehl. ### An die Konsole kommen @@ -871,10 +889,7 @@ Karte erst zuschalten, wenn klar ist, dass die Luft rein ist. | `--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`, `exfat` | -| `--exchange-dir ` | nur Container: dieses Host-Verzeichnis durchreichen | +| `--transfer ` | vorbereitetes Transfer-Laufwerk anhängen (mehrfach möglich) | | `--memory `, `--cores ` | abweichende Ausstattung (nicht mit `--resume`) | | `--name ` | Name der neuen Maschine | | `--keep-binds` | durchgereichte Host-Verzeichnisse des Originals übernehmen | @@ -888,8 +903,6 @@ Karte erst zuschalten, wenn klar ist, dass die Luft rein ist. | `list` | vorhandene Wiederherstellungen anzeigen | | `start` / `stop` | hoch- bzw. herunterfahren | | `console ` | noVNC-Adresse ausgeben | -| `pull ` | Austauschlaufwerk auf den Host holen | -| `release ` | es wieder lösen | | `flatten ` | vom Quell-Snapshot lösen (für den Dauerbetrieb) | | `cleanup` | Klone entfernen, zu denen es keinen Gast mehr gibt | | `destroy ` | restlos verwerfen | @@ -932,6 +945,8 @@ pvesnap/ 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 + transfer.py Transfer-Laufwerke: anlegen, ein-/aushängen, Verriegelung + transfer_ui.py deren Bildschirm, auch aus dem Explorer erreichbar 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/pvesnap/recovery.py b/pvesnap/recovery.py index 728a60f..94fa9e7 100644 --- a/pvesnap/recovery.py +++ b/pvesnap/recovery.py @@ -43,8 +43,6 @@ 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+$") @@ -65,35 +63,6 @@ DROP_KEYS = { "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], - "exfat": ["mkfs.exfat", "-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.""" @@ -474,10 +443,6 @@ class Spec: 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 transfers: list = field(default_factory=list) # Namen vorhandener Laufwerke memory: int = 0 # 0 = wie im Snapshot cores: int = 0 @@ -504,8 +469,6 @@ class Plan: vmstate: str = "" vmstate_bytes: int = 0 resume: bool = False - exchange_kind: str = "" # "disk" | "bind" | "" - exchange_detail: str = "" transfers: list = field(default_factory=list) # [transfer.Volume] warnings: list = field(default_factory=list) # Was nicht nur unschoen, sondern gefaehrlich ist. Hierfuer genuegt ein @@ -533,7 +496,6 @@ class Instance: created: int = 0 volumes: list = field(default_factory=list) protected: list = field(default_factory=list) # [[volid, snapname]] - exchange: dict = field(default_factory=dict) transfers: list = field(default_factory=list) resumed: bool = False status: str = "" @@ -559,14 +521,14 @@ class Instance: 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", "transfers", + "mode", "created", "volumes", "protected", "transfers", "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", "transfers", + "mode", "created", "volumes", "protected", "transfers", "resumed") if data.get(k) is not None} known["vmid"] = int(known.get("vmid") or 0) return cls(**known) @@ -770,37 +732,6 @@ def plan(proxmox, guest, snapname, spec): "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) - # -- Transfer-Laufwerke ------------------------------------------------ for name in spec.transfers: volume = transfer.get(name) # wirft, wenn es das nicht gibt @@ -917,38 +848,6 @@ def create(proxmox, plan_, progress=None): 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} - # -- Transfer-Laufwerke -------------------------------------------- attached = [] for volume in plan_.transfers: @@ -996,7 +895,7 @@ def create(proxmox, plan_, progress=None): 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, + protected=protected, resumed=plan_.resume, transfers=attached) _registry_add(instance) return instance @@ -1103,29 +1002,6 @@ def _attach_pending(proxmox, instance, step=None): entry["pending"] = False _registry_add(instance) - exchange = instance.exchange or {} - if not exchange.get("pending"): - return " ".join(notes) - 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: - notes.append("Das Austauschlaufwerk liess sich nicht anstecken: %s" % exc) - return " ".join(notes) - - if key not in config: - # Proxmox hat es nur vorgemerkt - im Gast taucht es dann nicht auf. - notes.append("Das Austauschlaufwerk ist eingetragen, aber nicht " - "angesteckt worden (Hotplug fuer Platten ist bei dieser " - "Maschine aus). Es erscheint erst nach einem Neustart.") - else: - exchange["pending"] = False - _registry_add(instance) return " ".join(notes) @@ -1312,93 +1188,8 @@ def _copy_state(volid, newid): # 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: - # Ohne das gehoert das Wurzelverzeichnis root, und ein gewoehnlicher - # Benutzer im Gast kann nichts hineinschreiben - was sich anfuehlt wie - # ein schreibgeschuetzter Datentraeger, aber keiner ist. - try: - os.chmod(mountpoint, 0o777) - except OSError: - pass - 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 +# Uebersicht und Verwerfen # --------------------------------------------------------------------------- def list_instances(proxmox, refresh=True): @@ -1632,99 +1423,5 @@ def destroy(proxmox, instance, progress=None, keep_snapshot_protection=False): except transfer.TransferError as exc: log.warning("%s: %s", name, 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 index 7ab4e6e..44afb28 100644 --- a/pvesnap/recovery_ui.py +++ b/pvesnap/recovery_ui.py @@ -5,10 +5,10 @@ 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 live 802 --transfer dumps 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 Taste v: Transfer-Laufwerke pvesnap-recovery destroy 9802 restlos verwerfen """ @@ -26,11 +26,10 @@ from .curses_util import (C_DIM, C_FOOTER, C_HEADER, C_MARK, C_OK, C_SEL, C_WARN choose, confirm, fill, init_colors, is_enter, is_escape, keybar, message, prompt, put, read_key) from .proxmox import Proxmox, ProxmoxError, pvesh_available -from .recovery import (EXCHANGE_GUEST_PATH, RecoveryError, Spec, create, +from .recovery import (RecoveryError, Spec, create, eject_transfer, insert_transfer, transfer_state, destroy, find_orphans, flatten, flatten_cost, flatten_hint, - human_bytes, - linked_volumes, list_instances, plan, pull, release, + human_bytes, linked_volumes, list_instances, plan, remove_orphans, start) from . import transfer, transfer_ui from .snapfs import list_snapshots @@ -43,7 +42,6 @@ KEYS = [ ("n", "Neu", "Neu"), ("s", "Starten", "Start"), ("h", "Herunterfahren", "Stop"), - ("a", "Austausch", "Austau."), ("x", "Verwerfen", "Verwerf."), ("v", "Laufwerke", "Laufw."), ("r", "Aktualisieren", "Aktual."), @@ -186,12 +184,8 @@ def _field_text(spec, key, kind, choices): return str(value) if value else "automatisch (naechste freie)" if key == "transfers": return transfer_ui.summary(value) - if key == "exchange": - return value or "keins" if key == "node": return value or "wie das Original" - if key == "exchange_dir": - return value or "(eigenes anlegen)" return str(value or "") @@ -280,10 +274,8 @@ def _edit(win, spec, key, kind, choices, back=False): 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) + label = {"newid": "Neue VMID (leer = automatisch)", + "node": "Node (leer = wie das Original)"}.get(key, key) answer = prompt(win, label, str(getattr(spec, key) or "")) if answer is None: return @@ -329,8 +321,6 @@ def summary_lines(plan_): % (human_bytes(plan_.vmstate_bytes) or "?") if plan_.resume else "vorhanden, wird aber nicht geladen"), curses.color_pair(C_OK) if plan_.resume else 0)) - if plan_.exchange_kind: - lines.append(("Austausch: %s" % plan_.exchange_detail, 0)) for volume in plan_.transfers: lines.append(("Transfer: %-14s %8s im Gast als %s" % (volume.name, human_bytes(volume.size), volume.label), @@ -380,7 +370,7 @@ def _new(stdscr, proxmox): if snapname is None: continue - spec = Spec(exchange_dir="") + spec = Spec() while True: if not _options(stdscr, guest, spec): break @@ -460,17 +450,6 @@ def _details(stdscr, proxmox, instance): 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 " @@ -512,7 +491,7 @@ def _details(stdscr, proxmox, instance): lines.append(("Datentraeger: eigenstaendig - haengt an keinem Snapshot", curses.color_pair(C_OK))) - footer = ("s Start | h Stop | e Auswerfen | a Austausch | f Loesen | " + footer = ("s Start | h Stop | e Auswerfen | f Loesen | " "x Verwerfen | q zurueck") key = _pages(stdscr, lines, "Wiederherstellung %s" % instance.label, footer) @@ -524,8 +503,6 @@ def _details(stdscr, proxmox, instance): elif key in ("h", "H"): _do(stdscr, "Fahre %s herunter ..." % instance.label, lambda: proxmox.shutdown_guest(instance.guest)) - elif key in ("a", "A"): - _pull(stdscr, proxmox, instance) elif key in ("e", "E"): _swap(stdscr, proxmox, instance, steckplaetze) elif key in ("f", "F"): @@ -546,55 +523,6 @@ def _do(stdscr, text, action): message(stdscr, hint, error=True) -def _pull(stdscr, proxmox, instance): - _wait_screen(stdscr, "Hole das Austauschlaufwerk auf den Host ...") - try: - path = pull(proxmox, instance) - except (RecoveryError, ProxmoxError, OSError) as exc: - message(stdscr, str(exc), error=True) - return - _pages(stdscr, _plain([ - "Das Austauschlaufwerk liegt jetzt hier:", - "", - " " + path, - "", - "Von dort kopieren, zum Beispiel mit:", - " pvesnap-explorer --path %s" % path, - " scp -r %s/. ziel:/pfad/" % path, - "", - "Wieder loesen mit: pvesnap-recovery release %d" % instance.vmid, - ]), "Austauschlaufwerk") - - -def _swap(stdscr, proxmox, instance, steckplaetze): - """Transfer-Laufwerk im laufenden Betrieb abziehen oder wieder einklinken.""" - wechselbar = [(e, drin) for e, drin in steckplaetze if e.get("kind") != "bind"] - if not wechselbar: - message(stdscr, "Hier gibt es nichts zu wechseln.", error=True) - return - - if len(wechselbar) == 1: - entry, drin = wechselbar[0] - else: - gewaehlt = choose(stdscr, "Welches Laufwerk?", - [((e, d), "%-14s %s" % (e["name"], - "eingesteckt" if d else "ausgeworfen")) - for e, d in wechselbar]) - if gewaehlt is None: - return - entry, drin = gewaehlt - - name = entry["name"] - if drin: - if not confirm(stdscr, "%r auswerfen? Im Gast vorher aushaengen!" % name): - return - _do(stdscr, "Werfe %r aus ..." % name, - lambda: eject_transfer(proxmox, instance, name)) - else: - _do(stdscr, "Klinke %r ein ..." % name, - lambda: insert_transfer(proxmox, instance, name)) - - def _flatten(stdscr, proxmox, instance, linked): if not linked: message(stdscr, "%s haengt an keinem Snapshot mehr." % instance.label) @@ -721,8 +649,6 @@ def _overview(stdscr, proxmox): _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 @@ -786,18 +712,12 @@ def _spec_from_args(args, mode): spec.node = args.node or "" spec.net = args.net or "" spec.resume = args.resume - spec.exchange = args.exchange or "" - spec.exchange_storage = args.exchange_storage or "" - spec.exchange_fs = "exfat" spec.transfers = list(args.transfer or []) - spec.exchange_dir = args.exchange_dir or "" spec.memory = args.memory or 0 spec.cores = args.cores or 0 spec.name = args.name or "" spec.keep_binds = args.keep_binds spec.start = not args.no_start - if spec.exchange_dir and not spec.exchange: - spec.exchange = "bind" return spec @@ -814,8 +734,9 @@ def _print_summary(plan_): 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 volume in plan_.transfers: + print(" Transfer : %-14s %8s im Gast als %s" + % (volume.name, human_bytes(volume.size), volume.label)) for note in plan_.notes: print(" Hinweis : %s" % note) for warning in plan_.warnings: @@ -887,15 +808,6 @@ def _print_access(instance): 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) @@ -925,50 +837,6 @@ def cmd_destroy(proxmox, args): return 0 -def cmd_pull(proxmox, args): - instance = _find_instance(proxmox, args.vmid) - path = pull(proxmox, instance) - print(path) - if not args.quiet: - print("\nDas Austauschlaufwerk liegt jetzt auf dem Host.", file=sys.stderr) - print(" pvesnap-explorer --path %s" % path, file=sys.stderr) - print(" Wieder loesen: pvesnap-recovery release %d" % instance.vmid, - file=sys.stderr) - return 0 - - -def cmd_flatten(proxmox, args): - instance = _find_instance(proxmox, args.vmid) - linked = linked_volumes(proxmox, instance) - if not linked: - print("%s haengt an keinem Snapshot mehr - nichts zu tun." - % instance.label) - return 0 - - print("%s loest sich von %s." % (instance.label, instance.origin)) - for volid in linked: - print(" %s" % volid) - cost = flatten_cost(proxmox, instance) - print("\nDabei werden die Daten wirklich kopiert%s." - % ((" - rund %s" % human_bytes(cost)) if cost else "")) - if not args.yes: - if _ask("Jetzt loesen? [j/N] ") not in ("j", "y", "ja", "yes"): - print("Abgebrochen.") - return 1 - - done, already, other = flatten(proxmox, instance, stream=True, force=args.force, - progress=lambda text: print(" " + text)) - print("\n%d Datentraeger geloest." % len(done)) - for volid in already: - print(" war schon eigenstaendig: %s" % volid) - for volid in other: - print(" %s: %s" % (volid, flatten_hint(proxmox, volid))) - if not linked_volumes(proxmox, instance): - print("\n%s ist jetzt eigenstaendig. Die Snapshots des Originals lassen " - "sich wieder loeschen." % instance.label) - return 0 - - def cmd_cleanup(proxmox, args): orphans = find_orphans(proxmox) if not orphans: @@ -1009,11 +877,6 @@ def cmd_cleanup(proxmox, args): return 1 if failed else 0 -def cmd_release(_proxmox, args): - print("geloest." if release(args.vmid) else "War nicht eingehaengt.") - return 0 - - def cmd_console(proxmox, args): instance = _find_instance(proxmox, args.vmid) print(instance.console_url()) @@ -1048,17 +911,9 @@ def _add_create_options(parser): help="Arbeitsspeicher des Snapshots laden") parser.add_argument("--no-resume", dest="resume", action="store_false", help="kalt starten, auch wenn der Snapshot RAM enthaelt") - parser.add_argument("--exchange", metavar="GROESSE", - help="Austauschlaufwerk anlegen, z.B. 20G " - "(bei Containern genuegt --exchange ja)") - parser.add_argument("--exchange-storage", metavar="STORAGE", - help="Storage dafuer (Vorgabe: wie die erste Platte)") parser.add_argument("--transfer", action="append", metavar="NAME", default=[], help="vorbereitetes Transfer-Laufwerk anhaengen " "(mehrfach moeglich)") - parser.add_argument("--exchange-dir", metavar="PFAD", - help="nur Container: dieses Host-Verzeichnis durchreichen " - "(auch ein eingehaengtes Netzlaufwerk)") parser.add_argument("--memory", type=int, help="Arbeitsspeicher in MB abweichend") parser.add_argument("--cores", type=int, help="Kerne abweichend") parser.add_argument("--name", help="Name der neuen Maschine") @@ -1083,16 +938,16 @@ def build_parser(): 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 802 --transfer dumps + neuesten Snapshot von VM 802 ohne Netzwerk starten, mit dem + vorbereiteten Transfer-Laufwerk "dumps" - pvesnap-recovery live 100 --exchange ja - Container 100 abgeschottet starten; ein Host-Verzeichnis erscheint - im Container unter %s + pvesnap-recovery live 100 --transfer dumps --transfer werkzeuge + Container 100 abgeschottet starten, mit zwei Laufwerken - pvesnap-recovery live 100 --exchange-dir /mnt/nfs/dumps - dasselbe, aber mit einem eigenen (auch eingehaengten) Verzeichnis + pvesnap-recovery + Taste v: Transfer-Laufwerke anlegen und befuellen (Commander) + Taste e: im laufenden Betrieb auswerfen und wieder einklinken pvesnap-recovery recover 802 autosnap-... --newid 9802 mit Netzwerk und gleicher Identitaet - nur wenn das Original steht! @@ -1102,7 +957,7 @@ def build_parser(): 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__) @@ -1123,14 +978,6 @@ def build_parser(): sub = subs.add_parser(name, help=helptext) sub.add_argument("vmid", type=int) - pull_ = subs.add_parser("pull", help="Austauschlaufwerk auf den Host holen") - pull_.add_argument("vmid", type=int) - pull_.add_argument("-q", "--quiet", action="store_true", - help="nur den Pfad ausgeben") - - release_ = subs.add_parser("release", help="Austauschlaufwerk wieder loesen") - release_.add_argument("vmid", type=int) - flatten_ = subs.add_parser( "flatten", help="vom Quell-Snapshot loesen - fuer den Dauerbetrieb") flatten_.add_argument("vmid", type=int) @@ -1170,8 +1017,6 @@ def main(argv=None): "recover": lambda: cmd_create(proxmox, args, "recovery"), "list": lambda: cmd_list(proxmox, args), "destroy": lambda: cmd_destroy(proxmox, args), - "pull": lambda: cmd_pull(proxmox, args), - "release": lambda: cmd_release(proxmox, args), "flatten": lambda: cmd_flatten(proxmox, args), "cleanup": lambda: cmd_cleanup(proxmox, args), "console": lambda: cmd_console(proxmox, args),