Files
proxmox-snapshot-service/pvesnap/transfer_ui.py
T
duffyduckandClaude Opus 5 2b895e6f4f Transfer-Laufwerke: nur noch exFAT, Oberflaeche und Anbindung
Ein einziges Dateisystem, mit Absicht: exFAT koennen Windows und Linux beide
von Haus aus, es kennt keine Besitzrechte auf dem Datentraeger und hat anders
als FAT32 keine 4-GB-Grenze je Datei. Die Auswahl faellt damit weg - eine
Entscheidung weniger, wenn es schnell gehen muss.

Neu in der Oberflaeche:

  Taste v          Uebersicht der Laufwerke mit Zustand
  Enter darauf     haengt am Host ein und startet den Commander - links das
                   Laufwerk, rechts der eigene Rechner
  n / u / l        anlegen, aushaengen, loeschen
  Optionsmaske     Mehrfachauswahl statt Groessenangabe
  --transfer NAME  dasselbe auf der Kommandozeile, mehrfach moeglich

Am laufenden System durchgespielt: Laufwerk angelegt, vom Host ein Skript
hineingelegt, Wiederherstellung damit gestartet, im Gast gelesen, ausgefuehrt
und geschrieben, Wiederherstellung verworfen - das Laufwerk lebte samt
Ergebnis weiter.

Zwei Rechtefallen dabei gefunden und geschlossen:

  Der Gast konnte lesen, aber nicht schreiben. exFAT hat keine Besitzrechte
  auf der Platte, die entstehen beim Einhaengen - mit der Vorgabe gehoert
  alles root, und der root eines unprivilegierten Containers ist auf dem Host
  uid 100000. Wird jetzt mit umask=0000 eingehaengt.

  Ein durchgereichtes Laufwerk galt als "am Host eingehaengt", weil beim
  Container genau das der Fall ist. Wer das sah, haette es aushaengen und dem
  laufenden Gast den Boden wegziehen koennen. Die Belegung wird jetzt auch aus
  Container-Konfigurationen gelesen, der Gast hat Vorrang in der Anzeige, und
  Aushaengen wird abgelehnt, solange jemand darauf arbeitet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:12:18 +02:00

280 lines
10 KiB
Python

