Benannte, dauerhafte Austauschmedien zwischen Host und Gast - Abbilddateien
unter /var/lib/pvesnap/transfer, die zu keinem Gast gehoeren und jede
Wiederherstellung ueberleben.
Zwei Entwurfsentscheidungen, beide am Host geprueft:
Datei statt Proxmox-Volume. Ein Volume wuerde beim Entfernen der Maschine
mitgeloescht, auch aus der Weboberflaeche heraus - die vorbereitete
Werkzeugsammlung waere weg. Pfade ueberspringt destroy_vm ausdruecklich
("return if $volid =~ m|^/|"). Nachgestellt: VM mit --purge entfernt, das
Abbild lebte samt Inhalt weiter.
Loop-Geraet statt direktem Pfad. Eine Abbilddatei als scsi3 einzutragen
lehnt Proxmox ab ("unable to associate path to any storage"), /dev/... wird
dagegen durchgereicht. Ueber losetup -P bekommt der Gast eine Platte mit
Partitionstabelle - ohne die zeigt Windows nur "nicht initialisiert".
Verriegelung, am laufenden System geprueft: ein Laufwerk ist entweder am Host
eingehaengt oder an einem Gast, nie beides. Alle drei Wege wurden abgelehnt -
anhaengen waehrend Host-Mount, Host-Mount waehrend angehaengt, loeschen
waehrend in Benutzung. Der Gast-Zustand wird aus den Konfigurationsdateien
gelesen, gilt also auch fuer gestoppte Maschinen.
Installer: parted und exfatprogs werden bei Bedarf nachinstalliert
(--no-install-deps schaltet das ab). ntfs-3g bewusst nicht - apt entfernt
dafuer 'fuse', an dem pve-cluster (/etc/pve) und ceph-fuse haengen. Dazu gibt
es nur einen Hinweis samt Begruendung; fuer Windows ist exFAT ohnehin die
einfachere Wahl.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
624 lines
21 KiB
Python
624 lines
21 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. Lesen kann der Kernel mehr, als er anlegen
|
|
# kann - exfat und ntfs3 sind eingebaut, die Formatierer aber Extrapakete.
|
|
FILESYSTEMS = {
|
|
"ext4": {
|
|
"mkfs": ["mkfs.ext4", "-q", "-m", "0", "-L"],
|
|
"parted": "ext4",
|
|
"fuer": "Linux-Gaeste",
|
|
"paket": "e2fsprogs",
|
|
},
|
|
"xfs": {
|
|
"mkfs": ["mkfs.xfs", "-q", "-L"],
|
|
"parted": "xfs",
|
|
"fuer": "Linux-Gaeste",
|
|
"paket": "xfsprogs",
|
|
},
|
|
"exfat": {
|
|
"mkfs": ["mkfs.exfat", "-L"],
|
|
"parted": "ntfs",
|
|
"fuer": "Windows und Linux - fuer Windows die erste Wahl",
|
|
"paket": "exfatprogs",
|
|
"hinweis": "apt install exfatprogs (281 KB, entfernt nichts)",
|
|
},
|
|
"ntfs": {
|
|
"mkfs": ["mkfs.ntfs", "-Q", "-F", "-L"],
|
|
"parted": "ntfs",
|
|
"fuer": "Windows",
|
|
"paket": "ntfs-3g",
|
|
# Bewusst als Warnung: apt will dafuer 'fuse' entfernen, und daran
|
|
# haengen ceph-fuse und pve-cluster - also /etc/pve.
|
|
"hinweis": ("apt install ntfs-3g - ACHTUNG: entfernt dabei 'fuse', an dem "
|
|
"pve-cluster (/etc/pve) und ceph-fuse haengen. Nur mit Bedacht "
|
|
"und nicht im laufenden Betrieb. Meist ist exfat die bessere Wahl."),
|
|
},
|
|
"vfat": {
|
|
"mkfs": ["mkfs.vfat", "-n"],
|
|
"parted": "fat32",
|
|
"fuer": "alles, aber nur Dateien bis 4 GB",
|
|
"paket": "dosfstools",
|
|
},
|
|
}
|
|
|
|
# Ohne Partitionstabelle zeigt Windows den Datentraeger als "nicht
|
|
# initialisiert" - erst danach gibt es einen Laufwerksbuchstaben.
|
|
DEFAULT_FS = "ext4"
|
|
DEFAULT_SIZE = "20G"
|
|
|
|
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 ("ext4", "exfat", "ntfs", "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 = DEFAULT_FS
|
|
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=DEFAULT_FS, 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)
|
|
|
|
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 = ("PVESNAP-%s" % name.upper().replace("_", "-"))[:11 if fs == "vfat" else 32]
|
|
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:
|
|
step("Partitionstabelle anlegen (GPT, eine Partition)")
|
|
run(["parted", "-s", image, "mklabel", "gpt",
|
|
"mkpart", "primary", spec["parted"], "1MiB", "100%"])
|
|
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 _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:
|
|
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
|