From 3a47bb06217933bfac115caeb80218b43201ce44 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Fri, 31 Jul 2026 14:00:11 +0200 Subject: [PATCH] recovery: flatten, cleanup und eine Platzpruefung, die weh getan hat flatten Linked Clones haengen fuer immer am Quell-Snapshot: der wird unloeschbar, und die Original-Platte muss bestehen bleiben. Fuer "mal reinschauen" egal, fuer eine dauerhaft uebernommene Maschine nicht. `pvesnap-recovery flatten` loest sie ueber `rbd flatten`, hebt danach den Schutz auf und meldet die Maschine als eigenstaendig. In der Oberflaeche Taste f; die Detailansicht zeigt an, ob eine Instanz noch verlinkt ist. Vorher wird gemessen, wieviel kopiert werden muss - `rbd du` zeigt je Snapshot nur den Zuwachs, kopiert wird aber der gesamte sichtbare Inhalt. Im Test: Snapshot-Zeile 56 MiB, tatsaechlicher Bedarf ueber 8 GB. Und es wird geprueft, ob der Platz reicht. Der Grund ist Erfahrung: laeuft ein Ceph-Pool beim Kopieren voll, blockiert er alle Schreibvorgaenge. Dann steht jede VM auf dem Storage - und Aufraeumen geht auch nicht mehr, weil Loeschen ebenfalls ein Schreibvorgang ist. cleanup Wird eine Wiederherstellung in der Proxmox-Oberflaeche geloescht statt mit destroy, bleiben Klon und Snapshot-Schutz liegen. `cleanup` findet das. Als Wahrheit ueber vergebene VMIDs dienen die Konfigurationsdateien unter /etc/pve/nodes/* - nicht /cluster/resources: dort fehlt ein Gast auf einem abgemeldeten Node moeglicherweise, und dann wuerden wir die Platten einer lebenden Maschine loeschen. Ohne Elternteil wird nichts angefasst, und der Schutz faellt nur, wenn kein Klon mehr daran haengt. Nebenbei: Abfragen antworten bei fehlender Eingabe mit "nein", statt einen Stacktrace zu werfen. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 82 +++++++++++ pvesnap/recovery.py | 317 +++++++++++++++++++++++++++++++++++++++-- pvesnap/recovery_ui.py | 162 ++++++++++++++++++++- 3 files changed, 543 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index c05d02f..e5a2e21 100644 --- a/README.md +++ b/README.md @@ -584,6 +584,86 @@ also nichts nachgebaut, was Proxmox schon kann. > einem qcow2-internen Snapshot kein Klon ziehen. Für diese Storages bleibt der > Weg über `pvesnap-explorer`. +**Beide Betriebsarten klonen gleich.** `live` und `recovery` unterscheiden sich +ausschließlich im Netzwerk und in den Sicherheitsabfragen — an den Datenträgern +ändert der Modus nichts. Es gibt keinen Vollkopie-Pfad. + +Nachsehen lässt sich das jederzeit auf dem Host: + +```bash +rbd -p ls -l | grep -E 'NAME|vm-' # Spalte PARENT +rbd -p du | grep -E 'NAME|vm-' # PROVISIONED gegen USED +``` + +Eine frisch geklonte 32-GB-Platte steht dort mit `PROVISIONED 32 GiB` und +`USED 80 MiB` — die 80 MiB sind alles, was der laufende Gast seither geschrieben +hat. + +### Eigenständig machen — `flatten` + +Ein Linked Clone hängt für immer am Quell-Snapshot. Für „mal reinschauen" ist +das ideal, für eine **dauerhaft übernommene** Maschine nicht: + +* der Quell-Snapshot lässt sich nicht mehr löschen (die Vorhaltezeit scheitert + mit `snapshot is protected`) +* die Original-VM muss mitsamt ihrer Platte bestehen bleiben +* der Klon wächst mit jedem Schreibvorgang + +Soll die Wiederherstellung bleiben, wird sie gelöst: + +```bash +pvesnap-recovery flatten 9802 +``` + +Dahinter steckt `rbd flatten` — die Daten aus dem Elternteil werden jetzt +wirklich in den Klon kopiert. Das dauert und belegt Platz, läuft aber im +laufenden Betrieb. Danach hängt die Maschine an nichts mehr, und der Schutz der +Quell-Snapshots wird automatisch aufgehoben, sodass die Vorhaltezeit wieder +durchkommt. In der Oberfläche liegt das auf der Taste `f`; die Detailansicht +zeigt an, ob eine Maschine noch am Snapshot hängt. + +> **Nicht von `rbd du` täuschen lassen.** Die Zeile eines Snapshots zeigt nur +> dessen *Zuwachs*, nicht seinen Inhalt. Im Test stand `@handtest01` mit +> `USED 56 MiB` da — kopiert werden beim Flatten trotzdem über 8 GB, denn +> sichtbar ist an dieser Stelle der gesamte Inhalt der Kette +> (` 14 GiB`). `pvesnap-recovery flatten` misst das deshalb vorher und +> nennt die Größe, bevor es losgeht. + +**Vorher wird geprüft, ob der Platz reicht.** Läuft ein Ceph-Pool während des +Kopierens voll, blockiert er *alle* Schreibvorgänge — dann steht nicht nur das +Flatten, sondern jede VM auf diesem Storage, und selbst das Aufräumen wird +schwierig, weil Löschen ebenfalls ein Schreibvorgang ist. `flatten` bricht +deshalb ab, wenn weniger als das 1,15-fache des Bedarfs frei ist; `--force` +setzt sich darüber hinweg. + +Schnell ist es, solange Platz da ist: 3,1 GB waren im Test in 26 Sekunden +kopiert. + +### Wenn außerhalb von pvesnap gelöscht wurde — `cleanup` + +Wird eine Wiederherstellung in der Proxmox-Oberfläche entfernt statt mit +`destroy`, bleibt etwas liegen: der Klon selbst kann übrigbleiben, und der +Quell-Snapshot bleibt geschützt — die Vorhaltezeit scheitert dann dauerhaft +mit `snapshot is protected`. + +```bash +pvesnap-recovery cleanup # zeigt Gefundenes, fragt, räumt auf +pvesnap-recovery cleanup --all # auch Datenträger ohne Elternteil +``` + +Gesucht wird nach Datenträgern der Form `vm--disk-N`, zu deren VMID es +**keine Konfigurationsdatei** unter `/etc/pve/nodes/*/` mehr gibt. Bewusst +nicht über `/cluster/resources`: Ein Gast auf einem abgemeldeten Node taucht +dort unter Umständen nicht auf — und dann würden die Platten einer lebenden +Maschine als verwaist gelten. Ohne Elternteil wird nichts von selbst entfernt, +und der Schutz eines Snapshots wird nur gelöst, wenn wirklich kein Klon mehr +daran hängt. + +Bei anderen Storages sieht es anders aus: **LVM-thin**-Klone sind ohnehin +unabhängig (der Thin-Pool zählt die Blöcke, das Original darf weg), bei **ZFS** +hängt der Klon am Snapshot und ließe sich nur per `zfs send | zfs recv` lösen — +das macht `flatten` nicht und meldet die Datenträger als nicht behandelt. + ### Der Arbeitsspeicher kommt mit Enthält der Snapshot den Arbeitsspeicher (`vmstate`, in der Konfiguration @@ -725,6 +805,8 @@ Karte erst zuschalten, wenn klar ist, dass die Luft rein ist. | `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 | ### Was beim Verwerfen passiert diff --git a/pvesnap/recovery.py b/pvesnap/recovery.py index ee941c1..9b08663 100644 --- a/pvesnap/recovery.py +++ b/pvesnap/recovery.py @@ -200,23 +200,17 @@ def volume_size(volid): return int(parts[0]) if parts and parts[0].isdigit() else 0 -def unprotect_snapshot(proxmox, volid, snapname): - """Den Schutz wieder loesen, den das Klonen auf Ceph gesetzt hat. - - RBD verlangt fuer einen Klon, dass der Quell-Snapshot geschuetzt ist - - Proxmox setzt das beim Klonen selbst. Bleibt der Schutz stehen, kann die - Vorhaltezeit den Snapshot spaeter nicht mehr loeschen. Deshalb wird er - beim Verwerfen der Wiederherstellung wieder entfernt. - """ +def _rbd_context(proxmox, volid): + """(rbd-Basisbefehl, Image-Pfad) fuer ein Volume - None, wenn kein Ceph.""" if ":" not in volid: - return False + return None storage, volname = volid.split(":", 1) try: info = proxmox._json(["get", "/storage/%s" % storage]) or {} except ProxmoxError: - return False + return None if info.get("type") != "rbd" or not have("rbd"): - return False + return None pool = info.get("pool") or "rbd" namespace = info.get("namespace") @@ -228,13 +222,192 @@ def unprotect_snapshot(proxmox, volid, snapname): keyring = "/etc/pve/priv/ceph/%s.keyring" % storage if os.path.exists(keyring): args += ["--keyring", keyring] + return args, image + +def unprotect_snapshot(proxmox, volid, snapname): + """Den Schutz wieder loesen, den das Klonen auf Ceph gesetzt hat. + + RBD verlangt fuer einen Klon, dass der Quell-Snapshot geschuetzt ist - + Proxmox setzt das beim Klonen selbst. Bleibt der Schutz stehen, kann die + Vorhaltezeit den Snapshot spaeter nicht mehr loeschen. Deshalb wird er + beim Verwerfen der Wiederherstellung wieder entfernt. + """ + context = _rbd_context(proxmox, volid) + if context is None: + return False + args, image = context result = run(args + ["snap", "unprotect", image, "--snap", snapname], check=False, timeout=120) log.debug("unprotect %s@%s: %s", image, snapname, result or "ok") return True +def linked_volumes(proxmox, instance): + """Welche Datentraeger noch am Quell-Snapshot haengen.""" + linked = [] + for volid in instance.volumes: + context = _rbd_context(proxmox, volid) + if context is None: + continue + args, image = context + if "parent:" in run(args + ["info", image], check=False, timeout=60): + linked.append(volid) + return linked + + +def flatten_cost(proxmox, instance): + """Wieviele Bytes ein flatten kopieren muesste. + + Wichtig, weil `rbd du` je Snapshot nur den *Zuwachs* zeigt. Kopiert wird + aber der gesamte an dieser Stelle sichtbare Inhalt - das ist typisch um + Groessenordnungen mehr, als die Zeile des Snapshots vermuten laesst. + """ + total = 0 + for volid in linked_volumes(proxmox, instance): + context = _rbd_context(proxmox, volid) + if context is None: + continue + args, image = context + parent = "" + for line in run(args + ["info", image], check=False, timeout=60).splitlines(): + if line.strip().startswith("parent:"): + parent = line.split(":", 1)[1].strip() + if not parent: + continue + # "pool/image@snap" -> die Kette des Elternteils vermessen. Der Pool + # muss dranbleiben, sonst sucht rbd im Standard-Pool. + base = parent.split("@")[0] + measured = 0 + for line in run(args + ["du", base], check=False, timeout=300).splitlines(): + parts = line.split() + if len(parts) < 2 or parts[0] in ("NAME",): + continue + # "... 32 GiB 14 GiB" - die letzten beiden Felder sind USED + value = _parse_bytes(" ".join(parts[-2:])) + if line.strip().startswith(""): + measured = value + break + measured = max(measured, value) + total += measured + return total + + +_UNITS = {"B": 1, "KIB": 1024, "MIB": 1024 ** 2, "GIB": 1024 ** 3, + "TIB": 1024 ** 4, "K": 1024, "M": 1024 ** 2, "G": 1024 ** 3} + + +def _parse_bytes(text): + match = re.match(r"^([\d.]+)\s*([A-Za-z]+)?$", str(text).strip()) + if not match: + return 0 + return int(float(match.group(1)) * _UNITS.get((match.group(2) or "B").upper(), 1)) + + +def run_streamed(args, timeout=6 * 3600): + """Wie run(), laesst das Kommando aber direkt ins Terminal schreiben. + + Fuer `rbd flatten`: das bringt eine eigene Fortschrittsanzeige mit, und bei + einer Platte, deren Kopie eine Stunde dauert, ist die mehr wert als eine + aufgeraeumte Ausgabe. In der ncurses-Oberflaeche waere sie dagegen toedlich - + dort wird weiter run() mit --no-progress verwendet. + """ + log.debug("exec: %s", " ".join(str(a) for a in args)) + try: + proc = subprocess.run([str(a) for a in args], timeout=timeout) + except FileNotFoundError: + raise RecoveryError("Kommando nicht gefunden: %s" % args[0]) + except subprocess.TimeoutExpired: + raise RecoveryError("Zeitueberschreitung bei: %s" % " ".join(str(a) for a in args)) + if proc.returncode != 0: + raise RecoveryError("%s ist fehlgeschlagen (Rueckgabewert %d)" + % (args[0], proc.returncode)) + return "" + + +def _check_space(proxmox, instance, force=False, reserve=1.15): + """Vor dem Flatten nachsehen, ob der Platz ueberhaupt reicht. + + Bitter gelernt: laeuft ein Storage waehrend des Kopierens voll, blockiert + Ceph *alle* Schreibvorgaenge im Pool. Dann steht nicht nur das Flatten, + sondern jede laufende VM - und selbst Aufraeumen wird schwierig, weil auch + Loeschen ein Schreibvorgang ist. + """ + needed = flatten_cost(proxmox, instance) + if not needed: + return + storages = {v.split(":", 1)[0] for v in instance.volumes if ":" in v} + for name in sorted(storages): + try: + info = proxmox._json(["get", "/nodes/%s/storage/%s/status" + % (instance.node, name)]) or {} + except ProxmoxError: + continue + free = int(info.get("avail") or 0) + if not free: + continue + if free < needed * reserve: + message = ("Auf '%s' sind nur %s frei, gebraucht werden aber rund %s. " + "Laeuft der Storage dabei voll, stehen alle Maschinen " + "darauf - auch das Aufraeumen." % (name, human_bytes(free), + human_bytes(needed))) + if not force: + raise RecoveryError(message + " Mit --force trotzdem.") + log.warning("%s (--force)", message) + + +def flatten(proxmox, instance, progress=None, stream=False, force=False): + """Die Klone vom Quell-Snapshot loesen. + + Danach steht die Maschine auf eigenen Beinen: Sie belegt ihren Platz + vollstaendig selbst, und die Snapshots, aus denen sie entstanden ist, + lassen sich wieder loeschen. Fuer eine dauerhafte Wiederherstellung ist + das der letzte Schritt - vorher haengt sie fuer immer am Original. + + Das kostet Zeit und Platz: hier werden die Daten wirklich kopiert. + """ + def step(text): + log.info("%s", text) + if progress: + progress(text) + + if not instance.volumes: + raise RecoveryError("Zu %s sind keine Datentraeger vermerkt." % instance.label) + + _check_space(proxmox, instance, force=force) + + flattened, already, unsupported = [], [], [] + for volid in instance.volumes: + context = _rbd_context(proxmox, volid) + if context is None: + unsupported.append(volid) + continue + args, image = context + if "parent:" not in run(args + ["info", image], check=False, timeout=60): + already.append(volid) + continue + step("Loese %s vom Quell-Snapshot - dabei werden die Daten wirklich " + "kopiert" % volid) + if stream: + run_streamed(args + ["flatten", image]) + else: + run(args + ["flatten", image, "--no-progress"], timeout=6 * 3600) + flattened.append(volid) + + # Erst wenn nichts mehr am Snapshot haengt, darf der Schutz weg. + if not linked_volumes(proxmox, instance): + for entry in list(instance.protected): + try: + step("Hebe Schutz von %s@%s auf" % (entry[0], entry[1])) + unprotect_snapshot(proxmox, entry[0], entry[1]) + except (RecoveryError, IndexError, TypeError): + pass + instance.protected = [] + _registry_add(instance) + + return flattened, already, unsupported + + # --------------------------------------------------------------------------- # Groessenangaben # --------------------------------------------------------------------------- @@ -606,6 +779,11 @@ def plan(proxmox, guest, snapname, spec): raise RecoveryError("Ein geladener Arbeitsspeicher laesst sich nicht " "vergroessern oder verkleinern - entweder --memory " "weglassen oder --no-resume verwenden.") + if spec.mode == "recovery": + result.notes.append( + "Die Datentraeger sind Linked Clones - sie haengen am Quell-Snapshot, " + "der dadurch unloeschbar wird. Soll die Maschine dauerhaft laufen, " + "spaeter mit 'pvesnap-recovery flatten %d' loesen." % result.newid) if result.resume: result.notes.append("Der Arbeitsspeicher (%s) wird kopiert; die Maschine " "laeuft danach genau dort weiter, wo sie beim Snapshot " @@ -1055,6 +1233,123 @@ def forget(vmid): _registry_remove(vmid) +# --------------------------------------------------------------------------- +# Liegengebliebenes finden +# --------------------------------------------------------------------------- + +def used_vmids(): + """Alle vergebenen VMIDs - aus den Konfigurationsdateien. + + Bewusst nicht ueber /cluster/resources: ein Gast auf einem abgemeldeten + Node taucht dort unter Umstaenden nicht auf, und dann wuerden wir die + Platten einer lebenden Maschine fuer verwaist halten. /etc/pve ist + cluster-weit und kennt auch abgeschaltete Gaeste. + """ + used = set() + try: + nodes = os.listdir(CONF_ROOT) + except OSError as exc: + raise RecoveryError("%s nicht lesbar: %s" % (CONF_ROOT, exc)) + for node in nodes: + for sub in ("qemu-server", "lxc"): + directory = os.path.join(CONF_ROOT, node, sub) + try: + names = os.listdir(directory) + except OSError: + continue + for name in names: + if name.endswith(".conf") and name[:-5].isdigit(): + used.add(int(name[:-5])) + return used + + +_IMAGE_NAME = re.compile(r"^vm-(\d+)-(?:disk|state|cloudinit)") + + +def find_orphans(proxmox): + """Datentraeger, zu denen es keinen Gast mehr gibt. + + Entsteht, wenn eine Wiederherstellung ausserhalb von pvesnap entfernt wird - + etwa in der Proxmox-Oberflaeche. Der Gast ist dann weg, sein Klon bleibt + liegen, und der Quell-Snapshot bleibt geschuetzt und damit unloeschbar. + """ + used = used_vmids() + if not used: + raise RecoveryError( + "Unter %s steht keine einzige Gast-Konfiguration - das kann nicht " + "stimmen. Zur Sicherheit wird nichts angefasst." % CONF_ROOT) + + found = [] + for storage in proxmox._json(["get", "/storage"]) or []: + if storage.get("type") != "rbd": + continue + name = storage.get("storage") + context = _rbd_context(proxmox, "%s:dummy" % name) + if context is None: + continue + args, _ = context + pool = storage.get("pool") or "rbd" + namespace = storage.get("namespace") + prefix = "%s/%s" % (pool, (namespace + "/") if namespace else "") + + for image in run(args + ["ls", pool.rstrip("/")], check=False, + timeout=120).splitlines(): + image = image.strip() + match = _IMAGE_NAME.match(image) + if not match or int(match.group(1)) in used: + continue + try: + info = json.loads(run(args + ["info", prefix + image, + "--format", "json"], timeout=60)) + except (RecoveryError, ValueError): + continue + parent = info.get("parent") or {} + found.append({ + "volid": "%s:%s" % (name, image), + "vmid": int(match.group(1)), + "bytes": int(info.get("size") or 0), + "parent": ("%s/%s@%s" % (parent.get("pool"), parent.get("image"), + parent.get("snapshot"))) + if parent else "", + "args": args, + "image": prefix + image, + }) + return found + + +def remove_orphans(proxmox, orphans, progress=None): + """Gefundene Reste entfernen und den Schutz ihrer Quell-Snapshots loesen.""" + def step(text): + log.info("%s", text) + if progress: + progress(text) + + removed, failed = [], [] + for entry in orphans: + try: + step("Entferne %s" % entry["volid"]) + free_volume(entry["volid"]) + removed.append(entry["volid"]) + except RecoveryError as exc: + failed.append("%s: %s" % (entry["volid"], exc)) + continue + + parent = entry.get("parent") + if not parent: + continue + args, image_snap = entry["args"], parent + base, _, snapname = image_snap.partition("@") + # Nur loesen, wenn wirklich kein Klon mehr daran haengt. + children = run(args + ["children", image_snap], check=False, timeout=120) + if children.strip(): + step("%s hat noch andere Klone - Schutz bleibt" % parent) + continue + step("Hebe Schutz von %s auf" % parent) + run(args + ["snap", "unprotect", base, "--snap", snapname], + check=False, timeout=120) + return removed, failed + + def destroy(proxmox, instance, progress=None, keep_snapshot_protection=False): """Eine Wiederherstellung restlos entfernen.""" def step(text): diff --git a/pvesnap/recovery_ui.py b/pvesnap/recovery_ui.py index 8f8315b..cbeb554 100644 --- a/pvesnap/recovery_ui.py +++ b/pvesnap/recovery_ui.py @@ -27,8 +27,9 @@ from .curses_util import (C_DIM, C_FOOTER, C_HEADER, C_MARK, C_OK, C_SEL, C_WARN 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) + destroy, find_orphans, flatten, flatten_cost, human_bytes, + linked_volumes, list_instances, plan, pull, release, + remove_orphans, start) from .snapfs import list_snapshots from .util import truncate @@ -474,8 +475,25 @@ def _details(stdscr, proxmox, instance): "ersten Start lief die Maschine genau weiter.", curses.color_pair(C_DIM))) - footer = ("s Starten | h Herunterfahren | a Austausch | x Verwerfen | " - "q zurueck") + try: + linked = linked_volumes(proxmox, instance) + except (RecoveryError, ProxmoxError): + linked = [] + lines.append(("", 0)) + if linked: + lines.append(("Datentraeger: Linked Clone - haengt am Quell-Snapshot", + curses.color_pair(C_MARK))) + lines.append((" Belegt nur, was seither geschrieben wurde. Der " + "Quell-Snapshot ist dafuer unloeschbar.", + curses.color_pair(C_DIM))) + lines.append((" Fuer den Dauerbetrieb mit f loesen (kopiert die " + "Daten wirklich).", curses.color_pair(C_DIM))) + elif instance.volumes: + lines.append(("Datentraeger: eigenstaendig - haengt an keinem Snapshot", + curses.color_pair(C_OK))) + + footer = ("s Starten | h Herunterfahren | a Austausch | f Loesen | " + "x Verwerfen | q zurueck") key = _pages(stdscr, lines, "Wiederherstellung %s" % instance.label, footer) if key in ("q", "\x1b") or is_escape(key): @@ -488,6 +506,8 @@ def _details(stdscr, proxmox, instance): lambda: proxmox.shutdown_guest(instance.guest)) elif key in ("a", "A"): _pull(stdscr, proxmox, instance) + elif key in ("f", "F"): + _flatten(stdscr, proxmox, instance, linked) elif key in ("x", "X"): if _destroy(stdscr, proxmox, instance): return @@ -524,6 +544,34 @@ def _pull(stdscr, proxmox, instance): ]), "Austauschlaufwerk") +def _flatten(stdscr, proxmox, instance, linked): + if not linked: + message(stdscr, "%s haengt an keinem Snapshot mehr." % instance.label) + return + _wait_screen(stdscr, "Ermittle, wieviel kopiert werden muss ...") + try: + cost = flatten_cost(proxmox, instance) + except (RecoveryError, ProxmoxError): + cost = 0 + if not confirm(stdscr, "%s kopieren und dauerhaft loesen?" + % (human_bytes(cost) if cost else "%d Datentraeger" % len(linked))): + return + progress = _Progress(stdscr, "Loese %s vom Quell-Snapshot" % instance.label) + try: + done, _already, other = flatten(proxmox, instance, progress=progress) + except (RecoveryError, ProxmoxError) as exc: + _pages(stdscr, [(line, curses.color_pair(C_WARN)) + for line in _wrap(str(exc), 74)] + [("", 0)] + + _plain(progress.lines), "Fehlgeschlagen") + return + lines = ["%d Datentraeger geloest." % len(done), ""] + lines += ["nicht behandelt (kein Ceph): %s" % volid for volid in other] + if not linked_volumes(proxmox, instance): + lines += ["", "%s ist jetzt eigenstaendig. Die Snapshots des Originals " + "lassen sich wieder loeschen." % instance.label] + _pages(stdscr, _plain(lines), "Fertig") + + def _destroy(stdscr, proxmox, instance): if not confirm(stdscr, "%s wirklich verwerfen? Alle Aenderungen darin sind weg." % instance.label): @@ -703,6 +751,19 @@ def _print_summary(plan_): print(" GEFAHR : %s" % reason) +def _ask(question): + """Frage am Terminal. Ohne Eingabemoeglichkeit gilt: nein. + + Wird die Ausgabe in ein Skript geleitet, liefert input() ein EOFError - + das darf keinen Stacktrace geben, sondern muss als Absage zaehlen. + """ + try: + return input(question).strip().lower() + except (EOFError, KeyboardInterrupt): + print() + return "" + + def cmd_create(proxmox, args, mode): guest = _find_guest(proxmox, args.vmid) snapname = args.snapshot or _newest_snapshot(proxmox, guest) @@ -727,13 +788,13 @@ def cmd_create(proxmox, args, mode): "bewusst --force setzen.", file=sys.stderr) return 2 print("\nWer das trotzdem will, tippt jetzt das Wort ja aus.") - if input("Trotzdem anlegen? [ja/nein] ").strip().lower() not in ("ja", "yes"): + if _ask("Trotzdem anlegen? [ja/nein] ") not in ("ja", "yes"): print("Abgebrochen.") return 1 elif not args.yes: if plan_.warnings: print("\nEs gibt Warnungen - bitte oben lesen.") - answer = input("\nAnlegen? [j/N] ").strip().lower() + answer = _ask("\nAnlegen? [j/N] ") if answer not in ("j", "y", "ja", "yes"): print("Abgebrochen.") return 1 @@ -784,7 +845,7 @@ def cmd_destroy(proxmox, args): 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"): + if _ask("Wirklich? [j/N] ") not in ("j", "y", "ja", "yes"): print("Abgebrochen.") return 1 destroy(proxmox, instance, progress=lambda text: print(" " + text)) @@ -803,6 +864,78 @@ def cmd_pull(proxmox, args): 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(" nicht behandelt (kein Ceph-Datentraeger): %s" % volid) + if not linked_volumes(proxmox, instance): + print("\n%s ist jetzt eigenstaendig. Die Snapshots des Originals lassen " + "sich wieder loeschen." % instance.label) + return 0 + + +def cmd_cleanup(proxmox, args): + orphans = find_orphans(proxmox) + if not orphans: + print("Nichts liegengeblieben.") + return 0 + + clones = [e for e in orphans if e["parent"]] + plain = [e for e in orphans if not e["parent"]] + + print("Datentraeger, zu denen es keinen Gast mehr gibt:\n") + for entry in clones: + print(" %-26s %8s Klon von %s" + % (entry["volid"], human_bytes(entry["bytes"]), entry["parent"])) + for entry in plain: + print(" %-26s %8s kein Klon" + % (entry["volid"], human_bytes(entry["bytes"]))) + + wanted = orphans if args.all else clones + if not wanted: + print("\nNur Datentraeger ohne Elternteil gefunden. Die werden nicht von\n" + "selbst entfernt - mit --all auch diese.") + return 0 + + print("\nEntfernt werden %d Datentraeger%s." + % (len(wanted), "" if args.all else " (nur Klone; --all nimmt auch die anderen)")) + print("Der Schutz der Quell-Snapshots wird dabei geloest, sofern kein\n" + "anderer Klon mehr daran haengt.") + if not args.yes: + if _ask("\nEntfernen? [j/N] ") not in ("j", "y", "ja", "yes"): + print("Abgebrochen.") + return 1 + + removed, failed = remove_orphans(proxmox, wanted, + progress=lambda text: print(" " + text)) + print("\n%d entfernt." % len(removed)) + for problem in failed: + print(" fehlgeschlagen: %s" % problem, file=sys.stderr) + return 1 if failed else 0 + + def cmd_release(_proxmox, args): print("geloest." if release(args.vmid) else "War nicht eingehaengt.") return 0 @@ -924,6 +1057,19 @@ def build_parser(): release_ = subs.add_parser("release", help="Austauschlaufwerk wieder loesen") release_.add_argument("vmid", type=int) + flatten_ = subs.add_parser( + "flatten", help="vom Quell-Snapshot loesen - fuer den Dauerbetrieb") + flatten_.add_argument("vmid", type=int) + flatten_.add_argument("-y", "--yes", action="store_true", help="nicht nachfragen") + flatten_.add_argument("--force", action="store_true", + help="auch starten, wenn der Storage dafuer zu voll ist") + + cleanup = subs.add_parser( + "cleanup", help="Klone entfernen, zu denen es keinen Gast mehr gibt") + cleanup.add_argument("--all", action="store_true", + help="auch Datentraeger ohne Elternteil entfernen") + cleanup.add_argument("-y", "--yes", action="store_true", help="nicht nachfragen") + destroy_ = subs.add_parser("destroy", help="Wiederherstellung restlos verwerfen") destroy_.add_argument("vmid", type=int) destroy_.add_argument("-y", "--yes", action="store_true", help="nicht nachfragen") @@ -952,6 +1098,8 @@ def main(argv=None): "destroy": lambda: cmd_destroy(proxmox, args), "pull": lambda: cmd_pull(proxmox, args), "release": lambda: cmd_release(proxmox, args), + "flatten": lambda: cmd_flatten(proxmox, args), + "cleanup": lambda: cmd_cleanup(proxmox, args), "console": lambda: cmd_console(proxmox, args), "start": lambda: cmd_start(proxmox, args), "stop": lambda: cmd_stop(proxmox, args),