Explorer: Transfer-Laufwerke auf F2; cleanup findet verwaiste Snapshot-Sperren

Zwei Luecken, die seit dem Umbau offenstanden.

1. Transfer-Laufwerke waren aus dem Explorer nicht erreichbar

F2 zeigte nur die Dateisysteme des Snapshots. Jetzt stehen die
Transfer-Laufwerke in derselben Liste - aus Sicht des Bedieners ist es
dieselbe Frage ("wo soll ich hinschauen?"), und der Weg ueber
pvesnap-recovery entfaellt.

Das gewaehlte Laufwerk landet im rechten Fenster, links bleibt der
Snapshot. Damit laesst sich direkt aus einem Snapshot auf das
Austauschmedium kopieren, das anschliessend in die wiederhergestellte
Maschine wandert - ohne Zwischenlager auf dem Host.

Ausgehaengt wird beim Verlassen, und zwar nur, was wir selbst eingehaengt
haben: ein Laufwerk, das schon vorher am Host hing, gehoert jemand
anderem. Das finally faengt auch Absturz und Strg-C ab - bliebe es
eingehaengt, gaelte es spaeter als belegt.

2. cleanup fand geschuetzte Snapshots ohne Klon nicht

Loescht jemand eine Wiederherstellung in der Proxmox-Oberflaeche, raeumt
Proxmox den Klon durchaus mit ab - aber nicht den Schutz seines
Quell-Snapshots (rbd snap protect, den setzt es beim Klonen selbst).
Zurueck bleibt ein geschuetzter Snapshot ohne Klon: die Vorhaltezeit
scheitert an ihm jede Nacht mit "snapshot is protected", und
find_orphans() findet prinzipiell nichts, weil es den Datentraeger, nach
dem es sucht, wirklich nicht mehr gibt. Dasselbe entsteht nach einem von
Hand ausgefuehrten rbd flatten.

find_stale_protections() sucht deshalb direkt nach der Ursache: jeder
geschuetzte Snapshot, an dem kein Klon haengt. Ein solcher Schutz hat
keinen Zweck - er existiert einzig dafuer, dass ein Klon seine Grundlage
behaelt. Geloest wird nur der Schutz, geloescht wird nichts. Vor jedem
Loesen wird ein zweites Mal nachgesehen, ob inzwischen doch ein Klon
daran haengt.

cleanup laeuft damit in zwei Durchgaengen und liegt neu auch in der
Oberflaeche auf Taste c - man sucht sonst lange nach einem Befehl, den
man nur im Notfall braucht.

Getestet gegen die Kulisse der Handbuch-Werkstatt: die rbd-Attrappe
kennt jetzt snap ls und children und enthaelt beide Zustaende
nebeneinander - ein geschuetzter Snapshot mit Klon (muss in Ruhe
gelassen werden) und einer ohne (muss gefunden werden).

