Snapshot-Explorer: Dateien zurueckholen, im Terminal und im Browser
Snapshots anzulegen half bisher nur halb - an die Daten darin kam man nur
ueber einen Rollback. Dafuer jetzt zwei Werkzeuge auf gemeinsamer Basis.
snapfs.py bindet einen Snapshot schreibgeschuetzt ein und liefert einen
gewoehnlichen Pfad; die Oberflaechen wissen dadurch nichts ueber Storages:
rbd (Ceph) rbd map pool/image@snap, bei nicht unterstuetzten
Image-Features faellt es auf rbd-nbd zurueck
zfspool Container ueber .zfs/snapshot, VMs ueber einen Klon
lvmthin/lvm die Snapshot-LV snap_<volume>_<snapname> aktivieren
dir/nfs/cifs qemu-nbd --load-snapshot (nur qcow2)
Gemountet wird mit ro,noload bzw. ro,norecovery,nouuid - Snapshots
laufender Gaeste haben fast immer ein unsauberes Journal. Alles Angelegte
steht in /run/pvesnap/explorer.json und laesst sich nach einem Absturz mit
"--cleanup" wieder abraeumen.
pvesnap-explorer: zwei Fenster wie im Midnight Commander, links der
Snapshot, rechts der lokale Rechner. Markieren, F5, Fortschrittsbalken,
ESC bricht ab, vorhandene Dateien werden abgefragt. In den Snapshot hinein
kann nicht kopiert werden. Geraetedateien und Sockets werden
uebersprungen, symbolische Verweise bleiben Verweise.
pvesnap-web: derselbe Inhalt im Browser, auch vom anderen Rechner.
Einzelne Dateien direkt, Verzeichnisse und Mehrfachauswahl als ZIP, das im
Strom erzeugt wird - ohne Zwischendatei auf der Platte. Zugang nur mit dem
beim Start ausgegebenen Schluessel; Pfade ausserhalb des Snapshots werden
abgewiesen; es wird ausschliesslich gelesen.
Nebenbei: die curses-Bausteine sind aus tui.py nach curses_util.py
gewandert, damit Editor und Explorer sie teilen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1be13ad294
commit
f37a942fea
@@ -0,0 +1,930 @@
|
||||
"""pvesnap-explorer - Dateien aus einem Snapshot holen.
|
||||
|
||||
Zwei Fenster wie im Midnight Commander: links der geoeffnete Snapshot
|
||||
(schreibgeschuetzt), rechts der lokale Rechner. Markieren, F5 - fertig.
|
||||
|
||||
pvesnap-explorer Gast und Snapshot auswaehlen
|
||||
pvesnap-explorer 802 Snapshot von VM 802 auswaehlen
|
||||
pvesnap-explorer 802 <snapname> direkt oeffnen
|
||||
pvesnap-explorer --path /mnt beliebiges Verzeichnis (ohne Proxmox)
|
||||
pvesnap-explorer --cleanup Reste eines Absturzes aufraeumen
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import curses
|
||||
import errno
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
from . import __version__
|
||||
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)
|
||||
from .proxmox import Proxmox, ProxmoxError, pvesh_available
|
||||
from .snapfs import (Session, SnapfsError, cleanup_leftovers, human_bytes,
|
||||
list_snapshots)
|
||||
from .util import truncate
|
||||
|
||||
log = logging.getLogger("pvesnap.explorer")
|
||||
|
||||
KEYS = [
|
||||
("Tab", "Fenster", "Fenst."),
|
||||
("Enter", "Oeffnen", "Oeffn."),
|
||||
("Leer", "Markieren", "Mark."),
|
||||
("F5", "Kopieren", "Kopie"),
|
||||
("F3", "Ansehen", "Ansehen"),
|
||||
("F7", "Neuer Ordner", "Ordner"),
|
||||
("F2", "Laufwerk", "Laufw."),
|
||||
("F10", "Ende", "Ende"),
|
||||
]
|
||||
|
||||
COPY_BUFFER = 1024 * 1024
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Eintraege und Fenster
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class Entry:
|
||||
name: str
|
||||
path: str
|
||||
is_dir: bool = False
|
||||
size: int = 0
|
||||
mtime: float = 0.0
|
||||
is_link: bool = False
|
||||
is_special: bool = False
|
||||
is_up: bool = False
|
||||
|
||||
@property
|
||||
def sort_key(self):
|
||||
return (0 if self.is_up else (1 if self.is_dir else 2), self.name.lower())
|
||||
|
||||
|
||||
@dataclass
|
||||
class Pane:
|
||||
path: str
|
||||
root: str = "/"
|
||||
readonly: bool = False
|
||||
title: str = ""
|
||||
entries: list = field(default_factory=list)
|
||||
index: int = 0
|
||||
top: int = 0
|
||||
marks: set = field(default_factory=set)
|
||||
error: str = ""
|
||||
|
||||
def reload(self):
|
||||
previous = self.current.name if self.entries and 0 <= self.index < len(self.entries) else ""
|
||||
self.entries = []
|
||||
self.error = ""
|
||||
|
||||
real = os.path.realpath(self.path)
|
||||
if real != os.path.realpath(self.root):
|
||||
parent = os.path.dirname(real) or "/"
|
||||
self.entries.append(Entry(name="..", path=parent, is_dir=True, is_up=True))
|
||||
|
||||
try:
|
||||
with os.scandir(self.path) as scan:
|
||||
for item in scan:
|
||||
self.entries.append(self._entry(item))
|
||||
except OSError as exc:
|
||||
self.error = "%s: %s" % (self.path, exc.strerror or exc)
|
||||
|
||||
self.entries.sort(key=lambda e: e.sort_key)
|
||||
self.marks = {name for name in self.marks
|
||||
if any(e.name == name for e in self.entries)}
|
||||
self.index = 0
|
||||
for position, entry in enumerate(self.entries):
|
||||
if entry.name == previous:
|
||||
self.index = position
|
||||
break
|
||||
self._clamp()
|
||||
|
||||
@staticmethod
|
||||
def _entry(item):
|
||||
try:
|
||||
info = item.stat(follow_symlinks=False)
|
||||
mode = info.st_mode
|
||||
is_link = stat.S_ISLNK(mode)
|
||||
if is_link:
|
||||
try:
|
||||
target = item.stat(follow_symlinks=True)
|
||||
is_dir = stat.S_ISDIR(target.st_mode)
|
||||
except OSError:
|
||||
is_dir = False
|
||||
special = False
|
||||
else:
|
||||
is_dir = stat.S_ISDIR(mode)
|
||||
special = not (is_dir or stat.S_ISREG(mode))
|
||||
return Entry(name=item.name, path=item.path, is_dir=is_dir,
|
||||
size=0 if is_dir else info.st_size, mtime=info.st_mtime,
|
||||
is_link=is_link, is_special=special)
|
||||
except OSError:
|
||||
return Entry(name=item.name, path=item.path)
|
||||
|
||||
# -- Navigation -------------------------------------------------------
|
||||
|
||||
@property
|
||||
def current(self):
|
||||
if 0 <= self.index < len(self.entries):
|
||||
return self.entries[self.index]
|
||||
return Entry(name="", path=self.path)
|
||||
|
||||
def _clamp(self):
|
||||
self.index = max(0, min(self.index, max(0, len(self.entries) - 1)))
|
||||
|
||||
def move(self, delta):
|
||||
self.index += delta
|
||||
self._clamp()
|
||||
|
||||
def open_current(self):
|
||||
entry = self.current
|
||||
if not entry.is_dir:
|
||||
return False
|
||||
target = os.path.realpath(entry.path)
|
||||
root = os.path.realpath(self.root)
|
||||
if not (target == root or target.startswith(root + os.sep)):
|
||||
return False # nicht aus dem Snapshot herauslaufen
|
||||
self.path = target
|
||||
self.marks.clear()
|
||||
self.reload()
|
||||
return True
|
||||
|
||||
def go(self, path):
|
||||
if os.path.isdir(path):
|
||||
self.path = os.path.realpath(path)
|
||||
self.marks.clear()
|
||||
self.reload()
|
||||
return True
|
||||
return False
|
||||
|
||||
def toggle_mark(self):
|
||||
entry = self.current
|
||||
if entry.is_up or not entry.name:
|
||||
return
|
||||
if entry.name in self.marks:
|
||||
self.marks.discard(entry.name)
|
||||
else:
|
||||
self.marks.add(entry.name)
|
||||
|
||||
def selection(self):
|
||||
"""Markierte Eintraege, sonst der Eintrag unter dem Balken."""
|
||||
if self.marks:
|
||||
return [e for e in self.entries if e.name in self.marks]
|
||||
entry = self.current
|
||||
return [] if (entry.is_up or not entry.name) else [entry]
|
||||
|
||||
def free_space(self):
|
||||
try:
|
||||
stats = os.statvfs(self.path)
|
||||
return stats.f_bavail * stats.f_frsize
|
||||
except OSError:
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Kopieren
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class Aborted(Exception):
|
||||
"""Der Benutzer hat abgebrochen."""
|
||||
|
||||
|
||||
class Copier:
|
||||
"""Kopiert Dateien und Verzeichnisse mit Fortschritt und Abbruch."""
|
||||
|
||||
def __init__(self, win, progress):
|
||||
self.win = win
|
||||
self.progress = progress # Rueckruf(text, done, total)
|
||||
self.total = 0
|
||||
self.done = 0
|
||||
self.overwrite = None # None | "alle" | "keine"
|
||||
self.copied = 0
|
||||
self.skipped = []
|
||||
self.failed = []
|
||||
|
||||
# -- Groesse vorab ----------------------------------------------------
|
||||
|
||||
def measure(self, entries):
|
||||
total = 0
|
||||
for entry in entries:
|
||||
if entry.is_dir and not entry.is_link:
|
||||
for base, _dirs, files in os.walk(entry.path, onerror=None):
|
||||
for name in files:
|
||||
try:
|
||||
info = os.lstat(os.path.join(base, name))
|
||||
if stat.S_ISREG(info.st_mode):
|
||||
total += info.st_size
|
||||
except OSError:
|
||||
pass
|
||||
elif not entry.is_special:
|
||||
total += entry.size
|
||||
self.total = total
|
||||
return total
|
||||
|
||||
# -- Ausfuehren -------------------------------------------------------
|
||||
|
||||
def copy(self, entries, target):
|
||||
for entry in entries:
|
||||
self._copy_any(entry.path, os.path.join(target, entry.name))
|
||||
|
||||
def _copy_any(self, source, destination):
|
||||
self._check_abort()
|
||||
try:
|
||||
info = os.lstat(source)
|
||||
except OSError as exc:
|
||||
self.failed.append("%s: %s" % (source, exc.strerror or exc))
|
||||
return
|
||||
|
||||
if stat.S_ISLNK(info.st_mode):
|
||||
self._copy_symlink(source, destination)
|
||||
elif stat.S_ISDIR(info.st_mode):
|
||||
self._copy_dir(source, destination)
|
||||
elif stat.S_ISREG(info.st_mode):
|
||||
self._copy_file(source, destination, info)
|
||||
else:
|
||||
# Geraetedateien, Sockets, FIFOs - im Gast-Dateisystem haeufig,
|
||||
# ausserhalb aber sinnlos.
|
||||
self.skipped.append("%s (Spezialdatei)" % source)
|
||||
|
||||
def _copy_symlink(self, source, destination):
|
||||
try:
|
||||
target = os.readlink(source)
|
||||
if os.path.lexists(destination):
|
||||
if not self._may_overwrite(destination):
|
||||
return
|
||||
os.remove(destination)
|
||||
os.symlink(target, destination)
|
||||
self.copied += 1
|
||||
except OSError as exc:
|
||||
self.failed.append("%s: %s" % (source, exc.strerror or exc))
|
||||
|
||||
def _copy_dir(self, source, destination):
|
||||
try:
|
||||
os.makedirs(destination, exist_ok=True)
|
||||
except OSError as exc:
|
||||
self.failed.append("%s: %s" % (destination, exc.strerror or exc))
|
||||
return
|
||||
try:
|
||||
names = sorted(os.listdir(source))
|
||||
except OSError as exc:
|
||||
self.failed.append("%s: %s" % (source, exc.strerror or exc))
|
||||
return
|
||||
for name in names:
|
||||
self._copy_any(os.path.join(source, name), os.path.join(destination, name))
|
||||
try:
|
||||
shutil.copystat(source, destination)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _copy_file(self, source, destination, info):
|
||||
if os.path.lexists(destination) and not self._may_overwrite(destination):
|
||||
self.done += info.st_size
|
||||
return
|
||||
self.progress(os.path.basename(source), self.done, self.total)
|
||||
try:
|
||||
with open(source, "rb") as src, open(destination, "wb") as dst:
|
||||
while True:
|
||||
self._check_abort()
|
||||
chunk = src.read(COPY_BUFFER)
|
||||
if not chunk:
|
||||
break
|
||||
dst.write(chunk)
|
||||
self.done += len(chunk)
|
||||
self.progress(os.path.basename(source), self.done, self.total)
|
||||
shutil.copystat(source, destination)
|
||||
self.copied += 1
|
||||
except Aborted:
|
||||
self._remove_partial(destination)
|
||||
raise
|
||||
except OSError as exc:
|
||||
self._remove_partial(destination)
|
||||
if exc.errno == errno.ENOSPC:
|
||||
raise SnapfsError("Kein Platz mehr auf dem Ziel-Dateisystem.")
|
||||
self.failed.append("%s: %s" % (source, exc.strerror or exc))
|
||||
|
||||
@staticmethod
|
||||
def _remove_partial(path):
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# -- Rueckfragen ------------------------------------------------------
|
||||
|
||||
def _may_overwrite(self, destination):
|
||||
if self.overwrite == "alle":
|
||||
return True
|
||||
if self.overwrite == "keine":
|
||||
self.skipped.append("%s (vorhanden)" % destination)
|
||||
return False
|
||||
answer = ask(self.win,
|
||||
"%s gibt es schon: [j]a ueberschreiben, [n]ein, "
|
||||
"[a]lle, [k]eine" % truncate(os.path.basename(destination), 40),
|
||||
"jnak", default="n")
|
||||
if answer == "a":
|
||||
self.overwrite = "alle"
|
||||
return True
|
||||
if answer == "k":
|
||||
self.overwrite = "keine"
|
||||
if answer == "j":
|
||||
return True
|
||||
self.skipped.append("%s (vorhanden)" % destination)
|
||||
return False
|
||||
|
||||
def _check_abort(self):
|
||||
self.win.nodelay(True)
|
||||
try:
|
||||
key = self.win.getch()
|
||||
except curses.error:
|
||||
key = -1
|
||||
finally:
|
||||
self.win.nodelay(False)
|
||||
if key == 27: # ESC
|
||||
raise Aborted()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Oberflaeche
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class Explorer:
|
||||
def __init__(self, left, right, session=None, subtitle=""):
|
||||
self.panes = [left, right]
|
||||
self.active = 0
|
||||
self.session = session
|
||||
self.subtitle = subtitle
|
||||
self.status = ""
|
||||
|
||||
@property
|
||||
def source(self):
|
||||
return self.panes[self.active]
|
||||
|
||||
@property
|
||||
def target(self):
|
||||
return self.panes[1 - self.active]
|
||||
|
||||
# -- Zeichnen ---------------------------------------------------------
|
||||
|
||||
def draw(self, win):
|
||||
win.erase()
|
||||
height, width = win.getmaxyx()
|
||||
keys = keybar(width, KEYS)
|
||||
list_top = 3
|
||||
list_bottom = height - 3 - len(keys)
|
||||
rows = max(1, list_bottom - list_top)
|
||||
|
||||
fill(win, 0, " pvesnap-explorer - %s" % self.subtitle,
|
||||
curses.color_pair(C_HEADER) | curses.A_BOLD)
|
||||
|
||||
middle = width // 2
|
||||
for row in range(1, list_bottom + 1):
|
||||
put(win, row, middle, "│", curses.color_pair(C_DIM))
|
||||
|
||||
self._draw_pane(win, self.panes[0], 0, middle, list_top, rows, self.active == 0)
|
||||
self._draw_pane(win, self.panes[1], middle + 1, width - middle - 2,
|
||||
list_top, rows, self.active == 1)
|
||||
|
||||
entry = self.source.current
|
||||
info = self.status or (entry.path if entry.name else self.source.path)
|
||||
fill(win, list_bottom + 1, " " + truncate(info, width - 2),
|
||||
curses.color_pair(C_DIM))
|
||||
|
||||
for offset, line in enumerate(reversed(keys)):
|
||||
fill(win, height - 1 - offset, " " + line, curses.color_pair(C_FOOTER))
|
||||
|
||||
def _draw_pane(self, win, pane, x, width, top, rows, active):
|
||||
if width < 8:
|
||||
return
|
||||
header = pane.title or pane.path
|
||||
attr = curses.color_pair(C_SEL) if active else curses.color_pair(C_DIM)
|
||||
put(win, 1, x, (" " + truncate(header, width - 2)).ljust(width), attr)
|
||||
put(win, 2, x, truncate(pane.path, width - 1).ljust(width),
|
||||
curses.A_BOLD if active else 0)
|
||||
|
||||
if pane.error:
|
||||
put(win, top, x + 1, truncate(pane.error, width - 2),
|
||||
curses.color_pair(C_WARN))
|
||||
return
|
||||
|
||||
if pane.index < pane.top:
|
||||
pane.top = pane.index
|
||||
if pane.index >= pane.top + rows:
|
||||
pane.top = pane.index - rows + 1
|
||||
pane.top = max(0, min(pane.top, max(0, len(pane.entries) - rows)))
|
||||
|
||||
for row in range(rows):
|
||||
position = pane.top + row
|
||||
if position >= len(pane.entries):
|
||||
break
|
||||
entry = pane.entries[position]
|
||||
marked = entry.name in pane.marks
|
||||
|
||||
if active and position == pane.index:
|
||||
attribute = curses.color_pair(C_SEL)
|
||||
elif marked:
|
||||
attribute = curses.color_pair(C_MARK) | curses.A_BOLD
|
||||
elif entry.is_dir:
|
||||
attribute = curses.color_pair(C_DIR)
|
||||
else:
|
||||
attribute = 0
|
||||
|
||||
put(win, top + row, x, self._format(entry, width, marked), attribute)
|
||||
|
||||
total = len(pane.entries)
|
||||
put(win, top + rows, x, " %d Eintraege%s"
|
||||
% (total, " %d markiert" % len(pane.marks) if pane.marks else ""),
|
||||
curses.color_pair(C_DIM))
|
||||
|
||||
@staticmethod
|
||||
def _format(entry, width, marked):
|
||||
"""Eine Zeile mit genau `width` Zeichen - laenger wuerde ins andere
|
||||
Fenster hineinschreiben."""
|
||||
mark = "*" if marked else " "
|
||||
suffix = "@" if entry.is_link else ""
|
||||
|
||||
def sized(text):
|
||||
return text.ljust(width)[:width]
|
||||
|
||||
if entry.is_up:
|
||||
return sized("%s.. <hoch>" % mark)
|
||||
|
||||
if entry.is_dir:
|
||||
size = "<DIR>"
|
||||
elif entry.is_special:
|
||||
size = "<SPEZ>"
|
||||
else:
|
||||
size = human_bytes(entry.size)
|
||||
|
||||
when = (datetime.fromtimestamp(entry.mtime).strftime("%d.%m.%y %H:%M")
|
||||
if entry.mtime else "")
|
||||
|
||||
# 1 Markierung + Name + 1 + 8 Groesse + 1 + 14 Datum
|
||||
room = width - 25
|
||||
if room < 8:
|
||||
return sized("%s%s" % (mark, truncate(entry.name + suffix, max(1, width - 2))))
|
||||
name = truncate(entry.name + suffix, room).ljust(room)
|
||||
return sized("%s%s %s %s" % (mark, name, size.rjust(8), when.rjust(14)))
|
||||
|
||||
# -- Hauptschleife ----------------------------------------------------
|
||||
|
||||
def run(self, win):
|
||||
for pane in self.panes:
|
||||
pane.reload()
|
||||
|
||||
while True:
|
||||
self.draw(win)
|
||||
win.refresh()
|
||||
key = read_key(win)
|
||||
rows = max(1, win.getmaxyx()[0] - 7)
|
||||
self.status = ""
|
||||
pane = self.source
|
||||
|
||||
if key == "\t" or key == curses.KEY_BTAB:
|
||||
self.active = 1 - self.active
|
||||
elif key in (curses.KEY_UP, "k"):
|
||||
pane.move(-1)
|
||||
elif key in (curses.KEY_DOWN, "j"):
|
||||
pane.move(1)
|
||||
elif key == curses.KEY_NPAGE:
|
||||
pane.move(rows)
|
||||
elif key == curses.KEY_PPAGE:
|
||||
pane.move(-rows)
|
||||
elif key == curses.KEY_HOME:
|
||||
pane.index = 0
|
||||
elif key == curses.KEY_END:
|
||||
pane.index = len(pane.entries) - 1
|
||||
elif is_enter(key):
|
||||
if not pane.open_current():
|
||||
self._view(win, pane.current)
|
||||
elif key in (curses.KEY_BACKSPACE, "\x7f", curses.KEY_LEFT):
|
||||
if pane.entries and pane.entries[0].is_up:
|
||||
pane.index = 0
|
||||
pane.open_current()
|
||||
elif key == " ":
|
||||
pane.toggle_mark()
|
||||
pane.move(1)
|
||||
elif key == "*":
|
||||
for entry in pane.entries:
|
||||
if entry.is_up:
|
||||
continue
|
||||
if entry.name in pane.marks:
|
||||
pane.marks.discard(entry.name)
|
||||
else:
|
||||
pane.marks.add(entry.name)
|
||||
elif key in ("a", "A"):
|
||||
pane.marks = {e.name for e in pane.entries if not e.is_up}
|
||||
elif key in ("u", "U"):
|
||||
pane.marks.clear()
|
||||
elif key in (curses.KEY_F5, "c", "C"):
|
||||
self._copy(win)
|
||||
elif key in (curses.KEY_F3, "v", "V"):
|
||||
self._view(win, pane.current)
|
||||
elif key in (curses.KEY_F7, "n", "N"):
|
||||
self._mkdir(win)
|
||||
elif key in (curses.KEY_F2, "m", "M"):
|
||||
self._switch_mount(win)
|
||||
elif key in (curses.KEY_F6, "g", "G"):
|
||||
self._goto(win)
|
||||
elif key in ("r", "R", curses.KEY_F9):
|
||||
for item in self.panes:
|
||||
item.reload()
|
||||
elif key in ("h", "?", curses.KEY_F1):
|
||||
self._help(win)
|
||||
elif key in (curses.KEY_F10, "q", "Q"):
|
||||
return
|
||||
elif is_escape(key):
|
||||
if confirm(win, "Explorer beenden?"):
|
||||
return
|
||||
|
||||
# -- Aktionen ---------------------------------------------------------
|
||||
|
||||
def _copy(self, win):
|
||||
source, target = self.source, self.target
|
||||
if target.readonly:
|
||||
message(win, "Das Ziel ist schreibgeschuetzt - ein Snapshot laesst sich "
|
||||
"nicht beschreiben.", error=True)
|
||||
return
|
||||
|
||||
entries = source.selection()
|
||||
if not entries:
|
||||
message(win, "Nichts ausgewaehlt.", error=True)
|
||||
return
|
||||
|
||||
def progress(name, done, total):
|
||||
height, width = win.getmaxyx()
|
||||
share = (done / total * 100) if total else 100
|
||||
bar_width = max(10, width - 46)
|
||||
filled = int(bar_width * share / 100)
|
||||
fill(win, height - 2,
|
||||
" %s [%s%s] %3d%% %s / %s (ESC bricht ab)"
|
||||
% (truncate(name, 24).ljust(24), "#" * filled,
|
||||
"." * (bar_width - filled), share,
|
||||
human_bytes(done), human_bytes(total)),
|
||||
curses.color_pair(C_SEL))
|
||||
win.refresh()
|
||||
|
||||
copier = Copier(win, progress)
|
||||
progress("Groesse ermitteln ...", 0, 0)
|
||||
total = copier.measure(entries)
|
||||
|
||||
free = target.free_space()
|
||||
if free and total > free:
|
||||
if not confirm(win, "Es werden %s benoetigt, frei sind nur %s - trotzdem?"
|
||||
% (human_bytes(total), human_bytes(free))):
|
||||
return
|
||||
|
||||
what = ("%d Eintraege" % len(entries)) if len(entries) > 1 else entries[0].name
|
||||
if not confirm(win, "%s (%s) nach %s kopieren?"
|
||||
% (truncate(what, 30), human_bytes(total), target.path)):
|
||||
return
|
||||
|
||||
started = time.time()
|
||||
try:
|
||||
copier.copy(entries, target.path)
|
||||
summary = ("%d Objekt(e) kopiert, %s in %.1fs"
|
||||
% (copier.copied, human_bytes(copier.done), time.time() - started))
|
||||
except Aborted:
|
||||
summary = "Abgebrochen - %d Objekt(e) waren fertig" % copier.copied
|
||||
except SnapfsError as exc:
|
||||
summary = str(exc)
|
||||
|
||||
source.marks.clear()
|
||||
target.reload()
|
||||
|
||||
if copier.failed or copier.skipped:
|
||||
self._report(win, summary, copier)
|
||||
else:
|
||||
message(win, summary)
|
||||
|
||||
def _report(self, win, summary, copier):
|
||||
height, width = win.getmaxyx()
|
||||
lines = [summary, ""]
|
||||
if copier.failed:
|
||||
lines.append("Fehlgeschlagen (%d):" % len(copier.failed))
|
||||
lines += [" " + item for item in copier.failed[:10]]
|
||||
if copier.skipped:
|
||||
lines.append("Uebersprungen (%d):" % len(copier.skipped))
|
||||
lines += [" " + item for item in copier.skipped[:10]]
|
||||
|
||||
win.erase()
|
||||
fill(win, 0, " Ergebnis", curses.color_pair(C_HEADER) | curses.A_BOLD)
|
||||
for row, line in enumerate(lines[: height - 3]):
|
||||
put(win, row + 2, 2, truncate(line, width - 4))
|
||||
fill(win, height - 1, " beliebige Taste = weiter", curses.color_pair(C_FOOTER))
|
||||
win.refresh()
|
||||
read_key(win)
|
||||
|
||||
def _mkdir(self, win):
|
||||
pane = self.source
|
||||
if pane.readonly:
|
||||
message(win, "Im Snapshot laesst sich nichts anlegen.", error=True)
|
||||
return
|
||||
name = prompt(win, "Neues Verzeichnis", "")
|
||||
if not name:
|
||||
return
|
||||
try:
|
||||
os.makedirs(os.path.join(pane.path, name), exist_ok=False)
|
||||
pane.reload()
|
||||
except OSError as exc:
|
||||
message(win, "Nicht angelegt: %s" % (exc.strerror or exc), error=True)
|
||||
|
||||
def _goto(self, win):
|
||||
pane = self.source
|
||||
path = prompt(win, "Verzeichnis wechseln nach", pane.path)
|
||||
if not path:
|
||||
return
|
||||
if not pane.go(os.path.expanduser(path)):
|
||||
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.")
|
||||
return
|
||||
entries = []
|
||||
for device, mountpoint in sorted(self.session.mounts.items()):
|
||||
entries.append((mountpoint, "%s -> %s" % (device, mountpoint)))
|
||||
chosen = choose(win, "Welches Dateisystem?", entries)
|
||||
if chosen:
|
||||
self.panes[0].root = chosen
|
||||
self.panes[0].go(chosen)
|
||||
|
||||
def _view(self, win, entry):
|
||||
if not entry.name or entry.is_dir or entry.is_special:
|
||||
return
|
||||
try:
|
||||
with open(entry.path, "rb") as handle:
|
||||
raw = handle.read(1024 * 1024)
|
||||
except OSError as exc:
|
||||
message(win, "Nicht lesbar: %s" % (exc.strerror or exc), error=True)
|
||||
return
|
||||
|
||||
binary = b"\0" in raw[:8192]
|
||||
if binary:
|
||||
lines = _hexdump(raw[: 64 * 1024])
|
||||
else:
|
||||
lines = raw.decode("utf-8", "replace").splitlines() or ["(leer)"]
|
||||
|
||||
top = 0
|
||||
while True:
|
||||
height, width = win.getmaxyx()
|
||||
rows = height - 3
|
||||
win.erase()
|
||||
fill(win, 0, " %s %s%s" % (truncate(entry.path, width - 30),
|
||||
human_bytes(entry.size),
|
||||
" [binaer, Hex-Ansicht]" if binary else ""),
|
||||
curses.color_pair(C_HEADER) | curses.A_BOLD)
|
||||
for row in range(rows):
|
||||
position = top + row
|
||||
if position >= len(lines):
|
||||
break
|
||||
put(win, row + 1, 0, truncate(lines[position].rstrip("\n"), width - 1))
|
||||
fill(win, height - 1,
|
||||
" Pfeiltasten/Bild blaettern | Pos1/Ende | q zurueck (Zeile %d von %d)"
|
||||
% (min(top + 1, len(lines)), len(lines)), curses.color_pair(C_FOOTER))
|
||||
win.refresh()
|
||||
|
||||
key = read_key(win)
|
||||
if key in (curses.KEY_DOWN, "j"):
|
||||
top += 1
|
||||
elif key in (curses.KEY_UP, "k"):
|
||||
top -= 1
|
||||
elif key == curses.KEY_NPAGE or key == " ":
|
||||
top += rows
|
||||
elif key == curses.KEY_PPAGE:
|
||||
top -= rows
|
||||
elif key == curses.KEY_HOME:
|
||||
top = 0
|
||||
elif key == curses.KEY_END:
|
||||
top = len(lines) - rows
|
||||
elif key in ("q", "Q", curses.KEY_F3, curses.KEY_F10) or is_escape(key):
|
||||
return
|
||||
top = max(0, min(top, max(0, len(lines) - 1)))
|
||||
|
||||
def _help(self, win):
|
||||
lines = [
|
||||
"Tab zwischen den Fenstern wechseln",
|
||||
"Pfeiltasten bewegen, Enter oeffnet Verzeichnis oder zeigt Datei",
|
||||
"Backspace ein Verzeichnis hoch",
|
||||
"Leertaste markieren, * umkehren, a alle, u keine",
|
||||
"F5 / c markiertes ins andere Fenster kopieren",
|
||||
"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",
|
||||
"r neu einlesen",
|
||||
"F10 / q beenden",
|
||||
"",
|
||||
"Der Snapshot ist schreibgeschuetzt eingebunden - es kann nichts",
|
||||
"kaputtgehen. Kopiert wird immer vom aktiven ins andere Fenster.",
|
||||
"Geraetedateien und Sockets werden uebersprungen, symbolische",
|
||||
"Verweise bleiben Verweise.",
|
||||
]
|
||||
height, width = win.getmaxyx()
|
||||
win.erase()
|
||||
fill(win, 0, " Tastenbelegung", curses.color_pair(C_HEADER) | curses.A_BOLD)
|
||||
for row, line in enumerate(lines[: height - 3]):
|
||||
put(win, row + 2, 3, truncate(line, width - 5))
|
||||
fill(win, height - 1, " beliebige Taste = zurueck", curses.color_pair(C_FOOTER))
|
||||
win.refresh()
|
||||
read_key(win)
|
||||
|
||||
|
||||
def _hexdump(raw, width=16):
|
||||
lines = []
|
||||
for offset in range(0, len(raw), width):
|
||||
chunk = raw[offset:offset + width]
|
||||
hexpart = " ".join("%02x" % byte for byte in chunk).ljust(width * 3)
|
||||
text = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk)
|
||||
lines.append("%08x %s |%s|" % (offset, hexpart, text))
|
||||
return lines
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auswahl von Gast und Snapshot
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _pick_guest(win, guests):
|
||||
entries = [(g, "%s %-6d %-24s %-8s %s"
|
||||
% ("LXC" if g.type == "lxc" else "VM ", g.vmid,
|
||||
truncate(g.name or "-", 24), g.status, g.node))
|
||||
for g in guests]
|
||||
return choose(win, "Welcher Gast?", entries)
|
||||
|
||||
|
||||
def _pick_snapshot(win, snapshots):
|
||||
entries = []
|
||||
for snap in snapshots:
|
||||
when = (datetime.fromtimestamp(snap.snaptime).strftime("%d.%m.%Y %H:%M")
|
||||
if snap.snaptime else "?")
|
||||
entries.append((snap.name, "%-34s %s %s"
|
||||
% (truncate(snap.name, 34), when,
|
||||
truncate(snap.description.replace("\n", " "), 40))))
|
||||
return choose(win, "Welcher Snapshot?", entries)
|
||||
|
||||
|
||||
def _wait_screen(win, text):
|
||||
height, width = win.getmaxyx()
|
||||
win.erase()
|
||||
fill(win, 0, " pvesnap-explorer", curses.color_pair(C_HEADER) | curses.A_BOLD)
|
||||
put(win, height // 2, max(2, (width - len(text)) // 2), text, curses.A_BOLD)
|
||||
win.refresh()
|
||||
|
||||
|
||||
def _show_notes(win, notes, title="Hinweise"):
|
||||
if not notes:
|
||||
return
|
||||
height, width = win.getmaxyx()
|
||||
win.erase()
|
||||
fill(win, 0, " %s" % title, curses.color_pair(C_HEADER) | curses.A_BOLD)
|
||||
for row, note in enumerate(notes[: height - 3]):
|
||||
put(win, row + 2, 2, truncate("- " + note, width - 4), curses.color_pair(C_WARN))
|
||||
fill(win, height - 1, " beliebige Taste = weiter", curses.color_pair(C_FOOTER))
|
||||
win.refresh()
|
||||
read_key(win)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ablauf
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _main(stdscr, args):
|
||||
curses.curs_set(0)
|
||||
if hasattr(curses, "set_escdelay"):
|
||||
curses.set_escdelay(25)
|
||||
init_colors()
|
||||
stdscr.keypad(True)
|
||||
|
||||
local = Pane(path=os.path.realpath(args.local), root="/", readonly=False,
|
||||
title="Lokaler Rechner")
|
||||
|
||||
if args.path:
|
||||
left = Pane(path=os.path.realpath(args.path), root=os.path.realpath(args.path),
|
||||
readonly=True, title="Verzeichnis: %s" % args.path)
|
||||
Explorer(left, local, subtitle=args.path).run(stdscr)
|
||||
return 0
|
||||
|
||||
_wait_screen(stdscr, "Frage Proxmox nach VMs und Containern ...")
|
||||
proxmox = Proxmox()
|
||||
guests = proxmox.inventory()
|
||||
if not guests:
|
||||
message(stdscr, "Keine VMs oder Container gefunden.", error=True)
|
||||
return 1
|
||||
|
||||
if args.vmid:
|
||||
guest = next((g for g in guests if g.vmid == args.vmid), None)
|
||||
if guest is None:
|
||||
message(stdscr, "Gast %d nicht gefunden." % args.vmid, error=True)
|
||||
return 2
|
||||
else:
|
||||
guest = _pick_guest(stdscr, guests)
|
||||
if guest is None:
|
||||
return 0
|
||||
|
||||
_wait_screen(stdscr, "Lese Snapshots von %s ..." % guest.label)
|
||||
snapshots = list_snapshots(proxmox, guest)
|
||||
if not snapshots:
|
||||
message(stdscr, "%s hat keine Snapshots." % guest.label, error=True)
|
||||
return 1
|
||||
|
||||
if args.snapshot:
|
||||
name = next((s.name for s in snapshots if s.name == args.snapshot), None)
|
||||
if name is None:
|
||||
message(stdscr, "Snapshot %r nicht gefunden." % args.snapshot, error=True)
|
||||
return 2
|
||||
else:
|
||||
name = _pick_snapshot(stdscr, snapshots)
|
||||
if name is None:
|
||||
return 0
|
||||
|
||||
session = Session(proxmox, guest, name)
|
||||
try:
|
||||
_wait_screen(stdscr, "Binde Snapshot '%s' ein - das kann etwas dauern ..." % name)
|
||||
session.open()
|
||||
session.prepare()
|
||||
except (SnapfsError, ProxmoxError) as exc:
|
||||
session.close()
|
||||
_show_notes(stdscr, str(exc).splitlines() + session.notes, "Fehlgeschlagen")
|
||||
return 3
|
||||
|
||||
try:
|
||||
_show_notes(stdscr, session.notes)
|
||||
mountpoints = sorted(set(session.mounts.values()))
|
||||
start = mountpoints[0]
|
||||
if len(mountpoints) > 1:
|
||||
entries = [(m, m) for m in mountpoints]
|
||||
start = choose(stdscr, "Welches Dateisystem zuerst?", entries) or mountpoints[0]
|
||||
|
||||
left = Pane(path=start, root=start, readonly=True,
|
||||
title="%s @ %s" % (guest.label, name))
|
||||
Explorer(left, local, session=session,
|
||||
subtitle="%s - Snapshot %s" % (guest.label, name)).run(stdscr)
|
||||
finally:
|
||||
_wait_screen(stdscr, "Haenge Snapshot wieder aus ...")
|
||||
session.close()
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser():
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="pvesnap-explorer",
|
||||
description="Dateien aus einem Proxmox-Snapshot holen (zwei Fenster wie im "
|
||||
"Midnight Commander).")
|
||||
parser.add_argument("vmid", nargs="?", type=int, help="VMID des Gastes")
|
||||
parser.add_argument("snapshot", nargs="?", help="Name des Snapshots")
|
||||
parser.add_argument("-l", "--local", default=os.getcwd(),
|
||||
help="Startverzeichnis im rechten Fenster (Vorgabe: aktuelles)")
|
||||
parser.add_argument("-p", "--path",
|
||||
help="beliebiges Verzeichnis oeffnen, ohne Proxmox zu fragen")
|
||||
parser.add_argument("--cleanup", action="store_true",
|
||||
help="haengengebliebene Einbindungen aufraeumen und beenden")
|
||||
parser.add_argument("-v", "--verbose", action="store_true")
|
||||
parser.add_argument("-V", "--version", action="version",
|
||||
version="pvesnap-explorer %s" % __version__)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
args = build_parser().parse_args(argv)
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO,
|
||||
format="%(levelname)s %(message)s")
|
||||
|
||||
if args.cleanup:
|
||||
removed = cleanup_leftovers(verbose=True)
|
||||
print("%d Eintrag/Eintraege aufgeraeumt." % len(removed))
|
||||
return 0
|
||||
|
||||
if not args.path:
|
||||
if not pvesh_available():
|
||||
print("'pvesh' nicht gefunden - der Explorer laeuft auf dem Proxmox-Host.\n"
|
||||
"Zum Ausprobieren: pvesnap-explorer --path /ein/verzeichnis",
|
||||
file=sys.stderr)
|
||||
return 3
|
||||
if os.geteuid() != 0:
|
||||
print("Der Explorer braucht root-Rechte (Snapshots einbinden und mounten).",
|
||||
file=sys.stderr)
|
||||
return 4
|
||||
|
||||
# Waehrend der Oberflaeche darf nichts ins Terminal protokolliert werden.
|
||||
handlers = logging.getLogger().handlers[:]
|
||||
if not args.verbose:
|
||||
logging.getLogger().handlers = []
|
||||
try:
|
||||
return curses.wrapper(_main, args)
|
||||
except KeyboardInterrupt:
|
||||
return 130
|
||||
finally:
|
||||
logging.getLogger().handlers = handlers
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user