ext4 speichert Besitzer und Rechte auf dem Datentraeger. Das Wurzelverzeichnis gehoerte root, weil der Host es angelegt hat - im Gast sitzt davor aber ein gewoehnlicher Benutzer, der dann nichts hineinschreiben kann. Das sieht aus wie ein schreibgeschuetzter Datentraeger, ist aber eine Rechtefrage. Das Wurzelverzeichnis wird beim Anlegen jetzt auf 0777 gesetzt - in beiden Wegen, dem alten --exchange und den neuen Transfer-Laufwerken. Bei exFAT und FAT laeuft das ins Leere, weil sie ueberhaupt keine Besitzrechte kennen; dort entscheidet der Einhaengevorgang, und der Automounter im Gast setzt den angemeldeten Benutzer als Eigentuemer. Genau deshalb ist exFAT auch fuer Linux-Gaeste die unkompliziertere Wahl. vorher drwxr-xr-x root -> Benutzer im Gast kann nur lesen nachher drwxrwxrwx root -> schreiben moeglich Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
645 lines
22 KiB
Python
645 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, exfat, "Datenbank-Dumps"
|
|
/var/lib/pvesnap/transfer/werkzeuge.img 2G, ext4, "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}$")
|
|
|
|
# Was sich womit anlegen laesst. Die Partitionstabelle macht in jedem Fall
|
|
# parted, das Dateisystem der jeweilige Formatierer.
|
|
FILESYSTEMS = {
|
|
"ext4": {
|
|
"mkfs": ["mkfs.ext4", "-q", "-m", "0", "-L"],
|
|
"fuer": "nur Linux, behaelt Rechte und Besitzer",
|
|
"paket": "e2fsprogs",
|
|
},
|
|
"xfs": {
|
|
"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"
|
|
|
|
|
|
def default_filesystem():
|
|
return "exfat" if have("mkfs.exfat") else "ext4"
|
|
|
|
|
|
DEFAULT_FS = "exfat"
|
|
|
|
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 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):
|
|
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 = "ext4"
|
|
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):
|
|
if self.mountpoint:
|
|
return "host"
|
|
if self.attached_to:
|
|
return "guest"
|
|
return "frei"
|
|
|
|
@property
|
|
def state_text(self):
|
|
return {"host": "am Host eingehaengt",
|
|
"guest": "an VM %d angehaengt" % 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 = {}
|
|
device = re.compile(r"(/dev/loop\d+)")
|
|
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.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 = 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, fs=None, 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)
|
|
|
|
fs = fs or default_filesystem()
|
|
if fs not in FILESYSTEMS:
|
|
raise TransferError("Unbekanntes Dateisystem %r (%s)"
|
|
% (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"):
|
|
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, fs)
|
|
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=fs, 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" % (fs, label))
|
|
run(spec["mkfs"] + [label, device], timeout=1800)
|
|
_place_readme(device, fs, 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, fs):
|
|
"""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.get(fs, 11)] or "PVESNAP"
|
|
|
|
|
|
def _place_readme(device, fs, label):
|
|
mountpoint = os.path.join(MOUNT_ROOT, ".neu-%d" % os.getpid())
|
|
try:
|
|
os.makedirs(mountpoint, exist_ok=True)
|
|
run(["mount", 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:
|
|
run(["mount", 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.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
|