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),