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>
629 lines
22 KiB
Python
629 lines
22 KiB
Python
"""Transfer-Laufwerke - benannte Austauschmedien zwischen Host und Gast.
|
|
|
|
Ein wiederhergestellter Snapshot laeuft ohne Netzwerk. Damit trotzdem etwas
|
|
hinein- und wieder herauskommt, gibt es Transfer-Laufwerke: schlichte
|
|
Abbilddateien, die auf dem Host ganz normal eingehaengt werden koennen und sich
|
|
einem Gast als zusaetzliche Platte anhaengen lassen.
|
|
|
|
/var/lib/pvesnap/transfer/dumps.img 20G "Datenbank-Dumps"
|
|
/var/lib/pvesnap/transfer/werkzeuge.img 2G "Skripte und Tools"
|
|
|
|
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
|
|
aus einer Liste aus.
|
|
|
|
Warum eine Datei und kein Proxmox-Volume?
|
|
|
|
* Ein Proxmox-Volume wuerde beim Entfernen der Maschine mitgeloescht - und
|
|
zwar auch dann, wenn jemand sie in der Weboberflaeche wegwirft. Die
|
|
muehsam vorbereitete Werkzeugsammlung waere weg.
|
|
* Bei Pfaden ist das ausgeschlossen: PVE::QemuServer::destroy_vm ueberspringt
|
|
sie ausdruecklich ("return if $volid =~ m|^/|").
|
|
|
|
Angehaengt wird ueber ein Loop-Geraet, weil Proxmox als Pfad nur /dev/...
|
|
akzeptiert - eine Abbilddatei direkt einzutragen lehnt es ab ("unable to
|
|
associate path to any storage").
|
|
|
|
Damit sich Host und Gast nicht gegenseitig das Dateisystem zerlegen, darf ein
|
|
Laufwerk immer nur an einer Stelle in Benutzung sein. Darueber wacht diese
|
|
Schicht.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
|
|
log = logging.getLogger("pvesnap.transfer")
|
|
|
|
TRANSFER_DIR = "/var/lib/pvesnap/transfer"
|
|
INDEX = os.path.join(TRANSFER_DIR, "index.json")
|
|
MOUNT_ROOT = "/run/pvesnap/transfer"
|
|
CONF_ROOT = "/etc/pve/nodes"
|
|
|
|
NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,30}$")
|
|
|
|
# Ein einziges Dateisystem, mit Absicht. exFAT koennen Windows und Linux beide
|
|
# von Haus aus, es kennt keine Besitzrechte auf dem Datentraeger (damit kann
|
|
# das Rechteproblem gar nicht erst auftreten, das ext4 hier hatte) und anders
|
|
# als FAT32 gibt es keine 4-GB-Grenze je Datei. Eine Auswahl waere eine
|
|
# Entscheidung mehr im Notfall - und genau die soll wegfallen.
|
|
FILESYSTEM = "exfat"
|
|
MKFS = ["mkfs.exfat", "-L"]
|
|
PACKAGE = "exfatprogs"
|
|
LABEL_MAX = 11 # mehr laesst exFAT nicht zu
|
|
|
|
DEFAULT_SIZE = "20G"
|
|
|
|
|
|
def formatter_missing():
|
|
"""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))
|
|
|
|
|
|
README = """Transfer-Laufwerk von pvesnap
|
|
|
|
Dieses Laufwerk gehoert zu keiner Maschine. Es dient nur dazu, Dateien
|
|
zwischen dem Proxmox-Host und einem wiederhergestellten Snapshot hin und her
|
|
zu schaufeln - der hat ja kein Netzwerk.
|
|
|
|
Im Gast : Datentraeger mit der Bezeichnung %s einhaengen
|
|
Auf dem Host: pvesnap-recovery -> Taste v -> Enter
|
|
|
|
Alles, was hier liegt, bleibt erhalten - auch wenn die Wiederherstellung
|
|
laengst verworfen ist.
|
|
"""
|
|
|
|
|
|
class TransferError(Exception):
|
|
"""Ein Transfer-Laufwerk liess sich nicht anlegen oder benutzen."""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Kommandos
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def run(args, timeout=300, check=True):
|
|
args = [str(a) for a in args]
|
|
args[0] = which(args[0]) or args[0]
|
|
log.debug("exec: %s", " ".join(args))
|
|
try:
|
|
proc = subprocess.run(args, stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE, timeout=timeout)
|
|
except FileNotFoundError:
|
|
raise TransferError("Kommando nicht gefunden: %s" % args[0])
|
|
except subprocess.TimeoutExpired:
|
|
raise TransferError("Zeitueberschreitung bei: %s" % " ".join(args))
|
|
stdout = proc.stdout.decode("utf-8", "replace").strip()
|
|
stderr = proc.stderr.decode("utf-8", "replace").strip()
|
|
if check and proc.returncode != 0:
|
|
detail = (stderr or stdout or "Fehler").splitlines()
|
|
raise TransferError("%s: %s" % (args[0], detail[-1] if detail else "Fehler"))
|
|
return stdout
|
|
|
|
|
|
# Die Formatierer liegen in sbin. Wer pvesnap aus einer Umgebung mit magerem
|
|
# PATH aufruft - etwa als systemd-Dienst - findet sie sonst nicht, obwohl sie
|
|
# da sind.
|
|
_SBIN = ("/usr/local/sbin", "/usr/sbin", "/sbin")
|
|
|
|
|
|
def which(command):
|
|
found = shutil.which(command)
|
|
if found:
|
|
return found
|
|
for directory in _SBIN:
|
|
candidate = os.path.join(directory, command)
|
|
if os.access(candidate, os.X_OK):
|
|
return candidate
|
|
return None
|
|
|
|
|
|
def have(command):
|
|
return which(command) is not None
|
|
|
|
|
|
def human_bytes(count):
|
|
value = float(count or 0)
|
|
for unit in ("B", "K", "M", "G", "T"):
|
|
if value < 1024 or unit == "T":
|
|
return "%d%s" % (value, unit) if unit == "B" else "%.1f%s" % (value, unit)
|
|
value /= 1024
|
|
return "%.1fT" % value
|
|
|
|
|
|
_SIZE = re.compile(r"^\s*(\d+(?:[.,]\d+)?)\s*([KMGT])?i?B?\s*$", re.I)
|
|
_FACTOR = {"K": 1024, "M": 1024 ** 2, "G": 1024 ** 3, "T": 1024 ** 4}
|
|
|
|
|
|
def parse_size(text, default_unit="G"):
|
|
match = _SIZE.match(str(text or ""))
|
|
if not match:
|
|
raise TransferError("Groessenangabe nicht verstanden: %r (erwartet z.B. 20G)"
|
|
% text)
|
|
value = float(match.group(1).replace(",", "."))
|
|
return max(1024 ** 2, int(value * _FACTOR[(match.group(2) or default_unit).upper()]))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Datenmodell
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@dataclass
|
|
class Volume:
|
|
name: str
|
|
image: str = ""
|
|
size: int = 0
|
|
fs: str = FILESYSTEM
|
|
label: str = ""
|
|
note: str = ""
|
|
created: int = 0
|
|
partitioned: bool = True
|
|
|
|
# zur Laufzeit ermittelt, nicht gespeichert
|
|
loop: str = ""
|
|
mountpoint: str = ""
|
|
attached_to: int = 0
|
|
|
|
@property
|
|
def state(self):
|
|
# Beim Container haelt der Host den Mountpunkt und reicht ihn per
|
|
# 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:
|
|
return "guest"
|
|
if self.mountpoint:
|
|
return "host"
|
|
return "frei"
|
|
|
|
@property
|
|
def state_text(self):
|
|
return {"host": "am Host eingehaengt",
|
|
"guest": "in Benutzung von %d" % self.attached_to,
|
|
"frei": "frei"}[self.state]
|
|
|
|
@property
|
|
def free(self):
|
|
return self.state == "frei"
|
|
|
|
@property
|
|
def used_bytes(self):
|
|
try:
|
|
return os.stat(self.image).st_blocks * 512
|
|
except OSError:
|
|
return 0
|
|
|
|
def to_dict(self):
|
|
return {k: getattr(self, k) for k in
|
|
("name", "image", "size", "fs", "label", "note", "created",
|
|
"partitioned")}
|
|
|
|
@classmethod
|
|
def from_dict(cls, data):
|
|
known = {k: data.get(k) for k in
|
|
("name", "image", "size", "fs", "label", "note", "created",
|
|
"partitioned") if data.get(k) is not None}
|
|
return cls(**known)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Verzeichnis und Verzeichnisdatei
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _index_load():
|
|
try:
|
|
with open(INDEX, "r", encoding="utf-8") as handle:
|
|
data = json.load(handle)
|
|
return [Volume.from_dict(e) for e in data] if isinstance(data, list) else []
|
|
except (OSError, ValueError, TypeError):
|
|
return []
|
|
|
|
|
|
def _index_save(volumes):
|
|
try:
|
|
os.makedirs(TRANSFER_DIR, exist_ok=True)
|
|
tmp = INDEX + ".tmp"
|
|
with open(tmp, "w", encoding="utf-8") as handle:
|
|
json.dump([v.to_dict() for v in volumes], handle, indent=1)
|
|
os.replace(tmp, INDEX)
|
|
except OSError as exc:
|
|
raise TransferError("Verzeichnisdatei %s nicht schreibbar: %s" % (INDEX, exc))
|
|
|
|
|
|
def _index_put(volume):
|
|
volumes = [v for v in _index_load() if v.name != volume.name]
|
|
volumes.append(volume)
|
|
_index_save(sorted(volumes, key=lambda v: v.name))
|
|
|
|
|
|
def _index_drop(name):
|
|
_index_save([v for v in _index_load() if v.name != name])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Zustand aus der Wirklichkeit lesen
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _loop_devices():
|
|
"""{Abbildpfad: Loop-Geraet}"""
|
|
try:
|
|
data = json.loads(run(["losetup", "--json", "--list"], check=False) or "{}")
|
|
except ValueError:
|
|
return {}
|
|
result = {}
|
|
for entry in data.get("loopdevices") or []:
|
|
backing = entry.get("back-file") or ""
|
|
# geloeschte Abbilder haengt losetup ein " (deleted)" an
|
|
backing = backing.split(" (deleted)")[0]
|
|
if backing:
|
|
result[os.path.realpath(backing)] = entry.get("name") or ""
|
|
return result
|
|
|
|
|
|
def _mounts():
|
|
"""{Geraet: Mountpunkt}"""
|
|
result = {}
|
|
try:
|
|
with open("/proc/mounts", "r", encoding="utf-8") as handle:
|
|
for line in handle:
|
|
parts = line.split()
|
|
if len(parts) >= 2:
|
|
result[parts[0]] = parts[1].replace("\\040", " ")
|
|
except OSError:
|
|
pass
|
|
return result
|
|
|
|
|
|
def _guest_usage():
|
|
"""{Loop-Geraet: VMID} - wer haelt gerade welches Geraet?
|
|
|
|
Gelesen aus den Gast-Konfigurationen, weil das die Wahrheit ist: dort
|
|
steht der Eintrag auch dann, wenn die Maschine gerade nicht laeuft.
|
|
"""
|
|
usage = {}
|
|
# 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:
|
|
nodes = os.listdir(CONF_ROOT)
|
|
except OSError:
|
|
return usage
|
|
for node in nodes:
|
|
for sub in ("qemu-server", "lxc"):
|
|
directory = os.path.join(CONF_ROOT, node, sub)
|
|
try:
|
|
names = os.listdir(directory)
|
|
except OSError:
|
|
continue
|
|
for name in names:
|
|
if not (name.endswith(".conf") and name[:-5].isdigit()):
|
|
continue
|
|
try:
|
|
with open(os.path.join(directory, name), "r",
|
|
encoding="utf-8", errors="replace") as handle:
|
|
text = handle.read()
|
|
except OSError:
|
|
continue
|
|
for found in device.findall(text):
|
|
usage[found] = int(name[:-5])
|
|
return usage
|
|
|
|
|
|
def load():
|
|
"""Alle Transfer-Laufwerke mit ihrem tatsaechlichen Zustand."""
|
|
loops = _loop_devices()
|
|
mounts = _mounts()
|
|
usage = _guest_usage()
|
|
|
|
volumes = []
|
|
for volume in _index_load():
|
|
if not os.path.exists(volume.image):
|
|
volume.note = (volume.note + " [Abbilddatei fehlt]").strip()
|
|
volumes.append(volume)
|
|
continue
|
|
volume.attached_to = usage.get(os.path.join(MOUNT_ROOT, volume.name), 0)
|
|
volume.loop = loops.get(os.path.realpath(volume.image), "")
|
|
if volume.loop:
|
|
# Bei partitionierten Abbildern haengt das Dateisystem auf ...p1
|
|
for candidate in (volume.loop + "p1", volume.loop):
|
|
if candidate in mounts:
|
|
volume.mountpoint = mounts[candidate]
|
|
break
|
|
volume.attached_to = volume.attached_to or usage.get(volume.loop, 0)
|
|
volumes.append(volume)
|
|
return volumes
|
|
|
|
|
|
def get(name):
|
|
for volume in load():
|
|
if volume.name == name:
|
|
return volume
|
|
raise TransferError("Kein Transfer-Laufwerk mit dem Namen %r. "
|
|
"Vorhandene zeigt: pvesnap-recovery transfer list" % name)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Anlegen
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def create(name, size=DEFAULT_SIZE, note="", partitioned=True, progress=None):
|
|
"""Ein neues Transfer-Laufwerk anlegen und formatieren."""
|
|
def step(text):
|
|
log.info("%s", text)
|
|
if progress:
|
|
progress(text)
|
|
|
|
name = str(name or "").strip().lower()
|
|
if not NAME_PATTERN.match(name):
|
|
raise TransferError("Ungueltiger Name %r - erlaubt sind Kleinbuchstaben, "
|
|
"Ziffern, Bindestrich und Unterstrich (max. 31)." % name)
|
|
if any(v.name == name for v in _index_load()):
|
|
raise TransferError("Ein Transfer-Laufwerk namens %r gibt es schon." % name)
|
|
|
|
fehlt = formatter_missing()
|
|
if fehlt:
|
|
raise TransferError(fehlt)
|
|
if partitioned and not have("parted"):
|
|
raise TransferError("'parted' fehlt (apt install parted) - ohne "
|
|
"Partitionstabelle zeigt Windows den Datentraeger nur "
|
|
"als 'nicht initialisiert'.")
|
|
|
|
size_bytes = parse_size(size)
|
|
label = _label_for(name)
|
|
image = os.path.join(TRANSFER_DIR, "%s.img" % name)
|
|
|
|
os.makedirs(TRANSFER_DIR, exist_ok=True)
|
|
step("Lege %s an (%s, duenn belegt)" % (image, human_bytes(size_bytes)))
|
|
try:
|
|
with open(image, "wb") as handle:
|
|
handle.truncate(size_bytes)
|
|
except OSError as exc:
|
|
raise TransferError("%s nicht anlegbar: %s" % (image, exc))
|
|
|
|
volume = Volume(name=name, image=image, size=size_bytes, fs=FILESYSTEM,
|
|
label=label,
|
|
note=note, created=int(time.time()), partitioned=partitioned)
|
|
try:
|
|
if partitioned:
|
|
# Die Typ-Kennung "msftdata" (Microsoft basic data) ist der
|
|
# entscheidende Teil: nur darauf vergibt Windows von selbst einen
|
|
# Laufwerksbuchstaben. Ohne sie vergibt parted "Linux filesystem",
|
|
# und Windows zeigt die Partition zwar an, haengt sie aber nicht ein.
|
|
step("Partitionstabelle anlegen (GPT, eine Partition, msftdata)")
|
|
run(["parted", "-s", image, "mklabel", "gpt",
|
|
"mkpart", "primary", "1MiB", "100%",
|
|
"set", "1", "msftdata", "on"])
|
|
with _loop(image, partitioned) as device:
|
|
step("Formatieren mit %s, Bezeichnung %s" % (FILESYSTEM, label))
|
|
run(MKFS + [label, device], timeout=1800)
|
|
_place_readme(device, label)
|
|
except Exception:
|
|
try:
|
|
os.unlink(image)
|
|
except OSError:
|
|
pass
|
|
raise
|
|
|
|
_index_put(volume)
|
|
step("Fertig: %s" % name)
|
|
return volume
|
|
|
|
|
|
class _loop:
|
|
"""Ein Abbild voruebergehend als Blockgeraet - bei Bedarf die Partition."""
|
|
|
|
def __init__(self, image, partitioned=True):
|
|
self.image = image
|
|
self.partitioned = partitioned
|
|
self.device = ""
|
|
|
|
def __enter__(self):
|
|
self.device = attach_loop(self.image, self.partitioned)
|
|
return partition_of(self.device) if self.partitioned else self.device
|
|
|
|
def __exit__(self, *_exc):
|
|
detach_loop(self.device)
|
|
return False
|
|
|
|
|
|
def attach_loop(image, partitioned=True):
|
|
"""Abbild an ein freies Loop-Geraet haengen (oder das vorhandene liefern)."""
|
|
existing = _loop_devices().get(os.path.realpath(image))
|
|
if existing:
|
|
return existing
|
|
args = ["losetup", "--find", "--show"]
|
|
if partitioned:
|
|
args.insert(1, "-P") # Partitionen als /dev/loopNp1 anbieten
|
|
device = run(args + [image], timeout=120)
|
|
if not device.startswith("/dev/"):
|
|
raise TransferError("losetup lieferte keinen Geraetenamen: %s" % device)
|
|
run(["udevadm", "settle", "--timeout=10"], check=False)
|
|
return device
|
|
|
|
|
|
def detach_loop(device):
|
|
if device:
|
|
run(["losetup", "-d", device], check=False, timeout=60)
|
|
|
|
|
|
def partition_of(device):
|
|
"""/dev/loop0 -> /dev/loop0p1, falls es die Partition gibt."""
|
|
for _ in range(30):
|
|
candidate = device + "p1"
|
|
if os.path.exists(candidate):
|
|
return candidate
|
|
time.sleep(0.1)
|
|
return device
|
|
|
|
|
|
def _label_for(name):
|
|
"""Datentraegerbezeichnung - so, dass man sie im Gast wiederfindet.
|
|
|
|
Kurz gehalten, weil exFAT und FAT nur 11 Zeichen zulassen. Im Gast reicht
|
|
damit: mount /dev/disk/by-label/<NAME> /mnt
|
|
"""
|
|
clean = re.sub(r"[^A-Z0-9_-]", "", name.upper())
|
|
return clean[:LABEL_MAX] or "PVESNAP"
|
|
|
|
|
|
def _place_readme(device, label):
|
|
mountpoint = os.path.join(MOUNT_ROOT, ".neu-%d" % os.getpid())
|
|
try:
|
|
os.makedirs(mountpoint, exist_ok=True)
|
|
run(["mount", "-o", "umask=0000", device, mountpoint], timeout=120)
|
|
except (TransferError, OSError) as exc:
|
|
log.debug("Anleitung nicht ablegbar: %s", exc)
|
|
return
|
|
try:
|
|
# Sonst gehoert das Wurzelverzeichnis root - und im Gast sitzt davor
|
|
# ein gewoehnlicher Benutzer, der nichts hineinschreiben kann. Das
|
|
# sieht dann aus wie ein schreibgeschuetzter Datentraeger, ist aber
|
|
# eine Rechtefrage. Bei exFAT und FAT laeuft chmod ins Leere, weil sie
|
|
# ueberhaupt keine Besitzrechte kennen - dort ist es ohnehin egal.
|
|
try:
|
|
os.chmod(mountpoint, 0o777)
|
|
except OSError:
|
|
pass
|
|
with open(os.path.join(mountpoint, "LIESMICH.txt"), "w",
|
|
encoding="utf-8") as handle:
|
|
handle.write(README % label)
|
|
except OSError as exc:
|
|
log.debug("LIESMICH.txt nicht schreibbar: %s", exc)
|
|
finally:
|
|
run(["umount", mountpoint], check=False, timeout=120)
|
|
try:
|
|
os.rmdir(mountpoint)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Am Host benutzen
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def host_mount(volume):
|
|
"""Auf dem Host einhaengen; liefert den Pfad."""
|
|
volume = get(volume.name if isinstance(volume, Volume) else volume)
|
|
if volume.mountpoint:
|
|
return volume.mountpoint
|
|
if volume.attached_to:
|
|
raise TransferError(
|
|
"%r haengt gerade an VM %d. Erst dort abhaengen - Host und Gast "
|
|
"duerfen nicht gleichzeitig darauf schreiben, das zerlegt das "
|
|
"Dateisystem." % (volume.name, volume.attached_to))
|
|
if not os.path.exists(volume.image):
|
|
raise TransferError("Abbilddatei fehlt: %s" % volume.image)
|
|
|
|
device = attach_loop(volume.image, volume.partitioned)
|
|
target = partition_of(device) if volume.partitioned else device
|
|
mountpoint = os.path.join(MOUNT_ROOT, volume.name)
|
|
os.makedirs(mountpoint, exist_ok=True)
|
|
try:
|
|
# 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:
|
|
detach_loop(device)
|
|
raise
|
|
log.info("Transfer-Laufwerk %r eingehaengt unter %s", volume.name, mountpoint)
|
|
return mountpoint
|
|
|
|
|
|
def host_umount(volume):
|
|
"""Wieder aushaengen und das Loop-Geraet freigeben."""
|
|
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:
|
|
run(["umount", volume.mountpoint], check=False, timeout=120)
|
|
try:
|
|
os.rmdir(volume.mountpoint)
|
|
except OSError:
|
|
pass
|
|
if volume.loop and not volume.attached_to:
|
|
detach_loop(volume.loop)
|
|
return True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# An einen Gast haengen
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def drive_string(volume):
|
|
"""Der Wert, der in die Gast-Konfiguration geschrieben wird.
|
|
|
|
Proxmox nimmt als Pfad nur /dev/... - eine Abbilddatei lehnt es ab. Und
|
|
weil es ein Pfad ist, wird er beim Entfernen der Maschine nie freigegeben.
|
|
"""
|
|
volume = get(volume.name if isinstance(volume, Volume) else volume)
|
|
if volume.mountpoint:
|
|
raise TransferError(
|
|
"%r ist gerade auf dem Host eingehaengt (%s). Erst dort aushaengen."
|
|
% (volume.name, volume.mountpoint))
|
|
if volume.attached_to:
|
|
raise TransferError("%r haengt bereits an VM %d."
|
|
% (volume.name, volume.attached_to))
|
|
device = attach_loop(volume.image, volume.partitioned)
|
|
return "%s,backup=0" % device
|
|
|
|
|
|
def release(volume):
|
|
"""Nach dem Verwerfen einer Maschine das Loop-Geraet wieder freigeben."""
|
|
try:
|
|
volume = get(volume.name if isinstance(volume, Volume) else volume)
|
|
except TransferError:
|
|
return False
|
|
if volume.mountpoint or volume.attached_to:
|
|
return False
|
|
if volume.loop:
|
|
detach_loop(volume.loop)
|
|
return True
|
|
|
|
|
|
def remove(name, force=False):
|
|
"""Ein Transfer-Laufwerk endgueltig loeschen."""
|
|
volume = get(name)
|
|
if not volume.free and not force:
|
|
raise TransferError("%r ist gerade in Benutzung (%s)."
|
|
% (name, volume.state_text))
|
|
if volume.mountpoint:
|
|
run(["umount", volume.mountpoint], check=False, timeout=120)
|
|
if volume.loop:
|
|
detach_loop(volume.loop)
|
|
try:
|
|
if os.path.exists(volume.image):
|
|
os.unlink(volume.image)
|
|
except OSError as exc:
|
|
raise TransferError("%s nicht loeschbar: %s" % (volume.image, exc))
|
|
_index_drop(name)
|
|
return True
|
|
|
|
|
|
def cleanup_loops():
|
|
"""Loop-Geraete freigeben, die niemand mehr braucht."""
|
|
freed = []
|
|
for volume in load():
|
|
if volume.loop and not volume.mountpoint and not volume.attached_to:
|
|
detach_loop(volume.loop)
|
|
freed.append(volume.name)
|
|
return freed
|