"""Bildschirm fuer die Transfer-Laufwerke.
Bewusst knapp gehalten: im Notfall soll niemand Parameter im Kopf haben.
Anlegen, oeffnen, aushaengen, loeschen - vier Tasten.
Wird aus pvesnap-recovery (Taste v) und aus pvesnap-explorer (F2) heraus
benutzt, damit man von beiden Seiten an dieselben Laufwerke kommt.
"""
from __future__ import annotations
import curses
import os
from datetime import datetime
from .curses_util import (C_DIM, C_FOOTER, C_HEADER, C_MARK, C_OK, C_SEL, C_WARN,
confirm, fill, is_enter, is_escape, keybar, message,
prompt, put, read_key)
from .transfer import (DEFAULT_SIZE, TransferError, create, formatter_missing,
host_mount, host_umount, human_bytes, load, remove)
from .util import truncate
KEYS = [
("Enter", "Oeffnen", "Oeffn."),
("n", "Neu", "Neu"),
("u", "Aushaengen", "Aush."),
("l", "Loeschen", "Losch."),
("r", "Aktualisieren", "Aktual."),
("q", "Zurueck", "Zurueck"),
]
def _wrap(text, width):
words, lines, current = str(text).split(), [], ""
for word in words:
if len(current) + len(word) + 1 > width:
lines.append(current)
current = word
else:
current = (current + " " + word).strip()
if current:
lines.append(current)
return lines
def _pages(win, lines, title, footer="beliebige Taste = weiter"):
height, width = win.getmaxyx()
win.erase()
fill(win, 0, " " + title, curses.color_pair(C_HEADER) | curses.A_BOLD)
for row, (text, attr) in enumerate(lines[: height - 4]):
put(win, row + 2, 2, truncate(text, width - 4), attr)
fill(win, height - 1, " " + footer, curses.color_pair(C_FOOTER))
win.refresh()
return read_key(win)
def _new(win):
"""Ein Laufwerk anlegen. Nur zwei Fragen - mehr braucht es nicht."""
fehlt = formatter_missing()
if fehlt:
_pages(win, [(line, curses.color_pair(C_WARN)) for line in fehlt.split("\n")],
"Geht hier nicht")
return
name = prompt(win, "Name (kurz, klein, z.B. dumps)")
if not name:
return
size = prompt(win, "Groesse", DEFAULT_SIZE)
if size is None:
return
note = prompt(win, "Wofuer ist das? (optional)", "")
if note is None:
note = ""
height, _ = win.getmaxyx()
lines = []
def progress(text):
lines.append(text)
fill(win, height - 2, " " + text, curses.color_pair(C_SEL))
win.refresh()
try:
volume = create(name, size, note=note, progress=progress)
except TransferError as exc:
_pages(win, [(line, curses.color_pair(C_WARN))
for line in _wrap(str(exc), 74)], "Fehlgeschlagen")
return
message(win, "%r angelegt - im Gast als Datentraeger %s"
% (volume.name, volume.label))
def _open(win, volume, browse):
"""Am Host einhaengen und den Commander darauf loslassen."""
try:
path = host_mount(volume.name)
except TransferError as exc:
_pages(win, [(line, curses.color_pair(C_WARN))
for line in _wrap(str(exc), 74)], "Nicht moeglich")
return
if browse is None:
_pages(win, [("Eingehaengt unter:", 0), ("", 0),
(" " + path, curses.color_pair(C_MARK))],
"Transfer-Laufwerk %s" % volume.name)
return
try:
browse(win, path, volume)
finally:
host_umount(volume.name)
def screen(win, browse=None):
"""Die Uebersicht. `browse(win, pfad, laufwerk)` oeffnet den Commander."""
index = 0
volumes = []
reload_ = True
while True:
if reload_:
volumes = load()
reload_ = False
index = max(0, min(index, len(volumes) - 1))
height, width = win.getmaxyx()
win.erase()
fill(win, 0, " Transfer-Laufwerke - Austausch mit der Wiederherstellung",
curses.color_pair(C_HEADER) | curses.A_BOLD)
if not volumes:
put(win, 3, 3, "Noch kein Transfer-Laufwerk angelegt.", curses.A_BOLD)
for row, text in enumerate([
"",
"Ein Transfer-Laufwerk ist eine Abbilddatei, die du hier in Ruhe",
"befuellst und im Notfall nur noch auswaehlst. Sie gehoert zu",
"keiner Maschine und ueberlebt jede Wiederherstellung.",
"",
"Mit n eines anlegen.",
]):
put(win, 4 + row, 3, text, curses.color_pair(C_DIM))
fehlt = formatter_missing()
if fehlt:
for row, line in enumerate(fehlt.split("\n")):
put(win, 11 + row, 3, line, curses.color_pair(C_WARN))
else:
put(win, 2, 2, "%-14s %8s %8s %-24s %s"
% ("Name", "Groesse", "belegt", "Zustand", "Wofuer"),
curses.A_BOLD | curses.A_UNDERLINE)
rows = max(1, height - 6)
top = max(0, min(index - rows + 1, len(volumes) - rows))
for row in range(min(rows, len(volumes) - top)):
volume = volumes[top + row]
attr = curses.color_pair(C_SEL) if top + row == index else 0
if not attr and volume.state == "guest":
attr = curses.color_pair(C_MARK)
elif not attr and volume.state == "host":
attr = curses.color_pair(C_OK)
fill(win, row + 3, " %-14s %8s %8s %-24s %s"
% (truncate(volume.name, 14), human_bytes(volume.size),
human_bytes(volume.used_bytes),
volume.state_text, truncate(volume.note, 28)), attr)
for offset, line in enumerate(keybar(width, KEYS)):
fill(win, height - 2 + offset, " " + line, curses.color_pair(C_FOOTER))
win.refresh()
key = read_key(win)
current = volumes[index] if volumes else None
if key in ("q", "Q") or is_escape(key):
return
if key in ("n", "N"):
_new(win)
reload_ = True
elif key in ("r", "R"):
reload_ = True
elif key == curses.KEY_UP and volumes:
index = (index - 1) % len(volumes)
elif key == curses.KEY_DOWN and volumes:
index = (index + 1) % len(volumes)
elif current is None:
continue
elif is_enter(key):
_open(win, current, browse)
reload_ = True
elif key in ("u", "U"):
try:
host_umount(current.name)
except TransferError as exc:
message(win, str(exc), error=True)
reload_ = True
elif key in ("l", "L"):
if confirm(win, "%r endgueltig loeschen? Der Inhalt ist dann weg."
% current.name):
try:
remove(current.name)
except TransferError as exc:
message(win, str(exc), error=True)
reload_ = True
def choose(win, preselected=()):
"""Mehrfachauswahl fuer die Optionsmaske. Liefert eine Liste von Namen."""
volumes = [v for v in load()]
if not volumes:
message(win, "Es gibt noch keine Transfer-Laufwerke. In der Uebersicht "
"mit v anlegen.", error=True)
return list(preselected)
chosen = set(preselected)
index = 0
while True:
height, width = win.getmaxyx()
win.erase()
fill(win, 0, " Welche Transfer-Laufwerke anhaengen?",
curses.color_pair(C_HEADER) | curses.A_BOLD)
put(win, 2, 2, "%-3s %-14s %8s %-24s %s"
% ("", "Name", "Groesse", "Zustand", "Wofuer"),
curses.A_BOLD | curses.A_UNDERLINE)
for row, volume in enumerate(volumes[: height - 7]):
marker = "[x]" if volume.name in chosen else "[ ]"
attr = curses.color_pair(C_SEL) if row == index else 0
if not volume.free and not attr:
attr = curses.color_pair(C_WARN)
fill(win, row + 3, " %-3s %-14s %8s %-24s %s"
% (marker, truncate(volume.name, 14), human_bytes(volume.size),
volume.state_text, truncate(volume.note, 26)), attr)
put(win, height - 4, 3,
"Belegte Laufwerke lassen sich nicht anhaengen - erst dort aushaengen.",
curses.color_pair(C_DIM))
for offset, line in enumerate(keybar(width, [
("Leertaste", "Aus/Abwaehlen", "Waehlen"),
("Enter", "Uebernehmen", "OK"),
("q", "Abbrechen", "Abbr.")])):
fill(win, height - 2 + offset, " " + line, curses.color_pair(C_FOOTER))
win.refresh()
key = read_key(win)
if key == curses.KEY_UP:
index = (index - 1) % len(volumes)
elif key == curses.KEY_DOWN:
index = (index + 1) % len(volumes)
elif key == " ":
volume = volumes[index]
if volume.name in chosen:
chosen.discard(volume.name)
elif not volume.free:
message(win, "%r ist gerade %s." % (volume.name, volume.state_text),
error=True)
else:
chosen.add(volume.name)
elif is_enter(key):
return [v.name for v in volumes if v.name in chosen]
elif is_escape(key) or key in ("q", "Q"):
return list(preselected)
def summary(names):
"""Kurzfassung fuer die Optionsmaske."""
if not names:
return "keine"
return ", ".join(names)
def describe(volume):
"""Mehrzeilige Beschreibung fuer Detailansichten."""
lines = ["Transfer-Laufwerk %s" % volume.name,
" Abbild : %s" % volume.image,
" Groesse : %s (belegt: %s)" % (human_bytes(volume.size),
human_bytes(volume.used_bytes)),
" Im Gast : Datentraeger %s" % volume.label,
" Zustand : %s" % volume.state_text]
if volume.note:
lines.append(" Wofuer : %s" % volume.note)
if volume.created:
lines.append(" Angelegt : %s"
% datetime.fromtimestamp(volume.created).strftime("%d.%m.%Y %H:%M"))
return lines