Files
duffyduckandClaude Opus 5 d9d9345d03 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>
2026-08-11 09:39:25 +02:00

233 lines
8.0 KiB
Python
Executable File

#!/usr/bin/env python3
"""Startet eine Oberflaeche von pvesnap gegen die Demo-Kulisse.
Aufruf: run.py <szene> [argumente]
Der eigentliche Trick steckt in _patch(): alle Pfade, die sonst auf einen
echten Proxmox-Host zeigen, werden auf demo/ umgebogen. Der Programmcode
selbst wird nicht angefasst - die Bildschirmfotos zeigen also wirklich das,
was auf dem Host auch zu sehen ist.
"""
from __future__ import annotations
import curses
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
DEMO = os.path.join(HERE, "demo")
sys.path.insert(0, os.environ.get("PVESNAP_SRC", ""))
# Wo die Kulisse haengt, wenn stage.sh sie eingehaengt hat. Ohne Namensraum
# (beim schnellen Nachsehen) wird auf die Verzeichnisse selbst zurueckgefallen.
SNAP_MNT = "/run/pvesnap/mnt/101-auto-taeglich-20260809-023000"
TRANSFER_MNT = "/run/pvesnap/transfer/dumps"
LOKAL = "/root"
def _oder(pfad, ersatz):
return pfad if os.path.isdir(pfad) and os.listdir(pfad) else ersatz
def _patch():
from pvesnap import preflight, recovery, transfer, tui
recovery.STATE_DIR = os.path.join(DEMO, "lib")
recovery.REGISTRY = os.path.join(DEMO, "lib", "recovery.json")
recovery.CONF_ROOT = os.path.join(DEMO, "pve", "nodes")
transfer.TRANSFER_DIR = os.path.join(DEMO, "lib", "transfer")
transfer.INDEX = os.path.join(DEMO, "lib", "transfer", "index.json")
transfer.MOUNT_ROOT = "/run/pvesnap/transfer"
transfer.CONF_ROOT = recovery.CONF_ROOT
# Loop-Geraete und Einhaengepunkte gibt es hier nicht - also erzaehlen wir
# sie. "dumps" haengt in der Maschine, "austausch" gerade am Host.
abbild = lambda name: os.path.join(transfer.TRANSFER_DIR, name + ".img")
transfer._loop_devices = lambda: {abbild("dumps"): "/dev/loop3",
abbild("austausch"): "/dev/loop5"}
transfer._mounts = lambda: {"/dev/loop5p1": os.path.join(transfer.MOUNT_ROOT,
"austausch")}
with open(os.path.join(DEMO, "pve", ".members"), encoding="utf-8") as handle:
mitglieder = json.load(handle)
recovery._ADDRESSES.update({n: e["ip"] for n, e
in mitglieder["nodelist"].items()})
preflight.check_environment = lambda require_pve=True: []
tui.service_state = lambda: (True, "active")
tui.service_text = lambda: "Dienst: laeuft"
class Kulissensession:
"""Genau so viel Sitzung, wie der Explorer fuer die Laufwerkswahl braucht.
Eine echte Session wuerde einen Snapshot einbinden - dafuer braucht es
Ceph. Der Explorer fasst sie nur an zwei Stellen an: um die Dateisysteme
aufzuzaehlen und um zu wissen, welches Fenster der Snapshot ist.
"""
mounts = {"root": 1, "boot": 1}
@staticmethod
def mount_entries():
return [(SNAP_MNT, "/ (ext4, 400G) - wahrscheinlich das System"),
(SNAP_MNT + "-boot", "/boot (ext4, 1G)")]
def _explorer_panes(links_pfad, links_titel, untertitel, links_schreibbar=False,
session=None):
from pvesnap.explorer import Explorer, Pane
links = Pane(path=links_pfad, root=links_pfad,
readonly=not links_schreibbar, title=links_titel)
rechts = Pane(path=_oder(LOKAL, os.path.join(DEMO, "lokal")), root="/",
readonly=False, title="Lokaler Rechner")
return Explorer(links, rechts, session=session, subtitle=untertitel)
# ---------------------------------------------------------------------------
# Szenen
# ---------------------------------------------------------------------------
def szene_config(stdscr):
from pvesnap.tui import Editor
pfad = "/etc/pvesnap.conf"
editor = Editor(pfad if os.path.exists(pfad)
else os.path.join(DEMO, "pvesnap.conf"))
editor.load()
editor.run(stdscr)
def szene_recovery(stdscr):
from pvesnap.proxmox import Proxmox
from pvesnap.recovery_ui import _overview
_overview(stdscr, Proxmox())
def szene_transfer(stdscr):
from pvesnap import transfer_ui
from pvesnap.recovery_ui import _browse_transfer
transfer_ui.screen(stdscr, browse=_browse_transfer)
def szene_explorer(stdscr):
_explorer_panes(_oder(SNAP_MNT, os.path.join(DEMO, "snapshot")),
"VM 101 (db01) @ auto-taeglich-20260809-023000",
"VM 101 (db01) - Snapshot auto-taeglich-20260809-023000",
session=Kulissensession()).run(stdscr)
def szene_explorer_transfer(stdscr):
_explorer_panes(_oder(TRANSFER_MNT, os.path.join(DEMO, "transfer-dumps")),
"Transfer: dumps (DUMPS)",
"Transfer-Laufwerk dumps - beide Seiten beschreibbar",
links_schreibbar=True).run(stdscr)
def szene_gastauswahl(stdscr):
from pvesnap.explorer import _pick_guest
from pvesnap.proxmox import Proxmox
_pick_guest(stdscr, Proxmox().inventory())
def szene_snapshotauswahl(stdscr):
from pvesnap.explorer import _pick_snapshot
from pvesnap.proxmox import Proxmox
from pvesnap.snapfs import list_snapshots
proxmox = Proxmox()
gast = next(g for g in proxmox.inventory() if g.vmid == 101)
_pick_snapshot(stdscr, [s for s in list_snapshots(proxmox, gast) if s.complete])
def szene_neu(stdscr):
"""Die Optionsmaske des Assistenten - ohne etwas anzulegen."""
from pvesnap.proxmox import Proxmox
from pvesnap.recovery_ui import _options
from pvesnap.recovery import Spec
proxmox = Proxmox()
gast = next(g for g in proxmox.inventory() if g.vmid == 101)
spec = Spec(mode="live")
spec.snapshot = "auto-stuendlich-20260809-100000"
spec.newid = 9103
spec.node = "pve1"
spec.transfers = ["werkzeuge"]
_options(stdscr, gast, spec)
def _vorhaben(stdscr, vmid, snapshot, **werte):
from pvesnap.curses_util import init_colors
from pvesnap.proxmox import Proxmox
from pvesnap.recovery import Spec, plan
from pvesnap.recovery_ui import _pages, summary_lines
init_colors()
proxmox = Proxmox()
gast = next(g for g in proxmox.inventory() if g.vmid == vmid)
spec = Spec(mode=werte.pop("mode", "live"))
for schluessel, wert in werte.items():
setattr(spec, schluessel, wert)
_pages(stdscr, summary_lines(plan(proxmox, gast, snapshot, spec)),
"Vorhaben pruefen", "j = anlegen | n = abbrechen")
def szene_vorhaben(stdscr):
"""Der Normalfall: abgeschottet hineinschauen, mit Arbeitsspeicher."""
_vorhaben(stdscr, 101, "auto-stuendlich-20260809-100000",
newid=9103, transfers=["werkzeuge"])
def szene_vorhaben_dongle(stdscr):
"""Die Windows-Maschine mit SPICE und USB - fuer das Schutzmodul."""
_vorhaben(stdscr, 105, "auto-taeglich-20260809-023000",
mode="live", newid=9104, transfers=["werkzeuge"], spice=True, usb=2)
def szene_laufwerkswahl(stdscr):
from pvesnap import transfer_ui
transfer_ui.choose(stdscr, preselected=["werkzeuge"])
SZENEN = {
"config": szene_config,
"recovery": szene_recovery,
"transfer": szene_transfer,
"explorer": szene_explorer,
"explorer-transfer": szene_explorer_transfer,
"gastauswahl": szene_gastauswahl,
"snapshotauswahl": szene_snapshotauswahl,
"neu": szene_neu,
"vorhaben": szene_vorhaben,
"vorhaben-dongle": szene_vorhaben_dongle,
"laufwerkswahl": szene_laufwerkswahl,
}
def main():
if len(sys.argv) < 2 or sys.argv[1] not in SZENEN:
print("Szenen: %s" % ", ".join(sorted(SZENEN)), file=sys.stderr)
return 1
_patch()
from pvesnap.curses_util import init_colors
szene = SZENEN[sys.argv[1]]
def start(stdscr):
curses.curs_set(0)
if hasattr(curses, "set_escdelay"):
curses.set_escdelay(25)
init_colors()
stdscr.keypad(True)
return szene(stdscr)
try:
curses.wrapper(start)
except Exception: # beim Bauen will man den Grund sehen
import traceback
traceback.print_exc()
return 2
return 0
if __name__ == "__main__":
sys.exit(main())