diff --git a/install.sh b/install.sh index 4f9ca88..6603bf4 100755 --- a/install.sh +++ b/install.sh @@ -11,6 +11,8 @@ # --bind Adresse, auf der sie lauscht # (Vorgabe: 0.0.0.0 = alle) # --no-start installieren, aber Dienste nicht starten +# --no-install-deps fehlende Werkzeuge (parted, exfatprogs) nicht +# nachinstallieren # --force auch ohne erkanntes Proxmox VE installieren # -h, --help diese Hilfe # @@ -37,6 +39,7 @@ SRC="$(cd "$(dirname "$0")" && pwd)" START=1 FORCE=0 +DEPS=1 WEB=0 WEB_PORT="" WEB_BIND="0.0.0.0" @@ -48,8 +51,9 @@ while [ $# -gt 0 ]; do --bind) WEB_BIND="${2:-}"; shift ;; --bind=*) WEB_BIND="${1#*=}" ;; --no-start) START=0 ;; + --no-install-deps) DEPS=0 ;; --force) FORCE=1 ;; - -h|--help) sed -n '2,24p' "$0" | sed 's/^#[[:space:]]\{0,1\}//'; exit 0 ;; + -h|--help) sed -n '2,27p' "$0" | sed 's/^#[[:space:]]\{0,1\}//'; exit 0 ;; *) echo "Unbekannte Option: $1" >&2 echo "Hilfe mit: $0 --help" >&2; exit 1 ;; esac @@ -108,6 +112,58 @@ fi command -v systemctl >/dev/null 2>&1 || fail "systemd wird benoetigt." [ -d "${SRC}/pvesnap" ] || fail "Verzeichnis 'pvesnap' nicht gefunden (Aufruf aus dem Projektordner?)." +# --- Werkzeuge fuer Transfer-Laufwerke ------------------------------------- +# Transfer-Laufwerke sind Abbilddateien, die auf dem Host formatiert und +# eingehaengt werden. Dafuer braucht es eine Partitionstabelle (parted) und +# einen Formatierer je Dateisystem. +have_tool() { + command -v "$1" >/dev/null 2>&1 && return 0 + for d in /usr/local/sbin /usr/sbin /sbin; do + [ -x "$d/$1" ] && return 0 + done + return 1 +} + +if [ "$DEPS" -eq 1 ]; then + MISSING="" + have_tool parted || MISSING="${MISSING} parted" + have_tool mkfs.exfat || MISSING="${MISSING} exfatprogs" + + if [ -n "$MISSING" ]; then + info "Fehlende Werkzeuge fuer Transfer-Laufwerke:${MISSING}" + if command -v apt-get >/dev/null 2>&1; then + info "Installiere:${MISSING}" + # shellcheck disable=SC2086 + if DEBIAN_FRONTEND=noninteractive apt-get install -y ${MISSING}; then + info "Werkzeuge installiert" + else + warn "Installation fehlgeschlagen - Transfer-Laufwerke lassen sich" + warn "dann nur mit den vorhandenen Dateisystemen anlegen." + fi + else + warn "apt-get nicht gefunden - bitte von Hand:${MISSING}" + fi + fi + + # NTFS bewusst NICHT automatisch: apt entfernt dafuer 'fuse', und daran + # haengen pve-cluster (/etc/pve) und ceph-fuse. Auf einem laufenden Node + # ist das kein Schritt, den ein Installer ungefragt gehen darf. + if ! have_tool mkfs.ntfs; then + cat <<'EOF' + +Hinweis: NTFS-Transfer-Laufwerke lassen sich hier nicht anlegen, es fehlt +mkfs.ntfs aus dem Paket ntfs-3g. Die Installation wuerde 'fuse' entfernen, +an dem pve-cluster (/etc/pve) und ceph-fuse haengen - deshalb macht dieses +Skript das nicht von selbst. + + Fuer Windows-Gaeste ist exFAT die einfachere Wahl (bereits eingerichtet). + Wer wirklich NTFS braucht: apt install ntfs-3g - vorher Cluster pruefen. + +Lesen kann der Host NTFS ohnehin, ueber den ntfs3-Treiber im Kernel. +EOF + fi +fi + # --- Programm --------------------------------------------------------- info "Installiere Programm nach ${LIB_DIR}" rm -rf "${LIB_DIR}/pvesnap" diff --git a/pvesnap/transfer.py b/pvesnap/transfer.py new file mode 100644 index 0000000..3dd496f --- /dev/null +++ b/pvesnap/transfer.py @@ -0,0 +1,623 @@ +"""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