Handbuch und README nachgezogen, zwei Bildschirmfotos dazu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
duffyduck
2026-08-11 09:39:25 +02:00
co-authored by Claude Opus 5
parent 59e7224297
commit d9d9345d03
21 changed files with 727 additions and 88 deletions
+97 -8
View File
@@ -29,6 +29,7 @@ from dataclasses import dataclass, field
from datetime import datetime
from . import __version__
from . import transfer
from .curses_util import (C_DIM, C_DIR, C_FOOTER, C_HEADER, C_MARK, C_OK, C_SEL,
C_WARN, ask, choose, confirm, fill, init_colors, is_enter,
is_escape, keybar, message, prompt, put, read_key)
@@ -378,6 +379,10 @@ class Explorer:
self.session = session
self.subtitle = subtitle
self.status = ""
# Transfer-Laufwerke, die *wir* eingehaengt haben. Nur die haengen wir
# beim Verlassen wieder aus - ein Laufwerk, das schon vorher am Host
# hing, gehoert jemand anderem.
self.mounted_transfers = set()
@property
def source(self):
@@ -494,7 +499,15 @@ class Explorer:
def run(self, win):
for pane in self.panes:
pane.reload()
try:
self._loop(win)
finally:
# Auch bei Absturz oder Strg-C: ein Transfer-Laufwerk, das am Host
# eingehaengt bleibt, gilt spaeter als belegt und laesst sich dann
# nicht in eine Maschine haengen.
self._release_transfers()
def _loop(self, win):
while True:
self.draw(win)
win.refresh()
@@ -661,14 +674,85 @@ class Explorer:
message(win, "Kein Verzeichnis: %s" % path, error=True)
def _switch_mount(self, win):
if not self.session or len(self.session.mounts) < 2:
message(win, "Es ist nur ein Dateisystem eingebunden.")
"""Laufwerk waehlen - Dateisystem des Snapshots oder Transfer-Laufwerk.
Beides steht in derselben Liste, weil es aus Sicht des Bedieners
dieselbe Frage ist: "wo soll ich hinschauen?". Ein Transfer-Laufwerk
hier zu erreichen spart den Umweg ueber pvesnap-recovery - man kann
aus dem Snapshot direkt auf das Austauschmedium kopieren, das
anschliessend in die wiederhergestellte Maschine wandert.
"""
entries = []
filesystems = self.session.mount_entries() if self.session else []
if len(filesystems) > 1:
for path, text in filesystems:
entries.append((("fs", path), "Snapshot: %s" % text))
for volume in self._transfer_volumes():
state = "" if volume.free or volume.mountpoint \
else " [%s]" % volume.state_text
entries.append((("transfer", volume.name),
"Transfer: %-14s %8s %s%s"
% (volume.name, human_bytes(volume.size),
truncate(volume.note, 26), state)))
if not entries:
message(win, "Es ist nur ein Dateisystem eingebunden, und "
"Transfer-Laufwerke gibt es noch keine.")
return
entries = self.session.mount_entries()
chosen = choose(win, "Welches Dateisystem?", entries)
if chosen:
self.panes[0].root = chosen
self.panes[0].go(chosen)
chosen = choose(win, "Welches Laufwerk?", entries)
if not chosen:
return
kind, value = chosen
if kind == "fs":
self.panes[0].root = value
self.panes[0].go(value)
else:
self._open_transfer(win, value)
def _transfer_volumes(self):
try:
return transfer.load()
except transfer.TransferError as exc:
self.status = str(exc)
return []
def _open_transfer(self, win, name):
"""Ein Transfer-Laufwerk einhaengen und ins Arbeitsfenster holen.
Ins Arbeitsfenster, nicht ins Snapshot-Fenster: links steht der
Snapshot, aus dem geholt wird, rechts das Ziel. Genau dorthin gehoert
ein Austauschmedium. Gibt es keinen Snapshot (Aufruf aus der
Laufwerksverwaltung heraus), landet es im gerade aktiven Fenster.
"""
target = self.panes[1] if self.session else self.source
try:
already = bool(transfer.get(name).mountpoint)
path = transfer.host_mount(name)
except transfer.TransferError as exc:
message(win, str(exc), error=True)
return
if not already:
self.mounted_transfers.add(name)
target.root = "/"
target.marks.clear()
if not target.go(path):
message(win, "Eingehaengt unter %s, aber nicht lesbar." % path, error=True)
return
target.title = "Transfer: %s" % name
self.status = "%s eingehaengt unter %s" % (name, path)
def _release_transfers(self):
"""Was wir eingehaengt haben, geben wir auch wieder frei."""
for name in sorted(self.mounted_transfers):
try:
transfer.host_umount(name)
except transfer.TransferError as exc:
log.warning("Transfer-Laufwerk %s blieb eingehaengt: %s", name, exc)
self.mounted_transfers.clear()
def _view(self, win, entry):
if not entry.name or entry.is_dir or entry.is_special:
@@ -732,7 +816,8 @@ class Explorer:
"F3 / v Datei ansehen (Text oder Hex)",
"F7 / n neues Verzeichnis (nur im lokalen Fenster)",
"F6 / g Verzeichnis direkt eingeben",
"F2 / m anderes Dateisystem des Snapshots waehlen",
"F2 / m Laufwerk waehlen: anderes Dateisystem des Snapshots",
" oder ein Transfer-Laufwerk ins rechte Fenster",
"r neu einlesen",
"F10 / q zurueck zur Snapshot-Auswahl",
"",
@@ -741,6 +826,10 @@ class Explorer:
"Geraetedateien und Sockets werden uebersprungen, symbolische",
"Verweise bleiben Verweise.",
"",
"Transfer-Laufwerke (F2) sind Austauschmedien fuer Maschinen ohne",
"Netz: hier befuellen, spaeter in die Wiederherstellung haengen.",
"Beim Verlassen werden sie wieder ausgehaengt.",
"",
"Es gibt drei Ebenen: Gastauswahl -> Snapshot-Auswahl -> Dateien.",
"F10 oder q geht jeweils eine Ebene zurueck; der Snapshot wird",
"dabei wieder ausgehaengt. Das rechte Fenster behaelt sein",
+113 -12
View File
@@ -1535,18 +1535,7 @@ def find_orphans(proxmox):
"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 name, args, pool, prefix in _rbd_storages(proxmox):
for image in run(args + ["ls", pool.rstrip("/")], check=False,
timeout=120).splitlines():
image = image.strip()
@@ -1605,6 +1594,118 @@ def remove_orphans(proxmox, orphans, progress=None):
return removed, failed
def _is_protected(entry):
"""rbd meldet das je nach Version als true oder als "true"."""
value = entry.get("protected")
return value is True or str(value).strip().lower() == "true"
def _rbd_storages(proxmox):
"""[(Name, rbd-Basisbefehl, Praefix pool/[namespace/])] aller Ceph-Storages."""
result = []
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")
result.append((name, args, pool,
"%s/%s" % (pool, (namespace + "/") if namespace else "")))
return result
def find_stale_protections(proxmox, progress=None):
"""Geschuetzte Snapshots, an denen kein Klon mehr haengt.
Der Fall, den find_orphans() nicht findet: Wird eine Wiederherstellung in
der Proxmox-Oberflaeche geloescht, raeumt Proxmox den Klon durchaus mit ab -
aber nicht den Schutz seines Quell-Snapshots. Den setzt es beim Klonen
selbst (`rbd snap protect`, RBD verlangt ihn), und niemand nimmt ihn wieder
weg. Zurueck bleibt ein geschuetzter Snapshot ohne Klon: Die Vorhaltezeit
scheitert an ihm fuer immer mit "snapshot is protected", und nach einem
liegengebliebenen Datentraeger sucht man vergeblich - der ist ja wirklich
weg.
Dasselbe entsteht nach einem von Hand ausgefuehrten `rbd flatten`: der Klon
haengt danach an nichts mehr, der Schutz steht aber weiter.
Ein geschuetzter Snapshot ohne Kinder hat keinen Zweck - der Schutz
existiert einzig, damit ein Klon seine Grundlage behaelt.
"""
def step(text):
if progress:
progress(text)
found = []
for name, args, pool, prefix in _rbd_storages(proxmox):
images = [line.strip() for line
in run(args + ["ls", pool.rstrip("/")], check=False,
timeout=120).splitlines() if line.strip()]
step("%s: %d Datentraeger" % (name, len(images)))
for image in images:
try:
snapshots = json.loads(
run(args + ["snap", "ls", prefix + image, "--format", "json"],
check=False, timeout=60, quiet=True) or "[]")
except ValueError:
continue
if not isinstance(snapshots, list):
continue
for snapshot in snapshots:
if not _is_protected(snapshot):
continue
snapname = str(snapshot.get("name") or "")
if not snapname:
continue
target = "%s%s@%s" % (prefix, image, snapname)
if run(args + ["children", target], check=False,
timeout=120, quiet=True).strip():
continue # da haengt noch etwas dran - gut so
found.append({
"volid": "%s:%s" % (name, image),
"snapshot": snapname,
"image": prefix + image,
"bytes": int(snapshot.get("size") or 0),
"args": args,
})
return found
def release_protections(proxmox, entries, progress=None):
"""Den Schutz der gefundenen Snapshots loesen.
Loescht nichts - danach darf die Vorhaltezeit den Snapshot wieder
wegraeumen, mehr passiert nicht.
"""
def step(text):
log.info("%s", text)
if progress:
progress(text)
released, failed = [], []
for entry in entries:
target = "%s@%s" % (entry["image"], entry["snapshot"])
# Zwischen Suchen und Freigeben kann jemand einen Klon angelegt haben.
# Ein zweiter Blick kostet nichts und verhindert, dass wir einem
# frischen Klon die Grundlage unter den Fuessen wegziehen.
if run(entry["args"] + ["children", target], check=False,
timeout=120, quiet=True).strip():
failed.append("%s: haengt inzwischen wieder ein Klon daran" % target)
continue
step("Hebe Schutz von %s auf" % target)
try:
run(entry["args"] + ["snap", "unprotect", entry["image"],
"--snap", entry["snapshot"]], timeout=120)
released.append(target)
except RecoveryError as exc:
failed.append("%s: %s" % (target, exc))
return released, failed
def destroy(proxmox, instance, progress=None, keep_snapshot_protection=False):
"""Eine Wiederherstellung restlos entfernen."""
def step(text):
+129 -3
View File
@@ -29,10 +29,11 @@ from .curses_util import (C_DIM, C_FOOTER, C_HEADER, C_MARK, C_OK, C_SEL, C_WARN
from .proxmox import Proxmox, ProxmoxError, pvesh_available
from . import transfer, transfer_ui
from .recovery import (RecoveryError, Spec, create, destroy, eject_transfer,
find_orphans, flatten, flatten_cost, flatten_hint,
find_orphans, find_stale_protections, flatten,
flatten_cost, flatten_hint,
human_bytes, insert_transfer, linked_volumes,
display_state, list_instances, node_address, plan,
remove_orphans, set_display,
release_protections, remove_orphans, set_display,
spice_file, start, transfer_state)
from .snapfs import list_snapshots
from .util import truncate
@@ -46,6 +47,7 @@ KEYS = [
("h", "Herunterfahren", "Stop"),
("x", "Verwerfen", "Verwerf."),
("v", "Laufwerke", "Laufw."),
("c", "Aufraeumen", "Aufr."),
("r", "Aktualisieren", "Aktual."),
("q", "Beenden", "Ende"),
]
@@ -878,6 +880,9 @@ def _overview(stdscr, proxmox):
elif key in ("v", "V"):
transfer_ui.screen(stdscr, browse=_browse_transfer)
reload_ = True
elif key in ("c", "C"):
_cleanup(stdscr, proxmox)
reload_ = True
elif key in ("r", "R"):
reload_ = True
elif key == curses.KEY_UP and instances:
@@ -901,6 +906,80 @@ def _overview(stdscr, proxmox):
reload_ = True
def _cleanup(stdscr, proxmox):
"""Reste aufraeumen, die entstehen, wenn jemand an pvesnap vorbei loescht.
Zwei verschiedene Dinge, deshalb zwei Durchgaenge: liegengebliebene
Datentraeger - und geschuetzte Snapshots, deren Klon es nicht mehr gibt.
Den zweiten Fall findet die Suche nach Datentraegern prinzipiell nicht.
"""
_wait_screen(stdscr, "Suche liegengebliebene Datentraeger ...")
try:
orphans = find_orphans(proxmox)
except (RecoveryError, ProxmoxError) as exc:
message(stdscr, str(exc), error=True)
return
fortschritt = _Progress(stdscr, "Suche geschuetzte Snapshots ohne Klon")
try:
stale = find_stale_protections(proxmox, progress=fortschritt)
except (RecoveryError, ProxmoxError) as exc:
message(stdscr, str(exc), error=True)
return
klone = [e for e in orphans if e["parent"]]
if not klone and not stale:
message(stdscr, "Nichts liegengeblieben - alles sauber.")
return
zeilen = []
if klone:
zeilen.append(("Datentraeger, zu denen es keinen Gast mehr gibt:", curses.A_BOLD))
for entry in klone:
zeilen.append((" %-26s %8s Klon von %s"
% (entry["volid"], human_bytes(entry["bytes"]),
entry["parent"]), curses.color_pair(C_MARK)))
zeilen.append(("", 0))
if stale:
zeilen.append(("Geschuetzte Snapshots, an denen kein Klon mehr haengt:",
curses.A_BOLD))
for entry in stale:
zeilen.append((" %s@%s" % (entry["volid"], entry["snapshot"]),
curses.color_pair(C_MARK)))
zeilen.append(("", 0))
for text in _wrap("An diesen scheitert die Vorhaltezeit dauerhaft mit "
"\"snapshot is protected\". Der Schutz wird geloest - "
"geloescht wird dabei nichts.", 74):
zeilen.append((text, curses.color_pair(C_DIM)))
zeilen.append(("", 0))
ohne_eltern = [e for e in orphans if not e["parent"]]
if ohne_eltern:
for text in _wrap("Ausserdem liegen %d Datentraeger ohne Elternteil herum. "
"Die werden hier nicht angefasst - dafuer gibt es "
"'pvesnap-recovery cleanup --all'." % len(ohne_eltern), 74):
zeilen.append((text, curses.color_pair(C_DIM)))
taste = _pages(stdscr, zeilen, "Aufraeumen",
"j = aufraeumen | andere Taste = nichts tun")
if taste not in ("j", "J", "y", "Y"):
return
fortschritt = _Progress(stdscr, "Raeume auf")
bericht = []
if klone:
entfernt, gescheitert = remove_orphans(proxmox, klone, progress=fortschritt)
bericht.append("%d Datentraeger entfernt." % len(entfernt))
bericht += [" fehlgeschlagen: %s" % p for p in gescheitert]
if stale:
geloest, gescheitert = release_protections(proxmox, stale,
progress=fortschritt)
bericht.append("%s geloest." % _anzahl(len(geloest), "Schutzmarke",
"Schutzmarken"))
bericht += [" fehlgeschlagen: %s" % p for p in gescheitert]
_pages(stdscr, _plain(bericht), "Fertig")
def _browse_transfer(win, path, volume):
"""Den Zwei-Fenster-Commander auf ein Transfer-Laufwerk loslassen.
@@ -1119,9 +1198,23 @@ def cmd_flatten(proxmox, args):
def cmd_cleanup(proxmox, args):
"""Zwei Durchgaenge - sie finden verschiedene Dinge.
Der erste sucht Datentraeger ohne Gast. Der zweite geschuetzte Snapshots
ohne Klon: die findet der erste prinzipiell nicht, weil es dazu keinen
Datentraeger mehr gibt, nach dem er suchen koennte.
"""
schlecht = _cleanup_volumes(proxmox, args)
print()
schlecht += _cleanup_protections(proxmox, args)
return 1 if schlecht else 0
def _cleanup_volumes(proxmox, args):
"""Liegengebliebene Datentraeger. Liefert die Zahl der Fehlschlaege."""
orphans = find_orphans(proxmox)
if not orphans:
print("Nichts liegengeblieben.")
print("Keine liegengebliebenen Datentraeger.")
return 0
clones = [e for e in orphans if e["parent"]]
@@ -1158,6 +1251,39 @@ def cmd_cleanup(proxmox, args):
return 1 if failed else 0
def _anzahl(zahl, einzahl, mehrzahl):
return "%d %s" % (zahl, einzahl if zahl == 1 else mehrzahl)
def _cleanup_protections(proxmox, args):
"""Geschuetzte Snapshots ohne Klon. Liefert die Zahl der Fehlschlaege."""
print("Suche geschuetzte Snapshots ohne Klon ...")
stale = find_stale_protections(proxmox,
progress=lambda text: print(" " + text))
if not stale:
print("Keine gefunden.")
return 0
print("\nGeschuetzte Snapshots, an denen kein Klon mehr haengt:\n")
for entry in stale:
print(" %s@%s" % (entry["volid"], entry["snapshot"]))
print("\nAn diesen scheitert die Vorhaltezeit dauerhaft mit\n"
"\"snapshot is protected\". Der Schutz wird geloest - geloescht wird\n"
"dabei nichts, die Snapshots bleiben stehen.")
if not args.yes:
if _ask("\nSchutz loesen? [j/N] ") not in ("j", "y", "ja", "yes"):
print("Abgebrochen.")
return 0
released, failed = release_protections(
proxmox, stale, progress=lambda text: print(" " + text))
print("\n%s geloest." % _anzahl(len(released), "Schutzmarke", "Schutzmarken"))
for problem in failed:
print(" fehlgeschlagen: %s" % problem, file=sys.stderr)
return 1 if failed else 0
def cmd_spice(proxmox, args):
instance = _find_instance(proxmox, args.vmid)
text = spice_file(proxmox, instance)