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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6279a20d4a
commit
2b895e6f4f
+49
-4
@@ -35,6 +35,7 @@ import subprocess
|
|||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from . import transfer
|
||||||
from .proxmox import Guest, ProxmoxError
|
from .proxmox import Guest, ProxmoxError
|
||||||
|
|
||||||
log = logging.getLogger("pvesnap.recovery")
|
log = logging.getLogger("pvesnap.recovery")
|
||||||
@@ -477,6 +478,7 @@ class Spec:
|
|||||||
exchange_storage: str = ""
|
exchange_storage: str = ""
|
||||||
exchange_fs: str = DEFAULT_EXCHANGE_FS
|
exchange_fs: str = DEFAULT_EXCHANGE_FS
|
||||||
exchange_dir: str = "" # nur LXC: vorhandenes Host-Verzeichnis
|
exchange_dir: str = "" # nur LXC: vorhandenes Host-Verzeichnis
|
||||||
|
transfers: list = field(default_factory=list) # Namen vorhandener Laufwerke
|
||||||
memory: int = 0 # 0 = wie im Snapshot
|
memory: int = 0 # 0 = wie im Snapshot
|
||||||
cores: int = 0
|
cores: int = 0
|
||||||
name: str = ""
|
name: str = ""
|
||||||
@@ -504,6 +506,7 @@ class Plan:
|
|||||||
resume: bool = False
|
resume: bool = False
|
||||||
exchange_kind: str = "" # "disk" | "bind" | ""
|
exchange_kind: str = "" # "disk" | "bind" | ""
|
||||||
exchange_detail: str = ""
|
exchange_detail: str = ""
|
||||||
|
transfers: list = field(default_factory=list) # [transfer.Volume]
|
||||||
warnings: list = field(default_factory=list)
|
warnings: list = field(default_factory=list)
|
||||||
# Was nicht nur unschoen, sondern gefaehrlich ist. Hierfuer genuegt ein
|
# Was nicht nur unschoen, sondern gefaehrlich ist. Hierfuer genuegt ein
|
||||||
# beilaeufiges "ja" nicht - siehe cmd_create() und die Oberflaeche.
|
# beilaeufiges "ja" nicht - siehe cmd_create() und die Oberflaeche.
|
||||||
@@ -531,6 +534,7 @@ class Instance:
|
|||||||
volumes: list = field(default_factory=list)
|
volumes: list = field(default_factory=list)
|
||||||
protected: list = field(default_factory=list) # [[volid, snapname]]
|
protected: list = field(default_factory=list) # [[volid, snapname]]
|
||||||
exchange: dict = field(default_factory=dict)
|
exchange: dict = field(default_factory=dict)
|
||||||
|
transfers: list = field(default_factory=list)
|
||||||
resumed: bool = False
|
resumed: bool = False
|
||||||
status: str = ""
|
status: str = ""
|
||||||
|
|
||||||
@@ -555,14 +559,15 @@ class Instance:
|
|||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
return {k: getattr(self, k) for k in
|
return {k: getattr(self, k) for k in
|
||||||
("vmid", "type", "node", "name", "source", "source_node", "snapshot",
|
("vmid", "type", "node", "name", "source", "source_node", "snapshot",
|
||||||
"mode", "created", "volumes", "protected", "exchange", "resumed")}
|
"mode", "created", "volumes", "protected", "exchange", "transfers",
|
||||||
|
"resumed")}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data):
|
def from_dict(cls, data):
|
||||||
known = {k: data.get(k) for k in
|
known = {k: data.get(k) for k in
|
||||||
("vmid", "type", "node", "name", "source", "source_node", "snapshot",
|
("vmid", "type", "node", "name", "source", "source_node", "snapshot",
|
||||||
"mode", "created", "volumes", "protected", "exchange", "resumed")
|
"mode", "created", "volumes", "protected", "exchange", "transfers",
|
||||||
if data.get(k) is not None}
|
"resumed") if data.get(k) is not None}
|
||||||
known["vmid"] = int(known.get("vmid") or 0)
|
known["vmid"] = int(known.get("vmid") or 0)
|
||||||
return cls(**known)
|
return cls(**known)
|
||||||
|
|
||||||
@@ -796,6 +801,14 @@ def plan(proxmox, guest, snapname, spec):
|
|||||||
result.exchange_detail = "%s auf %s, %s, im Gast als weitere Platte" % (
|
result.exchange_detail = "%s auf %s, %s, im Gast als weitere Platte" % (
|
||||||
human_bytes(size_kb * 1024), storage, spec.exchange_fs)
|
human_bytes(size_kb * 1024), storage, spec.exchange_fs)
|
||||||
|
|
||||||
|
# -- Transfer-Laufwerke ------------------------------------------------
|
||||||
|
for name in spec.transfers:
|
||||||
|
volume = transfer.get(name) # wirft, wenn es das nicht gibt
|
||||||
|
if not volume.free:
|
||||||
|
raise RecoveryError("Transfer-Laufwerk %r ist gerade %s."
|
||||||
|
% (name, volume.state_text))
|
||||||
|
result.transfers.append(volume)
|
||||||
|
|
||||||
# -- Hinweise ----------------------------------------------------------
|
# -- Hinweise ----------------------------------------------------------
|
||||||
if result.dropped and not spec.keep_binds:
|
if result.dropped and not spec.keep_binds:
|
||||||
result.notes.append("Nicht uebernommen werden Einbindungen von Host-"
|
result.notes.append("Nicht uebernommen werden Einbindungen von Host-"
|
||||||
@@ -936,6 +949,23 @@ def create(proxmox, plan_, progress=None):
|
|||||||
exchange = {"kind": "bind", "path": host_dir, "key": slot,
|
exchange = {"kind": "bind", "path": host_dir, "key": slot,
|
||||||
"own": not spec.exchange_dir}
|
"own": not spec.exchange_dir}
|
||||||
|
|
||||||
|
# -- Transfer-Laufwerke --------------------------------------------
|
||||||
|
attached = []
|
||||||
|
for volume in plan_.transfers:
|
||||||
|
if guest.type == "lxc":
|
||||||
|
# Beim Container wird der Mountpunkt des Hosts durchgereicht.
|
||||||
|
# Ein Bind-Mount ist dasselbe Dateisystem an zwei Stellen, kein
|
||||||
|
# zweites Einhaengen - das ist unbedenklich.
|
||||||
|
step("Reiche Transfer-Laufwerk %r durch" % volume.name)
|
||||||
|
path = transfer.host_mount(volume.name)
|
||||||
|
slot = _free_mp_slot(new_config)
|
||||||
|
new_config[slot] = "%s,mp=/mnt/%s" % (path, volume.name)
|
||||||
|
else:
|
||||||
|
step("Haenge Transfer-Laufwerk %r an" % volume.name)
|
||||||
|
slot = _free_disk_slot(new_config)
|
||||||
|
new_config[slot] = transfer.drive_string(volume.name)
|
||||||
|
attached.append(volume.name)
|
||||||
|
|
||||||
# -- restliche Anpassungen -----------------------------------------
|
# -- restliche Anpassungen -----------------------------------------
|
||||||
if spec.memory:
|
if spec.memory:
|
||||||
new_config["memory"] = spec.memory
|
new_config["memory"] = spec.memory
|
||||||
@@ -957,7 +987,8 @@ def create(proxmox, plan_, progress=None):
|
|||||||
name=str(new_config.get("name") or new_config.get("hostname") or ""),
|
name=str(new_config.get("name") or new_config.get("hostname") or ""),
|
||||||
source=guest.vmid, source_node=guest.node, snapshot=plan_.snapshot,
|
source=guest.vmid, source_node=guest.node, snapshot=plan_.snapshot,
|
||||||
mode=spec.mode, created=int(time.time()), volumes=created,
|
mode=spec.mode, created=int(time.time()), volumes=created,
|
||||||
protected=protected, exchange=exchange, resumed=plan_.resume)
|
protected=protected, exchange=exchange, resumed=plan_.resume,
|
||||||
|
transfers=attached)
|
||||||
_registry_add(instance)
|
_registry_add(instance)
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
@@ -980,6 +1011,12 @@ def create(proxmox, plan_, progress=None):
|
|||||||
pass
|
pass
|
||||||
for path in made_dirs:
|
for path in made_dirs:
|
||||||
shutil.rmtree(path, ignore_errors=True)
|
shutil.rmtree(path, ignore_errors=True)
|
||||||
|
for volume in plan_.transfers:
|
||||||
|
try:
|
||||||
|
transfer.host_umount(volume.name)
|
||||||
|
transfer.release(volume.name)
|
||||||
|
except transfer.TransferError:
|
||||||
|
pass
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
@@ -1458,6 +1495,14 @@ def destroy(proxmox, instance, progress=None, keep_snapshot_protection=False):
|
|||||||
except RecoveryError as exc:
|
except RecoveryError as exc:
|
||||||
log.warning("Schutz von %s@%s blieb: %s", volid, snapname, exc)
|
log.warning("Schutz von %s@%s blieb: %s", volid, snapname, exc)
|
||||||
|
|
||||||
|
for name in instance.transfers or []:
|
||||||
|
step("Gebe Transfer-Laufwerk %r wieder frei" % name)
|
||||||
|
try:
|
||||||
|
transfer.host_umount(name)
|
||||||
|
transfer.release(name)
|
||||||
|
except transfer.TransferError as exc:
|
||||||
|
log.warning("%s: %s", name, exc)
|
||||||
|
|
||||||
exchange = instance.exchange or {}
|
exchange = instance.exchange or {}
|
||||||
if exchange.get("kind") == "bind" and exchange.get("own") and exchange.get("path"):
|
if exchange.get("kind") == "bind" and exchange.get("own") and exchange.get("path"):
|
||||||
step("Entferne Austauschverzeichnis %s" % exchange["path"])
|
step("Entferne Austauschverzeichnis %s" % exchange["path"])
|
||||||
|
|||||||
+42
-20
@@ -26,11 +26,12 @@ from .curses_util import (C_DIM, C_FOOTER, C_HEADER, C_MARK, C_OK, C_SEL, C_WARN
|
|||||||
choose, confirm, fill, init_colors, is_enter, is_escape,
|
choose, confirm, fill, init_colors, is_enter, is_escape,
|
||||||
keybar, message, prompt, put, read_key)
|
keybar, message, prompt, put, read_key)
|
||||||
from .proxmox import Proxmox, ProxmoxError, pvesh_available
|
from .proxmox import Proxmox, ProxmoxError, pvesh_available
|
||||||
from .recovery import (EXCHANGE_GUEST_PATH, MKFS, RecoveryError, Spec, create,
|
from .recovery import (EXCHANGE_GUEST_PATH, RecoveryError, Spec, create,
|
||||||
destroy, find_orphans, flatten, flatten_cost, flatten_hint,
|
destroy, find_orphans, flatten, flatten_cost, flatten_hint,
|
||||||
human_bytes,
|
human_bytes,
|
||||||
linked_volumes, list_instances, plan, pull, release,
|
linked_volumes, list_instances, plan, pull, release,
|
||||||
remove_orphans, start)
|
remove_orphans, start)
|
||||||
|
from . import transfer, transfer_ui
|
||||||
from .snapfs import list_snapshots
|
from .snapfs import list_snapshots
|
||||||
from .util import truncate
|
from .util import truncate
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ KEYS = [
|
|||||||
("h", "Herunterfahren", "Stop"),
|
("h", "Herunterfahren", "Stop"),
|
||||||
("a", "Austausch", "Austau."),
|
("a", "Austausch", "Austau."),
|
||||||
("x", "Verwerfen", "Verwerf."),
|
("x", "Verwerfen", "Verwerf."),
|
||||||
|
("v", "Laufwerke", "Laufw."),
|
||||||
("r", "Aktualisieren", "Aktual."),
|
("r", "Aktualisieren", "Aktual."),
|
||||||
("q", "Beenden", "Ende"),
|
("q", "Beenden", "Ende"),
|
||||||
]
|
]
|
||||||
@@ -163,14 +165,7 @@ def _fields(guest):
|
|||||||
]
|
]
|
||||||
if guest.type == "qemu":
|
if guest.type == "qemu":
|
||||||
rows.append(("resume", "Arbeitsspeicher", "choice", RESUME_CHOICES))
|
rows.append(("resume", "Arbeitsspeicher", "choice", RESUME_CHOICES))
|
||||||
rows.append(("exchange", "Austauschlaufwerk", "text", ()))
|
rows.append(("transfers", "Transfer-Laufwerke", "transfers", ()))
|
||||||
rows.append(("exchange_fs", " Dateisystem", "choice",
|
|
||||||
tuple((f, f) for f in sorted(MKFS))))
|
|
||||||
else:
|
|
||||||
rows.append(("exchange", "Austauschverzeichnis", "choice",
|
|
||||||
(("", "keins"), ("bind", "ja, unter %s im Gast"
|
|
||||||
% EXCHANGE_GUEST_PATH))))
|
|
||||||
rows.append(("exchange_dir", " eigenes Host-Verzeichnis", "text", ()))
|
|
||||||
rows += [
|
rows += [
|
||||||
("newid", "Neue VMID", "text", ()),
|
("newid", "Neue VMID", "text", ()),
|
||||||
("node", "Node", "text", ()),
|
("node", "Node", "text", ()),
|
||||||
@@ -188,6 +183,8 @@ def _field_text(spec, key, kind, choices):
|
|||||||
return str(value)
|
return str(value)
|
||||||
if key == "newid":
|
if key == "newid":
|
||||||
return str(value) if value else "automatisch (naechste freie)"
|
return str(value) if value else "automatisch (naechste freie)"
|
||||||
|
if key == "transfers":
|
||||||
|
return transfer_ui.summary(value)
|
||||||
if key == "exchange":
|
if key == "exchange":
|
||||||
return value or "keins"
|
return value or "keins"
|
||||||
if key == "node":
|
if key == "node":
|
||||||
@@ -220,12 +217,8 @@ def _options(win, guest, spec):
|
|||||||
"das Austauschlaufwerk da.",
|
"das Austauschlaufwerk da.",
|
||||||
"resume": "Snapshots mit Arbeitsspeicher laufen genau dort weiter, wo "
|
"resume": "Snapshots mit Arbeitsspeicher laufen genau dort weiter, wo "
|
||||||
"sie standen - ohne Crash-Recovery.",
|
"sie standen - ohne Crash-Recovery.",
|
||||||
"exchange": ("Groesse wie 20G. Leer = keins."
|
"transfers": "Vorbereitete Austauschmedien. Anlegen und befuellen "
|
||||||
if guest.type == "qemu" else
|
"in der Uebersicht mit v .",
|
||||||
"Ein Host-Verzeichnis, das im Container auftaucht."),
|
|
||||||
"exchange_fs": "Dateisystem des Austauschlaufwerks.",
|
|
||||||
"exchange_dir": "Leer = pvesnap legt selbst eines an. Hier passt auch "
|
|
||||||
"ein eingehaengtes Netzlaufwerk.",
|
|
||||||
"newid": "VMID der neuen Maschine.",
|
"newid": "VMID der neuen Maschine.",
|
||||||
"node": "Auf welchem Node sie laufen soll.",
|
"node": "Auf welchem Node sie laufen soll.",
|
||||||
"start": "Sofort hochfahren.",
|
"start": "Sofort hochfahren.",
|
||||||
@@ -252,8 +245,11 @@ def _options(win, guest, spec):
|
|||||||
elif is_escape(key) or key == "q":
|
elif is_escape(key) or key == "q":
|
||||||
return False
|
return False
|
||||||
elif is_enter(key) or key == " " or key in (curses.KEY_RIGHT, curses.KEY_LEFT):
|
elif is_enter(key) or key == " " or key in (curses.KEY_RIGHT, curses.KEY_LEFT):
|
||||||
_edit(win, spec, current_key, kind, choices,
|
if kind == "transfers":
|
||||||
back=key == curses.KEY_LEFT)
|
spec.transfers = transfer_ui.choose(win, spec.transfers)
|
||||||
|
else:
|
||||||
|
_edit(win, spec, current_key, kind, choices,
|
||||||
|
back=key == curses.KEY_LEFT)
|
||||||
|
|
||||||
|
|
||||||
def _wrap(text, width):
|
def _wrap(text, width):
|
||||||
@@ -334,6 +330,10 @@ def summary_lines(plan_):
|
|||||||
curses.color_pair(C_OK) if plan_.resume else 0))
|
curses.color_pair(C_OK) if plan_.resume else 0))
|
||||||
if plan_.exchange_kind:
|
if plan_.exchange_kind:
|
||||||
lines.append(("Austausch: %s" % plan_.exchange_detail, 0))
|
lines.append(("Austausch: %s" % plan_.exchange_detail, 0))
|
||||||
|
for volume in plan_.transfers:
|
||||||
|
lines.append(("Transfer: %-14s %8s im Gast als %s"
|
||||||
|
% (volume.name, human_bytes(volume.size), volume.label),
|
||||||
|
curses.color_pair(C_OK)))
|
||||||
|
|
||||||
if plan_.notes:
|
if plan_.notes:
|
||||||
lines.append(("", 0))
|
lines.append(("", 0))
|
||||||
@@ -649,6 +649,9 @@ def _overview(stdscr, proxmox):
|
|||||||
if key in ("n", "N"):
|
if key in ("n", "N"):
|
||||||
_new(stdscr, proxmox)
|
_new(stdscr, proxmox)
|
||||||
reload_ = True
|
reload_ = True
|
||||||
|
elif key in ("v", "V"):
|
||||||
|
transfer_ui.screen(stdscr, browse=_browse_transfer)
|
||||||
|
reload_ = True
|
||||||
elif key in ("r", "R"):
|
elif key in ("r", "R"):
|
||||||
reload_ = True
|
reload_ = True
|
||||||
elif key == curses.KEY_UP and instances:
|
elif key == curses.KEY_UP and instances:
|
||||||
@@ -675,6 +678,23 @@ def _overview(stdscr, proxmox):
|
|||||||
reload_ = True
|
reload_ = True
|
||||||
|
|
||||||
|
|
||||||
|
def _browse_transfer(win, path, volume):
|
||||||
|
"""Den Zwei-Fenster-Commander auf ein Transfer-Laufwerk loslassen.
|
||||||
|
|
||||||
|
Links das Laufwerk, rechts der lokale Rechner - also genau der Weg, um
|
||||||
|
Werkzeuge hineinzukopieren, bevor die Maschine startet, und den Dump
|
||||||
|
hinterher wieder herauszuholen.
|
||||||
|
"""
|
||||||
|
from .explorer import Explorer, Pane
|
||||||
|
links = Pane(path=path, root=path, readonly=False,
|
||||||
|
title="Transfer: %s (%s)" % (volume.name, volume.label))
|
||||||
|
rechts = Pane(path=os.getcwd(), root="/", readonly=False,
|
||||||
|
title="Lokaler Rechner")
|
||||||
|
Explorer(links, rechts,
|
||||||
|
subtitle="Transfer-Laufwerk %s - beide Seiten beschreibbar"
|
||||||
|
% volume.name).run(win)
|
||||||
|
|
||||||
|
|
||||||
def _main(stdscr, _args):
|
def _main(stdscr, _args):
|
||||||
curses.curs_set(0)
|
curses.curs_set(0)
|
||||||
if hasattr(curses, "set_escdelay"):
|
if hasattr(curses, "set_escdelay"):
|
||||||
@@ -718,7 +738,8 @@ def _spec_from_args(args, mode):
|
|||||||
spec.resume = args.resume
|
spec.resume = args.resume
|
||||||
spec.exchange = args.exchange or ""
|
spec.exchange = args.exchange or ""
|
||||||
spec.exchange_storage = args.exchange_storage or ""
|
spec.exchange_storage = args.exchange_storage or ""
|
||||||
spec.exchange_fs = args.exchange_fs
|
spec.exchange_fs = "exfat"
|
||||||
|
spec.transfers = list(args.transfer or [])
|
||||||
spec.exchange_dir = args.exchange_dir or ""
|
spec.exchange_dir = args.exchange_dir or ""
|
||||||
spec.memory = args.memory or 0
|
spec.memory = args.memory or 0
|
||||||
spec.cores = args.cores or 0
|
spec.cores = args.cores or 0
|
||||||
@@ -982,8 +1003,9 @@ def _add_create_options(parser):
|
|||||||
"(bei Containern genuegt --exchange ja)")
|
"(bei Containern genuegt --exchange ja)")
|
||||||
parser.add_argument("--exchange-storage", metavar="STORAGE",
|
parser.add_argument("--exchange-storage", metavar="STORAGE",
|
||||||
help="Storage dafuer (Vorgabe: wie die erste Platte)")
|
help="Storage dafuer (Vorgabe: wie die erste Platte)")
|
||||||
parser.add_argument("--exchange-fs", default="ext4", choices=sorted(MKFS),
|
parser.add_argument("--transfer", action="append", metavar="NAME", default=[],
|
||||||
help="Dateisystem des Austauschlaufwerks (Vorgabe: ext4)")
|
help="vorbereitetes Transfer-Laufwerk anhaengen "
|
||||||
|
"(mehrfach moeglich)")
|
||||||
parser.add_argument("--exchange-dir", metavar="PFAD",
|
parser.add_argument("--exchange-dir", metavar="PFAD",
|
||||||
help="nur Container: dieses Host-Verzeichnis durchreichen "
|
help="nur Container: dieses Host-Verzeichnis durchreichen "
|
||||||
"(auch ein eingehaengtes Netzlaufwerk)")
|
"(auch ein eingehaengtes Netzlaufwerk)")
|
||||||
|
|||||||
+58
-74
@@ -5,8 +5,8 @@ hinein- und wieder herauskommt, gibt es Transfer-Laufwerke: schlichte
|
|||||||
Abbilddateien, die auf dem Host ganz normal eingehaengt werden koennen und sich
|
Abbilddateien, die auf dem Host ganz normal eingehaengt werden koennen und sich
|
||||||
einem Gast als zusaetzliche Platte anhaengen lassen.
|
einem Gast als zusaetzliche Platte anhaengen lassen.
|
||||||
|
|
||||||
/var/lib/pvesnap/transfer/dumps.img 20G, exfat, "Datenbank-Dumps"
|
/var/lib/pvesnap/transfer/dumps.img 20G "Datenbank-Dumps"
|
||||||
/var/lib/pvesnap/transfer/werkzeuge.img 2G, ext4, "Skripte und Tools"
|
/var/lib/pvesnap/transfer/werkzeuge.img 2G "Skripte und Tools"
|
||||||
|
|
||||||
Sie gehoeren zu keinem Gast und ueberleben jede Wiederherstellung. Man legt sie
|
Sie gehoeren zu keinem Gast und ueberleben jede Wiederherstellung. Man legt sie
|
||||||
einmal an, befuellt sie in Ruhe, und waehlt sie beim naechsten Notfall nur noch
|
einmal an, befuellt sie in Ruhe, und waehlt sie beim naechsten Notfall nur noch
|
||||||
@@ -49,48 +49,29 @@ CONF_ROOT = "/etc/pve/nodes"
|
|||||||
|
|
||||||
NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,30}$")
|
NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,30}$")
|
||||||
|
|
||||||
# Was sich womit anlegen laesst. Die Partitionstabelle macht in jedem Fall
|
# Ein einziges Dateisystem, mit Absicht. exFAT koennen Windows und Linux beide
|
||||||
# parted, das Dateisystem der jeweilige Formatierer.
|
# von Haus aus, es kennt keine Besitzrechte auf dem Datentraeger (damit kann
|
||||||
FILESYSTEMS = {
|
# das Rechteproblem gar nicht erst auftreten, das ext4 hier hatte) und anders
|
||||||
"ext4": {
|
# als FAT32 gibt es keine 4-GB-Grenze je Datei. Eine Auswahl waere eine
|
||||||
"mkfs": ["mkfs.ext4", "-q", "-m", "0", "-L"],
|
# Entscheidung mehr im Notfall - und genau die soll wegfallen.
|
||||||
"fuer": "nur Linux, behaelt Rechte und Besitzer",
|
FILESYSTEM = "exfat"
|
||||||
"paket": "e2fsprogs",
|
MKFS = ["mkfs.exfat", "-L"]
|
||||||
},
|
PACKAGE = "exfatprogs"
|
||||||
"xfs": {
|
LABEL_MAX = 11 # mehr laesst exFAT nicht zu
|
||||||
"mkfs": ["mkfs.xfs", "-q", "-L"],
|
|
||||||
"fuer": "nur Linux",
|
|
||||||
"paket": "xfsprogs",
|
|
||||||
},
|
|
||||||
"exfat": {
|
|
||||||
"mkfs": ["mkfs.exfat", "-L"],
|
|
||||||
"fuer": "Windows und Linux, keine Groessengrenze je Datei",
|
|
||||||
"paket": "exfatprogs",
|
|
||||||
"hinweis": "apt install exfatprogs (281 KB, entfernt nichts)",
|
|
||||||
},
|
|
||||||
"vfat": {
|
|
||||||
"mkfs": ["mkfs.vfat", "-n"],
|
|
||||||
"fuer": "ueberall lesbar, aber nur Dateien bis 4 GB",
|
|
||||||
"paket": "dosfstools",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
# Wie lang die Datentraegerbezeichnung sein darf. exFAT und FAT lassen nur
|
|
||||||
# 11 Zeichen zu - laenger, und mkfs bricht ab.
|
|
||||||
LABEL_MAX = {"vfat": 11, "exfat": 11, "xfs": 12, "ext4": 16}
|
|
||||||
|
|
||||||
# exFAT als Vorgabe: Windows und Linux koennen beide damit umgehen, und anders
|
|
||||||
# als FAT32 gibt es keine 4-GB-Grenze je Datei. Fehlt der Formatierer, faellt
|
|
||||||
# die Wahl auf ext4 zurueck.
|
|
||||||
DEFAULT_SIZE = "20G"
|
DEFAULT_SIZE = "20G"
|
||||||
|
|
||||||
|
|
||||||
def default_filesystem():
|
def formatter_missing():
|
||||||
return "exfat" if have("mkfs.exfat") else "ext4"
|
"""Meldung, falls sich hier nichts anlegen laesst - sonst ''."""
|
||||||
|
if have(MKFS[0]):
|
||||||
|
return ""
|
||||||
|
return ("%s fehlt - ohne das lassen sich keine Transfer-Laufwerke anlegen.\n"
|
||||||
|
" apt install %s (klein, entfernt nichts)\n"
|
||||||
|
"Oder ./install.sh erneut aufrufen, das erledigt es mit."
|
||||||
|
% (MKFS[0], PACKAGE))
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_FS = "exfat"
|
|
||||||
|
|
||||||
README = """Transfer-Laufwerk von pvesnap
|
README = """Transfer-Laufwerk von pvesnap
|
||||||
|
|
||||||
Dieses Laufwerk gehoert zu keiner Maschine. Es dient nur dazu, Dateien
|
Dieses Laufwerk gehoert zu keiner Maschine. Es dient nur dazu, Dateien
|
||||||
@@ -153,15 +134,6 @@ def have(command):
|
|||||||
return which(command) is not None
|
return which(command) is not None
|
||||||
|
|
||||||
|
|
||||||
def available_filesystems():
|
|
||||||
"""[(name, verfuegbar, beschreibung)] - was sich hier anlegen laesst."""
|
|
||||||
result = []
|
|
||||||
for name in ("exfat", "ext4", "xfs", "vfat"):
|
|
||||||
spec = FILESYSTEMS[name]
|
|
||||||
result.append((name, have(spec["mkfs"][0]), spec))
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def human_bytes(count):
|
def human_bytes(count):
|
||||||
value = float(count or 0)
|
value = float(count or 0)
|
||||||
for unit in ("B", "K", "M", "G", "T"):
|
for unit in ("B", "K", "M", "G", "T"):
|
||||||
@@ -193,7 +165,7 @@ class Volume:
|
|||||||
name: str
|
name: str
|
||||||
image: str = ""
|
image: str = ""
|
||||||
size: int = 0
|
size: int = 0
|
||||||
fs: str = "ext4"
|
fs: str = FILESYSTEM
|
||||||
label: str = ""
|
label: str = ""
|
||||||
note: str = ""
|
note: str = ""
|
||||||
created: int = 0
|
created: int = 0
|
||||||
@@ -206,16 +178,20 @@ class Volume:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def state(self):
|
def state(self):
|
||||||
if self.mountpoint:
|
# Beim Container haelt der Host den Mountpunkt und reicht ihn per
|
||||||
return "host"
|
# Bind-Mount durch - das ist dasselbe Dateisystem an zwei Stellen, kein
|
||||||
|
# zweites Einhaengen. Der Gast hat trotzdem Vorrang in der Anzeige,
|
||||||
|
# sonst haengt jemand aus, was gerade benutzt wird.
|
||||||
if self.attached_to:
|
if self.attached_to:
|
||||||
return "guest"
|
return "guest"
|
||||||
|
if self.mountpoint:
|
||||||
|
return "host"
|
||||||
return "frei"
|
return "frei"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def state_text(self):
|
def state_text(self):
|
||||||
return {"host": "am Host eingehaengt",
|
return {"host": "am Host eingehaengt",
|
||||||
"guest": "an VM %d angehaengt" % self.attached_to,
|
"guest": "in Benutzung von %d" % self.attached_to,
|
||||||
"frei": "frei"}[self.state]
|
"frei": "frei"}[self.state]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -317,7 +293,11 @@ def _guest_usage():
|
|||||||
steht der Eintrag auch dann, wenn die Maschine gerade nicht laeuft.
|
steht der Eintrag auch dann, wenn die Maschine gerade nicht laeuft.
|
||||||
"""
|
"""
|
||||||
usage = {}
|
usage = {}
|
||||||
device = re.compile(r"(/dev/loop\d+)")
|
# Bei virtuellen Maschinen steht das Loop-Geraet in der Konfiguration, bei
|
||||||
|
# Containern der durchgereichte Mountpunkt. Beides muss erkannt werden -
|
||||||
|
# sonst gilt ein Laufwerk als frei, obwohl ein Container darauf arbeitet.
|
||||||
|
device = re.compile(r"(/dev/loop\d+|%s/[a-z0-9_-]+)"
|
||||||
|
% re.escape(MOUNT_ROOT))
|
||||||
try:
|
try:
|
||||||
nodes = os.listdir(CONF_ROOT)
|
nodes = os.listdir(CONF_ROOT)
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -355,6 +335,7 @@ def load():
|
|||||||
volume.note = (volume.note + " [Abbilddatei fehlt]").strip()
|
volume.note = (volume.note + " [Abbilddatei fehlt]").strip()
|
||||||
volumes.append(volume)
|
volumes.append(volume)
|
||||||
continue
|
continue
|
||||||
|
volume.attached_to = usage.get(os.path.join(MOUNT_ROOT, volume.name), 0)
|
||||||
volume.loop = loops.get(os.path.realpath(volume.image), "")
|
volume.loop = loops.get(os.path.realpath(volume.image), "")
|
||||||
if volume.loop:
|
if volume.loop:
|
||||||
# Bei partitionierten Abbildern haengt das Dateisystem auf ...p1
|
# Bei partitionierten Abbildern haengt das Dateisystem auf ...p1
|
||||||
@@ -362,7 +343,7 @@ def load():
|
|||||||
if candidate in mounts:
|
if candidate in mounts:
|
||||||
volume.mountpoint = mounts[candidate]
|
volume.mountpoint = mounts[candidate]
|
||||||
break
|
break
|
||||||
volume.attached_to = usage.get(volume.loop, 0)
|
volume.attached_to = volume.attached_to or usage.get(volume.loop, 0)
|
||||||
volumes.append(volume)
|
volumes.append(volume)
|
||||||
return volumes
|
return volumes
|
||||||
|
|
||||||
@@ -379,8 +360,7 @@ def get(name):
|
|||||||
# Anlegen
|
# Anlegen
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def create(name, size=DEFAULT_SIZE, fs=None, note="", partitioned=True,
|
def create(name, size=DEFAULT_SIZE, note="", partitioned=True, progress=None):
|
||||||
progress=None):
|
|
||||||
"""Ein neues Transfer-Laufwerk anlegen und formatieren."""
|
"""Ein neues Transfer-Laufwerk anlegen und formatieren."""
|
||||||
def step(text):
|
def step(text):
|
||||||
log.info("%s", text)
|
log.info("%s", text)
|
||||||
@@ -394,22 +374,16 @@ def create(name, size=DEFAULT_SIZE, fs=None, note="", partitioned=True,
|
|||||||
if any(v.name == name for v in _index_load()):
|
if any(v.name == name for v in _index_load()):
|
||||||
raise TransferError("Ein Transfer-Laufwerk namens %r gibt es schon." % name)
|
raise TransferError("Ein Transfer-Laufwerk namens %r gibt es schon." % name)
|
||||||
|
|
||||||
fs = fs or default_filesystem()
|
fehlt = formatter_missing()
|
||||||
if fs not in FILESYSTEMS:
|
if fehlt:
|
||||||
raise TransferError("Unbekanntes Dateisystem %r (%s)"
|
raise TransferError(fehlt)
|
||||||
% (fs, ", ".join(sorted(FILESYSTEMS))))
|
|
||||||
spec = FILESYSTEMS[fs]
|
|
||||||
if not have(spec["mkfs"][0]):
|
|
||||||
raise TransferError("%s fehlt - %s kann hier nicht angelegt werden.\n%s"
|
|
||||||
% (spec["mkfs"][0], fs,
|
|
||||||
spec.get("hinweis") or "apt install %s" % spec["paket"]))
|
|
||||||
if partitioned and not have("parted"):
|
if partitioned and not have("parted"):
|
||||||
raise TransferError("'parted' fehlt (apt install parted) - ohne "
|
raise TransferError("'parted' fehlt (apt install parted) - ohne "
|
||||||
"Partitionstabelle zeigt Windows den Datentraeger nur "
|
"Partitionstabelle zeigt Windows den Datentraeger nur "
|
||||||
"als 'nicht initialisiert'.")
|
"als 'nicht initialisiert'.")
|
||||||
|
|
||||||
size_bytes = parse_size(size)
|
size_bytes = parse_size(size)
|
||||||
label = _label_for(name, fs)
|
label = _label_for(name)
|
||||||
image = os.path.join(TRANSFER_DIR, "%s.img" % name)
|
image = os.path.join(TRANSFER_DIR, "%s.img" % name)
|
||||||
|
|
||||||
os.makedirs(TRANSFER_DIR, exist_ok=True)
|
os.makedirs(TRANSFER_DIR, exist_ok=True)
|
||||||
@@ -420,7 +394,8 @@ def create(name, size=DEFAULT_SIZE, fs=None, note="", partitioned=True,
|
|||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
raise TransferError("%s nicht anlegbar: %s" % (image, exc))
|
raise TransferError("%s nicht anlegbar: %s" % (image, exc))
|
||||||
|
|
||||||
volume = Volume(name=name, image=image, size=size_bytes, fs=fs, label=label,
|
volume = Volume(name=name, image=image, size=size_bytes, fs=FILESYSTEM,
|
||||||
|
label=label,
|
||||||
note=note, created=int(time.time()), partitioned=partitioned)
|
note=note, created=int(time.time()), partitioned=partitioned)
|
||||||
try:
|
try:
|
||||||
if partitioned:
|
if partitioned:
|
||||||
@@ -433,9 +408,9 @@ def create(name, size=DEFAULT_SIZE, fs=None, note="", partitioned=True,
|
|||||||
"mkpart", "primary", "1MiB", "100%",
|
"mkpart", "primary", "1MiB", "100%",
|
||||||
"set", "1", "msftdata", "on"])
|
"set", "1", "msftdata", "on"])
|
||||||
with _loop(image, partitioned) as device:
|
with _loop(image, partitioned) as device:
|
||||||
step("Formatieren mit %s, Bezeichnung %s" % (fs, label))
|
step("Formatieren mit %s, Bezeichnung %s" % (FILESYSTEM, label))
|
||||||
run(spec["mkfs"] + [label, device], timeout=1800)
|
run(MKFS + [label, device], timeout=1800)
|
||||||
_place_readme(device, fs, label)
|
_place_readme(device, label)
|
||||||
except Exception:
|
except Exception:
|
||||||
try:
|
try:
|
||||||
os.unlink(image)
|
os.unlink(image)
|
||||||
@@ -495,21 +470,21 @@ def partition_of(device):
|
|||||||
return device
|
return device
|
||||||
|
|
||||||
|
|
||||||
def _label_for(name, fs):
|
def _label_for(name):
|
||||||
"""Datentraegerbezeichnung - so, dass man sie im Gast wiederfindet.
|
"""Datentraegerbezeichnung - so, dass man sie im Gast wiederfindet.
|
||||||
|
|
||||||
Kurz gehalten, weil exFAT und FAT nur 11 Zeichen zulassen. Im Gast reicht
|
Kurz gehalten, weil exFAT und FAT nur 11 Zeichen zulassen. Im Gast reicht
|
||||||
damit: mount /dev/disk/by-label/<NAME> /mnt
|
damit: mount /dev/disk/by-label/<NAME> /mnt
|
||||||
"""
|
"""
|
||||||
clean = re.sub(r"[^A-Z0-9_-]", "", name.upper())
|
clean = re.sub(r"[^A-Z0-9_-]", "", name.upper())
|
||||||
return clean[:LABEL_MAX.get(fs, 11)] or "PVESNAP"
|
return clean[:LABEL_MAX] or "PVESNAP"
|
||||||
|
|
||||||
|
|
||||||
def _place_readme(device, fs, label):
|
def _place_readme(device, label):
|
||||||
mountpoint = os.path.join(MOUNT_ROOT, ".neu-%d" % os.getpid())
|
mountpoint = os.path.join(MOUNT_ROOT, ".neu-%d" % os.getpid())
|
||||||
try:
|
try:
|
||||||
os.makedirs(mountpoint, exist_ok=True)
|
os.makedirs(mountpoint, exist_ok=True)
|
||||||
run(["mount", device, mountpoint], timeout=120)
|
run(["mount", "-o", "umask=0000", device, mountpoint], timeout=120)
|
||||||
except (TransferError, OSError) as exc:
|
except (TransferError, OSError) as exc:
|
||||||
log.debug("Anleitung nicht ablegbar: %s", exc)
|
log.debug("Anleitung nicht ablegbar: %s", exc)
|
||||||
return
|
return
|
||||||
@@ -558,7 +533,12 @@ def host_mount(volume):
|
|||||||
mountpoint = os.path.join(MOUNT_ROOT, volume.name)
|
mountpoint = os.path.join(MOUNT_ROOT, volume.name)
|
||||||
os.makedirs(mountpoint, exist_ok=True)
|
os.makedirs(mountpoint, exist_ok=True)
|
||||||
try:
|
try:
|
||||||
run(["mount", target, mountpoint], timeout=120)
|
# umask=0000: exFAT kennt keine Besitzrechte auf dem Datentraeger, die
|
||||||
|
# entstehen erst beim Einhaengen. Mit der Vorgabe gehoert alles root -
|
||||||
|
# und ein unprivilegierter Container, dessen root auf dem Host die uid
|
||||||
|
# 100000 hat, darf dann nichts hineinschreiben. Fuer ein Austauschmedium
|
||||||
|
# ist das keine sinnvolle Grenze.
|
||||||
|
run(["mount", "-o", "umask=0000", target, mountpoint], timeout=120)
|
||||||
except TransferError:
|
except TransferError:
|
||||||
detach_loop(device)
|
detach_loop(device)
|
||||||
raise
|
raise
|
||||||
@@ -569,6 +549,10 @@ def host_mount(volume):
|
|||||||
def host_umount(volume):
|
def host_umount(volume):
|
||||||
"""Wieder aushaengen und das Loop-Geraet freigeben."""
|
"""Wieder aushaengen und das Loop-Geraet freigeben."""
|
||||||
volume = get(volume.name if isinstance(volume, Volume) else volume)
|
volume = get(volume.name if isinstance(volume, Volume) else volume)
|
||||||
|
if volume.attached_to:
|
||||||
|
raise TransferError(
|
||||||
|
"%r wird gerade von %d benutzt - aushaengen wuerde dem laufenden "
|
||||||
|
"Gast den Boden wegziehen." % (volume.name, volume.attached_to))
|
||||||
if volume.mountpoint:
|
if volume.mountpoint:
|
||||||
run(["umount", volume.mountpoint], check=False, timeout=120)
|
run(["umount", volume.mountpoint], check=False, timeout=120)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
"""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
|
||||||
Reference in New Issue
Block a user