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) <noreply@anthropic.com>
This commit is contained in:
duffyduck
2026-07-31 14:00:11 +02:00
co-authored by Claude Opus 5
parent dc5c7d417f
commit 3a47bb0621
3 changed files with 543 additions and 18 deletions
+306 -11
View File
@@ -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("<TOTAL>"):
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):