diff --git a/libvirtnbdbackup/backup/disk.py b/libvirtnbdbackup/backup/disk.py index 1f5f1767..24cd9696 100644 --- a/libvirtnbdbackup/backup/disk.py +++ b/libvirtnbdbackup/backup/disk.py @@ -31,6 +31,9 @@ from libvirtnbdbackup.backup import server from libvirtnbdbackup.backup import target from libvirtnbdbackup.backup.metadata import backupChecksum +from libvirtnbdbackup.backup.metadata import adler32_full_file +from libvirtnbdbackup.backup.metadata import write_checksum_sidecar +from libvirtnbdbackup.backup.metadata import _fsync_file from libvirtnbdbackup import extenthandler from libvirtnbdbackup.qemu import util as qemu from libvirtnbdbackup.qemu.exceptions import ProcessError @@ -208,24 +211,33 @@ def backup( # pylint: disable=too-many-arguments,too-many-branches, too-many-lo if args.compress: dStream.writeCompressionTrailer(writer, compressedSizes) - progressBar.close() + progressBar.close() writer.close() connection.disconnect() if args.offline is True and virtClient.remoteHost == "": logging.info("Stopping NBD Service.") lib.killProc(nbdProc.pid) - if args.offline is True: lib.remove(args, nbdProc.pidFile) if not args.stdout: if args.noprogress is True: - logging.info( - "Backup of disk [%s] finished, file: [%s]", disk.target, targetFile - ) + logging.info("Backup of disk [%s] finished, file: [%s]", disk.target, targetFile) + + # finalize filename before checksumming partialfile.rename(targetFilePartial, targetFile) - if streamType != "raw": - backupChecksum(fileStream, targetFile) + _fsync_file(targetFile) + if streamType == "raw": + logging.info("RAW path: computing full-file Adler32 for %s", targetFile) + checksum = adler32_full_file(targetFile) + logging.info("RAW path: Adler32=%d", checksum) + write_checksum_sidecar(targetFile, checksum) + else: + logging.info("STREAM path: writing stream metadata checksum for %s", targetFile) + backupChecksum(fileStream, targetFile) + + # Always return (even when args.stdout is True) return backupSize, True + diff --git a/libvirtnbdbackup/backup/metadata.py b/libvirtnbdbackup/backup/metadata.py index 22afd3e4..bebad1bb 100644 --- a/libvirtnbdbackup/backup/metadata.py +++ b/libvirtnbdbackup/backup/metadata.py @@ -15,7 +15,7 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import os +import os, zlib import logging from argparse import Namespace from typing import List, Union @@ -31,6 +31,33 @@ log = logging.getLogger() +def adler32_full_file(path: str, bufsize: int = 8 * 1024 * 1024) -> int: + """ + Compute Adler-32 over the entire file, matching virtnbdrestore's verify path. + """ + csum = 1 # zlib.adler32 seed + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(bufsize), b""): + csum = zlib.adler32(chunk, csum) + return csum & 0xFFFFFFFF + +def _fsync_file(path: str) -> None: + try: + with open(path, "rb", buffering=0) as f: + os.fsync(f.fileno()) + except Exception: + pass + +def write_checksum_sidecar(target_file: str, crc: int) -> None: + sidecar = f"{target_file}.chksum" + with open(sidecar, "w") as fh: + fh.write(f"{crc}\n") + try: + with open(sidecar, "rb", buffering=0) as f: + os.fsync(f.fileno()) + except Exception: + pass + def backupChecksum(fileStream, targetFile): """Save the calculated adler32 checksum, it can be verified @@ -144,4 +171,4 @@ def addFiles(args: Namespace, configFile: Union[str, None], zipStream, logFile: zipStream.zipStream.write(diskInfo, os.path.basename(diskInfo)) log.info("Adding backup log [%s] to zipfile", logFile) - zipStream.zipStream.write(logFile, logFile) + zipStream.zipStream.write(logFile, logFile) \ No newline at end of file diff --git a/libvirtnbdbackup/restore/disk.py b/libvirtnbdbackup/restore/disk.py index 60fcd59f..1d1099ad 100644 --- a/libvirtnbdbackup/restore/disk.py +++ b/libvirtnbdbackup/restore/disk.py @@ -1,28 +1,22 @@ #!/usr/bin/python3 """ -Copyright (C) 2023 Michael Ablassmeier - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . +Enable in-place RBD overwrite via virtnbdrestore: +- Detect if the RBD image already exists and pass `-n` to qemu-img convert. +- Handle both copy-mode (flat raw) and sparse-stream restores. """ import logging +import os +import re +import subprocess +from typing import Optional from argparse import Namespace + from libvirtnbdbackup import virt from libvirtnbdbackup import common as lib from libvirtnbdbackup.objects import DomainDisk from libvirtnbdbackup.restore import server from libvirtnbdbackup.restore import files -from libvirtnbdbackup.restore import image +from libvirtnbdbackup.restore import image as image_mod from libvirtnbdbackup.restore import header from libvirtnbdbackup.restore import data from libvirtnbdbackup.restore import vmconfig @@ -32,14 +26,106 @@ from libvirtnbdbackup.nbdcli.exceptions import NbdConnectionTimeout -def _backingstore(args: Namespace, disk: DomainDisk) -> None: - """If an virtual machine was running on an snapshot image, - warn user, the virtual machine configuration has to be - adjusted before starting the VM is possible. +log = logging.getLogger(__name__) +_RBD_RE = re.compile(r"^rbd:(?P[^/]+)(?:/(?P[^/]+))?$") + + +def _parse_rbd_target(target_str: Optional[str], disk: DomainDisk) -> Optional[str]: + """ + Accepts: + - 'rbd:/' (explicit image) + - 'rbd:' (derive image from disk filename) + Returns full 'rbd:/' or None if not rbd. + """ + if not target_str or not isinstance(target_str, str): + return None + m = _RBD_RE.match(target_str) + if not m: + return None + pool = m.group("pool") + image_name = m.group("image") + if not image_name: + image_name = getattr(disk, "filename", disk.target) + return f"rbd:{pool}/{image_name}" + + +def _rbd_pool_image(rbd_url: str) -> Optional[tuple]: + """Extract (pool, image) from rbd:/.""" + m = _RBD_RE.match(rbd_url) + if not m or not m.group("image"): + return None + return (m.group("pool"), m.group("image")) + + +def _rbd_target_for_disk(base_str: Optional[str], disk: DomainDisk, is_multi: bool) -> Optional[str]: + """ + Build a per-disk RBD URL from 'rbd:[/]'. - User created external or internal Snapshots are not part of - the backup. + Rules: + - 'rbd:' -> image := (or target dev if no filename) + - 'rbd:/': + if is_multi == True -> image := '-' (e.g., '-vda', '-vdb') + else -> image := '' (as-is) """ + if not base_str or not isinstance(base_str, str): + return None + m = _RBD_RE.match(base_str) + if not m: + return None + + pool = m.group("pool") + image = m.group("image") + dev = getattr(disk, "target", "disk") + + # Derive a safe base from filename if pool-only used + if not image: + fname = getattr(disk, "filename", None) or dev + base = os.path.splitext(os.path.basename(fname))[0] or dev + image = base + else: + if is_multi: + image = f"{image}-{dev}" + + return f"rbd:{pool}/{image}" + + +def _rbd_exists(rbd_url: str) -> bool: + """ + Return True if the RBD image exists (ceph CLI required). + """ + pi = _rbd_pool_image(rbd_url) + if not pi: + return False + pool, image = pi + try: + # rbd info / -> 0 if exists + subprocess.run( + ["rbd", "info", f"{pool}/{image}"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=True, + ) + return True + except subprocess.CalledProcessError: + return False + + +def _qemu_img_convert_raw_to_rbd(src: str, rbd_url: str) -> None: + """ + Convert raw file to RBD. If the image already exists, pass -n to overwrite in place. + """ + exists = _rbd_exists(rbd_url) + cmd = ["qemu-img", "convert", "-f", "raw", "-O", "raw"] + if exists: + cmd.append("-n") # do not try to create; overwrite existing image + log.info("Target RBD exists; using in-place overwrite (-n).") + cmd.extend([src, rbd_url]) + log.debug("Executing: %s", " ".join(cmd)) + subprocess.run(cmd, check=True) + + +def _backingstore(args: Namespace, disk: DomainDisk) -> None: + """Warn if the VM was on a snapshot image and user didn't request config adjust.""" if len(disk.backingstores) > 0 and not args.adjust_config: logging.warning( "Target image [%s] seems to be a snapshot image.", disk.filename @@ -51,15 +137,46 @@ def _backingstore(args: Namespace, disk: DomainDisk) -> None: def restore( # pylint: disable=too-many-branches,too-many-statements,too-many-locals args: Namespace, ConfigFile: str, virtClient: virt.client ) -> bytes: - """Handle disk restore operation and adjust virtual machine - configuration accordingly.""" + """Handle disk restore operation and adjust virtual machine configuration accordingly.""" + from libvirtnbdbackup.virt import xml # local import to avoid altering module imports + stream = streamer.SparseStream(types) + + # Load and normalize config for disk parsing vmConfig = vmconfig.read(ConfigFile) vmConfig = vmconfig.changeVolumePathes(args, vmConfig).decode() + + # Parse VM name and original disk sources (to detect in-place overwrite) + tree = xml.asTree(vmConfig) + try: + original_vm_name = tree.find("name").text + except Exception: + original_vm_name = None + + original_rbd_by_dev = {} + for d in tree.xpath("devices/disk"): + try: + dev = d.xpath("target")[0].get("dev") + except Exception: + continue + if d.get("type") == "network": + src = d.find("source") + if src is not None and src.get("protocol") == "rbd": + name = src.get("name") # expected "pool/image" + if name: + original_rbd_by_dev[dev] = name + vmDisks = virtClient.getDomainDisks(args, vmConfig) if not vmDisks: raise RestoreError("Unable to parse disks from config") + # Determine if restoring multiple disks in this invocation + restoreDisks = [d for d in vmDisks if args.disk in (None, d.target)] + is_multi = len(restoreDisks) > 1 + + # We will stop the original domain once (lazy), if we detect we're about to overwrite its disks + domain_stopped = False + restConfig: bytes = vmConfig.encode() for disk in vmDisks: if args.disk not in (None, disk.target): @@ -77,13 +194,64 @@ def restore( # pylint: disable=too-many-branches,too-many-statements,too-many-l restConfig = vmconfig.removeDisk(restConfig.decode(), disk.target) continue + # Decide targets (filesystem vs RBD). For RBD, ensure per-disk unique naming when needed. targetFile = files.target(args, disk) - if args.raw and disk.format == "raw": - logging.info("Restoring raw image to [%s]", targetFile) - lib.copy(args, restoreDisk[0], targetFile) + rbdTarget = _rbd_target_for_disk(getattr(args, "output", None), disk, is_multi) + isRbdTarget = rbdTarget is not None + if isRbdTarget: + logging.info("Disk [%s]: restore target is Ceph RBD [%s]", disk.target, rbdTarget) + + # If this is an in-place overwrite (target RBD equals original RBD for this disk), + # stop the original domain once before writing. + if isRbdTarget and not domain_stopped and original_vm_name: + # rbdTarget is "rbd:pool/image" -> compare the "pool/image" part + try: + pool, image = _rbd_pool_image(rbdTarget) # returns (pool, image) + except Exception: + pool, image = (None, None) + if pool and image: + original_name_for_dev = original_rbd_by_dev.get(disk.target) + if original_name_for_dev == f"{pool}/{image}": + logging.info( + "In-place overwrite detected for disk [%s] (%s); " + "ensuring domain [%s] is stopped before restore.", + disk.target, original_name_for_dev, original_vm_name, + ) + if not virtClient.ensureDomainStopped(original_vm_name, graceful_timeout=60): + raise RestoreError( + f"Unable to stop domain [{original_vm_name}] before in-place restore." + ) + domain_stopped = True + + # Detect copy-mode backups (flat raw). In this case, do NOT read sparse headers. + is_copy_backup = any(".copy." in p for p in restoreDisk) + + # ---- COPY BACKUP HANDLING ---- + if is_copy_backup: + src_raw = restoreDisk[0] # flat raw image + if isRbdTarget: + logging.info("Converting flat raw [%s] -> [%s] via qemu-img convert (raw).", src_raw, rbdTarget) + try: + _qemu_img_convert_raw_to_rbd(src_raw, rbdTarget) + except subprocess.CalledProcessError as e: + raise RestoreError(f"qemu-img convert to RBD failed: {e}") from e + + if args.adjust_config is True: + restConfig = vmconfig.adjust(args, disk, restConfig.decode(), rbdTarget) + + continue + + # Filesystem target: just copy the flat raw + logging.info("Restoring flat raw copy [%s] to [%s]", src_raw, targetFile) + lib.copy(args, src_raw, targetFile) + + if args.adjust_config is True: + restConfig = vmconfig.adjust(args, disk, restConfig.decode(), targetFile) + continue + # ---- SPARSE-STREAM (FULL/DIFF) HANDLING ---- if "full" not in restoreDisk[0] and "copy" not in restoreDisk[0]: logging.error( "[%s]: Unable to locate base full or copy backup.", restoreDisk[0] @@ -96,27 +264,56 @@ def restore( # pylint: disable=too-many-branches,too-many-statements,too-many-l meta = header.get(restoreDisk[cptnum], stream) + # For RBD target, reconstruct into a local temp raw first, then push to RBD. + if isRbdTarget: + tmp_dir = getattr(args, "tmpdir", "/var/tmp") + try: + os.makedirs(tmp_dir, exist_ok=True) + except Exception as e: # noqa: BLE001 + raise RestoreError(f"Failed to prepare temp dir [{tmp_dir}]: {e}") from e + localTarget = os.path.join( + tmp_dir, f"virtnbdrestore.{disk.target}.{os.getpid()}.img" + ) + createTarget = localTarget + logging.info("Creating local target for RBD stream: [%s]", createTarget) + else: + createTarget = targetFile + try: - image.create(args, meta, targetFile, args.sshClient) + image_mod.create(args, meta, createTarget, args.sshClient) except RestoreError as errmsg: raise RestoreError("Creating target image failed.") from errmsg try: - connection = server.start(args, meta["diskName"], targetFile, virtClient) + connection = server.start(args, meta["diskName"], createTarget, virtClient) except NbdConnectionTimeout as e: raise RestoreError(e) from e for dataFile in restoreDisk: try: - data.restore(args, stream, dataFile, targetFile, connection) + data.restore(args, stream, dataFile, createTarget, connection) except UntilCheckpointReached: break except RestoreError: break _backingstore(args, disk) + + if isRbdTarget: + logging.info("Streaming reconstructed image into Ceph RBD: [%s] -> [%s]", createTarget, rbdTarget) + try: + _qemu_img_convert_raw_to_rbd(createTarget, rbdTarget) + except subprocess.CalledProcessError as e: + raise RestoreError(f"qemu-img convert to RBD failed: {e}") from e + finally: + try: + os.remove(createTarget) + except Exception: # noqa: BLE001 + pass + if args.adjust_config is True: - restConfig = vmconfig.adjust(args, disk, restConfig.decode(), targetFile) + adjustTarget = rbdTarget if isRbdTarget else targetFile + restConfig = vmconfig.adjust(args, disk, restConfig.decode(), adjustTarget) logging.debug("Closing NBD connection") connection.disconnect() @@ -126,3 +323,4 @@ def restore( # pylint: disable=too-many-branches,too-many-statements,too-many-l restConfig = vmconfig.setVMName(args, restConfig.decode()) return restConfig + diff --git a/libvirtnbdbackup/restore/image.py b/libvirtnbdbackup/restore/image.py index e91cd115..723a86df 100644 --- a/libvirtnbdbackup/restore/image.py +++ b/libvirtnbdbackup/restore/image.py @@ -16,10 +16,12 @@ along with this program. If not, see . """ import os +import re import logging import json from argparse import Namespace -from typing import List, Dict +from typing import List, Dict, Optional + from libvirtnbdbackup.qemu import util as qemu from libvirtnbdbackup import output from libvirtnbdbackup import common as lib @@ -29,6 +31,15 @@ from libvirtnbdbackup.ssh.exceptions import sshError +_RBD_RE = re.compile(r"^rbd:(?P[^/]+)(?:/(?P[^/]+))?$") + + +def _is_rbd_target(s: Optional[str]) -> bool: + if not s or not isinstance(s, str): + return False + return _RBD_RE.match(s) is not None + + def getConfig(args: Namespace, meta: Dict[str, str]) -> List[str]: """Check if backup includes exported qcow config and return a list of options passed to qemu-img create command""" @@ -78,9 +89,11 @@ def getConfig(args: Namespace, meta: Dict[str, str]) -> List[str]: "Unable apply QCOW specific lazy_refcounts option: [%s]", errmsg ) + # Handle data-file path only when the output is a filesystem path. + # If args.output is an RBD URL (rbd:pool[/image]), we must not join it with filenames. try: dataFile = qcowConfig["format-specific"]["data"]["data-file"] - if args.adjust_config is True: + if args.adjust_config is True and not _is_rbd_target(getattr(args, "output", None)): dataFilePath = os.path.join( args.output, os.path.basename(dataFile), @@ -91,14 +104,15 @@ def getConfig(args: Namespace, meta: Dict[str, str]) -> List[str]: dataFilePath, ) else: + dataFilePath = dataFile logging.info( "QCOW image with data-file backend detected, keeping original path: [%s]", - dataFile, + dataFilePath, ) opt.append("-o") opt.append(f"data_file={dataFilePath}") - except KeyError as errmsg: + except KeyError: pass try: @@ -106,7 +120,7 @@ def getConfig(args: Namespace, meta: Dict[str, str]) -> List[str]: opt.append("-o") opt.append("data_file_raw=true") logging.info("QCOW image with RAW data-file backend detected.") - except KeyError as errmsg: + except KeyError: pass return opt @@ -114,7 +128,20 @@ def getConfig(args: Namespace, meta: Dict[str, str]) -> List[str]: def create(args: Namespace, meta: Dict[str, str], targetFile: str, sshClient): """Read QCOW image related backup json and create target image file using - its original options""" + its original options. + + RBD-aware behavior: + - If targetFile is an RBD URL (rbd:[/]), we do NOT create anything here. + The disk restore pipeline will stream the reconstructed raw image into RBD via + `qemu-img convert -O raw rbd:...` after writing to a local staging file. + """ + if _is_rbd_target(targetFile): + logging.info( + "Target [%s] is an RBD URL; skipping local image creation (handled later by qemu-img convert).", + targetFile, + ) + return + options = getConfig(args, meta) logging.info( "Create virtual disk [%s] format: [%s] size: [%s] based on: [%s] preallocated: [%s]", @@ -145,3 +172,4 @@ def create(args: Namespace, meta: Dict[str, str], targetFile: str, sshClient): except (ProcessError, sshError) as e: logging.error("Failed to create restore target: [%s]", e) raise RestoreError from e + diff --git a/libvirtnbdbackup/restore/rbd.py b/libvirtnbdbackup/restore/rbd.py new file mode 100644 index 00000000..752e5869 --- /dev/null +++ b/libvirtnbdbackup/restore/rbd.py @@ -0,0 +1,68 @@ +# libvirtnbdbackup/restore/_rbd.py +import os +import shlex +import socket +import subprocess +import tempfile +import time + +class NbdkitServer: + def __init__(self, blockmap_path, data_path): + self.blockmap_path = blockmap_path + self.data_path = data_path + self.sock = None + self.proc = None + + def __enter__(self): + self.sock = tempfile.mktemp(prefix="virtnbdrestore.", suffix=".sock", dir="/var/tmp") + # virtnbd-nbdkit-plugin is shipped by the project; use it to serve our backup as NBD + cmd = [ + "nbdkit", + "--exit-with-parent", + "--unix", self.sock, + "--threads", "1", + "python", + "script=virtnbd-nbdkit-plugin", + f"blockmap={self.blockmap_path}", + f"image={self.data_path}", + ] + self.proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + # wait until the socket is ready + for _ in range(50): + if os.path.exists(self.sock): + try: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s: + s.connect(self.sock) + break + except OSError: + pass + time.sleep(0.1) + return self.sock + + def __exit__(self, exc_type, exc, tb): + try: + if self.proc: + self.proc.terminate() + self.proc.wait(timeout=5) + except Exception: + try: + self.proc.kill() + except Exception: + pass + try: + if self.sock and os.path.exists(self.sock): + os.unlink(self.sock) + except Exception: + pass + + +def qemu_img_convert_from_unix_nbd_to_rbd(unix_sock, rbd_url, preallocate=False): + """ + Convert NBD served at `unix_sock` to RBD image (rbd:pool/image). + """ + src = f"nbd+unix:///?socket={unix_sock}" + cmd = ["qemu-img", "convert", "-f", "raw", "-O", "raw", src, rbd_url] + if preallocate: + cmd[4:4] = ["-o", "preallocation=falloc"] + subprocess.run(cmd, check=True) + diff --git a/libvirtnbdbackup/restore/vmconfig.py b/libvirtnbdbackup/restore/vmconfig.py index 98008eeb..ae0bea68 100644 --- a/libvirtnbdbackup/restore/vmconfig.py +++ b/libvirtnbdbackup/restore/vmconfig.py @@ -107,67 +107,141 @@ def setVMName(args: Namespace, vmConfig: str) -> bytes: return xml.ElementTree.tostring(tree, encoding="utf8", method="xml") -def adjust( - args: Namespace, restoreDisk: DomainDisk, vmConfig: str, targetFile: str -) -> bytes: - """Adjust virtual machine configuration after restoring. Changes - the paths to the virtual machine disks and attempts to remove - components excluded during restore.""" +def adjust(args, disk, vmConfig: str, adjustTarget: str) -> bytes: + """ + Adjust the provided libvirt VM XML (vmConfig) to point the given `disk` + (matched by its target dev, e.g. vda) at `adjustTarget`. + + RBD target ("rbd:/"): + + + + [] + + + + File target (/path/to/file): + + (left as-is unless absent) + + + + If args.detach_unrestored is True AND args.disk is set, all other entries + are removed so the clone never references original images. + + Additionally, we de-duplicate: any non-selected disk that ends up pointing to the + same RBD pool/image as the selected disk is removed. + """ tree = xml.asTree(vmConfig) - for disk in tree.xpath("devices/disk"): - dev = disk.xpath("target")[0].get("dev") - logging.debug("Handling target device: [%s]", dev) + want_rbd = isinstance(adjustTarget, str) and adjustTarget.startswith("rbd:") - device = disk.get("device") - driver = disk.xpath("driver")[0].get("type") + rbd_user = getattr(args, "rbd_user", None) + rbd_secret_uuid = getattr(args, "rbd_secret_uuid", None) + target_name = getattr(disk, "target", None) - if disktype.Optical(device, dev): - logging.info("Removing device [%s], type [%s] from vm config", dev, device) - disk.getparent().remove(disk) - continue + def _target_dev(disk_el): + tgt = disk_el.find("target") + return tgt.get("dev") if tgt is not None else None - if disktype.Raw(driver, device) and args.raw is False: - logging.warning( - "Removing raw disk [%s] from vm config, use --raw to copy as is.", - dev, - ) - disk.getparent().remove(disk) - continue - - backingStore = disk.xpath("backingStore") - if backingStore: - logging.info("Removing existent backing store settings") - disk.remove(backingStore[0]) - - dataStore = disk.xpath("source/dataStore") - if dataStore and dev == restoreDisk.target: - originalFile = os.path.basename( - disk.xpath("source/dataStore/source")[0].get("file") - ) - abspath = os.path.join( - os.path.abspath(os.path.dirname(targetFile)), originalFile - ) - logging.info( - "Adjusting dataStore setting for disk [%s] from [%s] to [%s]", - restoreDisk.target, - disk.xpath("source/dataStore/source")[0].get("file"), - abspath, - ) - disk.xpath("source/dataStore/source")[0].set("file", abspath) - - originalFile = disk.xpath("source")[0].get("file") - if dev == restoreDisk.target: - abspath = os.path.abspath(targetFile) - logging.info( - "Change target file for disk [%s] from [%s] to [%s]", - restoreDisk.target, - originalFile, - abspath, - ) - disk.xpath("source")[0].set("file", abspath) + devices = tree.find("devices") + if devices is None: + # No devices section? return unchanged + return xml.ElementTree.tostring(tree, encoding="utf-8") - return xml.ElementTree.tostring(tree, encoding="utf8", method="xml") + # Find the selected disk element + selected = None + for d in xml.disks(tree): + if _target_dev(d) == target_name: + selected = d + break + if selected is None: + # Could not find matching disk; return unchanged + return xml.ElementTree.tostring(tree, encoding="utf-8") + + # ----- Adjust the selected disk ----- + if want_rbd: + # Parse rbd:pool/image + pool_image = adjustTarget.split(":", 1)[1] + if "/" not in pool_image: + raise ValueError(f"Invalid RBD target '{adjustTarget}', expected rbd:/") + pool, image = pool_image.split("/", 1) + + # Force disk type to network, ensure driver + selected.set("type", "network") + if selected.get("device") is None: + selected.set("device", "disk") + + drv = selected.find("driver") + if drv is None: + drv = xml.ElementTree.SubElement(selected, "driver") + drv.set("name", "qemu") + if drv.get("type") is None: + drv.set("type", "raw") + + # Remove all existing and nodes before creating clean RBD source + for n in list(selected.findall("source")): + selected.remove(n) + for n in list(selected.findall("auth")): + selected.remove(n) + + src = xml.ElementTree.SubElement(selected, "source") + src.set("protocol", "rbd") + src.set("name", f"{pool}/{image}") + + # Optional auth + if rbd_user or rbd_secret_uuid: + auth = xml.ElementTree.SubElement(selected, "auth") + if rbd_user: + auth.set("username", rbd_user) + if rbd_secret_uuid: + secret = xml.ElementTree.SubElement(auth, "secret") + secret.set("type", "ceph") + secret.set("uuid", rbd_secret_uuid) + + # De-duplication: remove any other disk that points to the same pool/image + for d in list(xml.disks(tree)): + if d is selected: + continue + # Check if other disk has an RBD source with same name + s = d.find("source") + if s is not None and s.get("protocol") == "rbd" and s.get("name") == f"{pool}/{image}": + try: + devices.remove(d) + logging.info("Removed duplicate disk referencing the same RBD [%s/%s].", pool, image) + except Exception: + pass + + else: + # Filesystem-backed disk + selected.set("type", "file") + if selected.get("device") is None: + selected.set("device", "disk") + + # Remove all existing and just in case + for n in list(selected.findall("source")): + selected.remove(n) + for n in list(selected.findall("auth")): + selected.remove(n) + + src = selected.find("source") + if src is None: + src = xml.ElementTree.SubElement(selected, "source") + src.attrib.clear() + src.set("file", adjustTarget) + + # ----- Optionally detach all other (unrestored) disks ----- + detach_others = bool(getattr(args, "detach_unrestored", False)) and bool(getattr(args, "disk", None)) + if detach_others: + for d in list(xml.disks(tree)): + if d is selected: + continue + try: + devices.remove(d) + logging.info("Detached unrestored disk [%s] from adjusted VM config.", _target_dev(d)) + except Exception: + pass + return xml.ElementTree.tostring(tree, encoding="utf-8") def restore( args: Namespace, diff --git a/libvirtnbdbackup/virt/client.py b/libvirtnbdbackup/virt/client.py index 4947bc88..524387cf 100644 --- a/libvirtnbdbackup/virt/client.py +++ b/libvirtnbdbackup/virt/client.py @@ -18,6 +18,7 @@ import os import string import random +import time import logging from argparse import Namespace from typing import Any, Dict, List, Tuple, Union @@ -67,7 +68,6 @@ def _cred(credentials, user_data) -> int: credential[4] = user_data[0] elif credential[0] == libvirt.VIR_CRED_PASSPHRASE: credential[4] = user_data[1] - return 0 log.debug("Username: %s", user) @@ -80,7 +80,6 @@ def _cred(credentials, user_data) -> int: user_data = [user, password] auth.append(_cred) auth.append(user_data) - return libvirt.openAuth(uri, auth, 0) except libvirt.libvirtError as e: raise connectionFailed(e) from e @@ -102,7 +101,7 @@ def _connect(self, args: Namespace) -> libvirt.virConnect: is established to a remote host.""" log.debug("Libvirt URI: [%s]", args.uri) - if args.user and args.password: + if getattr(args, "user", None) and getattr(args, "password", None): conn = self._connectAuth(args.uri, args.user, args.password) else: conn = self._connectOpen(args.uri) @@ -115,10 +114,7 @@ def _connect(self, args: Namespace) -> libvirt.virConnect: # domain files will be copied via SFTP. if "qemu+ssh" in args.uri: remoteHostname = conn.getHostname() - log.info( - "Connected to remote host: [%s]", - remoteHostname, - ) + log.info("Connected to remote host: [%s]", remoteHostname) self.remoteHost = remoteHostname return conn @@ -212,17 +208,66 @@ def domainAutoStart(domObj: libvirt.virDomain) -> None: except libvirt.libvirtError as errmsg: log.warning("Failed to set autostart flag for domain: [%s]", errmsg) - def defineDomain(self, vmConfig: bytes, autoStart: bool) -> bool: - """Define domain based on restored config""" + def defineDomain( + self, vmConfig: bytes, autoStart: bool, allowExisting: bool = False + ) -> bool: + """Define domain based on restored config. + + When allowExisting=True: + - If a domain with the same name already exists, do NOT fail—just warn and return True. + - If autoStart is True, apply autostart to the existing domain. + """ + # Extract domain name from the XML we'll define + try: + tree = xml.asTree(vmConfig.decode()) + name_el = tree.find("name") + dom_name = name_el.text if name_el is not None else None + except Exception as e: + log.error("Failed to parse restored VM config for name: %s", e) + return False + + # If requested, tolerate existing domains + if allowExisting and dom_name: + try: + existing = self._conn.lookupByName(dom_name) + except libvirt.libvirtError: + existing = None + + if existing is not None: + log.warning( + "Domain [%s] already exists; skipping re-definition because --auto-register is enabled.", + dom_name, + ) + if autoStart: + self.domainAutoStart(existing) + return True + + # Normal define path try: log.info("Redefining domain based on adjusted config.") domObj = self._conn.defineXMLFlags(vmConfig.decode(), 0) log.info("Successfully redefined domain [%s]", domObj.name()) except libvirt.libvirtError as errmsg: + # If allowExisting is set, also tolerate 'already exists' errors that happen mid-define. + msg = str(errmsg) + if allowExisting and "already exists" in msg.lower() and dom_name: + log.warning( + "Domain [%s] already exists; continuing because --auto-register is enabled. (%s)", + dom_name, + msg, + ) + try: + domObj = self._conn.lookupByName(dom_name) + if autoStart: + self.domainAutoStart(domObj) + except libvirt.libvirtError: + pass + return True + log.error("Failed to define domain: [%s]", errmsg) return False - if autoStart is True: + if autoStart: self.domainAutoStart(domObj) return True @@ -231,11 +276,11 @@ def getDomainInfo(self, vmConfig: str) -> Dict[str, str]: """Return object with general vm information relevant for backup""" tree = xml.asTree(vmConfig) - settings = {} + settings: Dict[str, str] = {} for flag in ["loader", "nvram", "kernel", "initrd"]: try: - settings[flag] = tree.find("os").find(flag).text + settings[flag] = tree.find("os").find(flag).text # type: ignore[union-attr] except AttributeError as e: log.debug("No setting [%s] found: %s", flag, e) @@ -287,7 +332,7 @@ def _getDiskPathByVolume(self, disk: xml._Element) -> Union[str, None]: return diskPath - def _hint(self, dev: str): + def _hint(self, dev: str) -> None: """Show hint about possibility to reconfigure virtual machine with raw devices to support incremental backups""" @@ -300,13 +345,11 @@ def _hint(self, dev: str): ) log.warning(msg) - def getDomainDisks( # pylint: disable=too-many-branches - self, args: Namespace, vmConfig: str - ) -> List[DomainDisk]: + def getDomainDisks(self, args: Namespace, vmConfig: str) -> List[DomainDisk]: """Parse virtual machine configuration for disk devices, filter all unsupported or excluded devices """ - devices = [] + devices: List[DomainDisk] = [] excludeList = None if args.exclude is not None: @@ -351,9 +394,30 @@ def getDomainDisks( # pylint: disable=too-many-branches continue diskPath = disk.xpath("source")[0].get("dev") elif diskType == "network": - log.error("Unsupported network disk type for disk [%s]", dev) - self._hint(dev) - continue + # Support Ceph RBD volumes (network/protocol='rbd') when --raw is used + log.debug("Disk [%s]: network notation", dev) + src = disk.xpath("source")[0] + protocol = src.get("protocol") + # Only handle RBD here; other network protocols are not supported + if protocol != "rbd": + log.info( + "Skipping network disk [%s]: unsupported protocol [%s]", + dev, + protocol, + ) + continue + if args.raw is False: + # RBD presents as driver type='raw' — include only when --raw is set. + self._hint(dev) + log.info("Skipping RBD disk [%s] (use --raw to include)", dev) + continue + # Expect name="pool/image" on + name = src.get("name") + if not name or "/" not in name: + log.error("Invalid RBD source 'name' for disk [%s]", dev) + continue + # Synthesize a 'path' so downstream logic has a stable identifier + diskPath = f"rbd:{name}" else: log.error("Unable to detect disk volume type for disk [%s]", dev) continue @@ -388,7 +452,7 @@ def getDomainDisks( # pylint: disable=too-many-branches log.debug("Device list: %s ", devices) return devices - def _createBackupXml(self, args: Namespace, diskList) -> str: + def _createBackupXml(self, args: Namespace, diskList: List[DomainDisk]) -> str: """Create XML file for starting an backup task using libvirt API.""" top = xml.ElementTree.Element("domainbackup", {"mode": "pull"}) if self.remoteHost == "": @@ -398,9 +462,9 @@ def _createBackupXml(self, args: Namespace, diskList) -> str: else: listen = self.remoteHost tls = "no" - if args.tls: + if getattr(args, "tls", False): tls = "yes" - if args.nbd_ip != "": + if getattr(args, "nbd_ip", "") != "": listen = args.nbd_ip xml.ElementTree.SubElement( top, @@ -414,19 +478,19 @@ def _createBackupXml(self, args: Namespace, diskList) -> str: inc = xml.ElementTree.SubElement(top, "incremental") inc.text = args.cpt.parent - for disk in diskList: + for d in diskList: scratchId = "".join( random.choices(string.ascii_uppercase + string.digits, k=5) ) - scratchFile = f"{args.scratchdir}/backup.{scratchId}.{disk.target}" + scratchFile = f"{args.scratchdir}/backup.{scratchId}.{d.target}" log.debug("Using scratch file: %s", scratchFile) - dE = xml.ElementTree.SubElement(disks, "disk", {"name": disk.target}) + dE = xml.ElementTree.SubElement(disks, "disk", {"name": d.target}) xml.ElementTree.SubElement(dE, "scratch", {"file": f"{scratchFile}"}) return xml.indent(top) def _createCheckpointXml( - self, diskList: List[Any], parentCheckpoint: str, checkpointName: str + self, diskList: List[DomainDisk], parentCheckpoint: str, checkpointName: str ) -> str: """Create valid checkpoint XML file which is passed to libvirt API""" top = xml.ElementTree.Element("domaincheckpoint") @@ -439,29 +503,81 @@ def _createCheckpointXml( cptName = xml.ElementTree.SubElement(pct, "name") cptName.text = parentCheckpoint disks = xml.ElementTree.SubElement(top, "disks") - for disk in diskList: + for d in diskList: # No persistent checkpoint will be created for raw disks, # because it is not supported. Backup will only be crash - # consistent. If we would like to create a consistent - # backup, we would have to create an snapshot for these - # kind of disks, example: - # virsh checkpoint-create-as vm4 --diskspec sdb - # error: unsupported configuration: \ - # checkpoint for disk sdb unsupported for storage type raw - # See also: - # https://lists.gnu.org/archive/html/qemu-devel/2021-03/msg07448.html - if disk.format != "raw": - xml.ElementTree.SubElement(disks, "disk", {"name": disk.target}) + # consistent. + if d.format != "raw": + xml.ElementTree.SubElement(disks, "disk", {"name": d.target}) return xml.indent(top) + + def ensureDomainStopped(self, name: str, graceful_timeout: int = 60) -> bool: + """ + Ensure the domain 'name' is not running. + 1) Attempt graceful ACPI shutdown and wait up to 'graceful_timeout' seconds. + 2) If still running, force poweroff (destroy). + Returns True if the domain is stopped or does not exist; False on failure. + """ + try: + dom = self.getDomain(name) + except domainNotFound: + # Domain with that name isn't defined -> nothing to stop. + return True + + try: + if not dom.isActive(): + return True + except libvirt.libvirtError as e: + log.warning("Failed to check domain state for [%s]: %s", name, e) + # Try to proceed anyway + + # 1) Graceful shutdown + try: + log.info("Requesting graceful shutdown of domain [%s]...", name) + dom.shutdown() + except libvirt.libvirtError as e: + log.warning("Graceful shutdown request failed for [%s]: %s", name, e) + + deadline = time.time() + graceful_timeout + while time.time() < deadline: + try: + if not dom.isActive(): + log.info("Domain [%s] shut down gracefully.", name) + return True + except libvirt.libvirtError: + # If we can't query state, give it a moment + pass + time.sleep(1) + + # 2) Force poweroff + log.warning("Graceful shutdown timed out for [%s]; forcing poweroff.", name) + try: + dom.destroy() + except libvirt.libvirtError as e: + log.error("Force poweroff failed for [%s]: %s", name, e) + return False + + # Confirm it’s off + for _ in range(30): + try: + if not dom.isActive(): + log.info("Domain [%s] is now stopped.", name) + return True + except libvirt.libvirtError: + pass + time.sleep(0.5) + + log.error("Domain [%s] still appears to be running after destroy().", name) + return False def startBackup( self, args: Namespace, domObj: libvirt.virDomain, - diskList: List[Any], + diskList: List[DomainDisk], ) -> None: - """Attempt to start pull based backup task using XML description""" + """Attempt to start pull based backup task using XML description""" backupXml = self._createBackupXml(args, diskList) checkpointXml = None freezed = False @@ -505,3 +621,4 @@ def stopBackup(domObj: libvirt.virDomain) -> bool: except libvirt.libvirtError as err: log.warning("Failed to stop backup job: [%s]", err) return False + diff --git a/libvirtnbdbackup/virt/xml.py b/libvirtnbdbackup/virt/xml.py index ed6fe524..9fa3c2b0 100644 --- a/libvirtnbdbackup/virt/xml.py +++ b/libvirtnbdbackup/virt/xml.py @@ -16,12 +16,17 @@ """ import logging +from typing import List, Optional, Tuple, Dict + from lxml.etree import _Element from lxml import etree as ElementTree log = logging.getLogger() +# ----------------------------- +# Existing helpers +# ----------------------------- def asTree(vmConfig: str) -> _Element: """Return Etree element for vm config""" return ElementTree.fromstring(vmConfig) @@ -43,3 +48,181 @@ def indent(top: _Element) -> str: log.debug("\n%s", xml) return xml + + +# ----------------------------- +# New RBD-aware helpers +# ----------------------------- +def disks(tree: _Element) -> List[_Element]: + """Return a list of elements from a libvirt domain XML tree.""" + return list(tree.xpath("devices/disk")) + + +def disk_type(disk: _Element) -> Optional[str]: + """Return the disk 'type' attribute (file|block|network|volume|...)""" + return disk.get("type") + + +def disk_device(disk: _Element) -> Optional[str]: + """Return the disk 'device' attribute (disk|cdrom|floppy|lun)""" + return disk.get("device") + + +def disk_target_dev(disk: _Element) -> Optional[str]: + """Return the disk target dev (e.g. vda)""" + tgt = disk.find("target") + return tgt.get("dev") if tgt is not None else None + + +def disk_driver_type(disk: _Element) -> Optional[str]: + """Return the disk driver/@type (e.g. qcow2|raw)""" + drv = disk.find("driver") + return drv.get("type") if drv is not None else None + + +def disk_source(disk: _Element) -> Optional[_Element]: + """Return the element of a disk.""" + return disk.find("source") + + +def is_rbd_disk(disk: _Element) -> bool: + """ + Return True if disk is with . + """ + if disk_type(disk) != "network": + return False + src = disk_source(disk) + if src is None: + return False + return src.get("protocol") == "rbd" + + +def rbd_name(disk: _Element) -> Optional[str]: + """ + Return the Ceph RBD name attribute 'pool/image' from , or None. + """ + if not is_rbd_disk(disk): + return None + src = disk_source(disk) + return src.get("name") if src is not None else None + + +def rbd_pool_image(disk: _Element) -> Tuple[Optional[str], Optional[str]]: + """ + Return (pool, image) tuple for an RBD disk, or (None, None). + """ + name = rbd_name(disk) + if not name or "/" not in name: + return None, None + pool, image = name.split("/", 1) + return pool, image + + +def rbd_hosts(disk: _Element) -> List[Tuple[Optional[str], Optional[str]]]: + """ + Return a list of (host, port) tuples defined for RBD . + """ + out: List[Tuple[Optional[str], Optional[str]]] = [] + if not is_rbd_disk(disk): + return out + src = disk_source(disk) + if src is None: + return out + for h in src.findall("host"): + out.append((h.get("name"), h.get("port"))) + return out + + +def rbd_auth(disk: _Element) -> Dict[str, Optional[str]]: + """ + Return RBD auth details: + { + 'username': , + 'secret_uuid': + } + """ + res = {"username": None, "secret_uuid": None} + if not is_rbd_disk(disk): + return res + auth = disk.find("auth") + if auth is not None: + res["username"] = auth.get("username") + secret = auth.find("secret") + if secret is not None: + res["secret_uuid"] = secret.get("uuid") + return res + + +def set_rbd_name(disk: _Element, pool: str, image: str) -> None: + """ + Update for an RBD disk. Keeps and intact. + """ + if not is_rbd_disk(disk): + log.debug("set_rbd_name: disk is not an RBD disk, skipping.") + return + src = disk_source(disk) + if src is None: + log.debug("set_rbd_name: no element found, skipping.") + return + src.set("name", f"{pool}/{image}") + + +def ensure_rbd_source( + disk: _Element, + pool: str, + image: str, + hosts: Optional[List[Tuple[str, str]]] = None, + username: Optional[str] = None, + secret_uuid: Optional[str] = None, +) -> None: + """ + Ensure a disk has an RBD . This can be used when converting a file/block disk + to an RBD-based disk in the XML. + + - Sets type='network' and . + - Preserves existing , , etc. + - Optionally sets elements and . + """ + # Mark disk as network/RBD + disk.set("type", "network") + src = disk_source(disk) + if src is None: + # Create source element in the expected position (order is not critical for libvirt) + src = ElementTree.SubElement(disk, "source") + + src.attrib.clear() + src.set("protocol", "rbd") + src.set("name", f"{pool}/{image}") + + # Hosts + # Remove any existing host children first + for h in list(src.findall("host")): + src.remove(h) + if hosts: + for host, port in hosts: + host_el = ElementTree.SubElement(src, "host") + if host: + host_el.set("name", host) + if port: + host_el.set("port", port) + + # Auth + auth_el = disk.find("auth") + if username or secret_uuid: + if auth_el is None: + auth_el = ElementTree.SubElement(disk, "auth") + auth_el.attrib.clear() + if username: + auth_el.set("username", username) + # secret child + secret_el = auth_el.find("secret") + if secret_el is None: + secret_el = ElementTree.SubElement(auth_el, "secret") + secret_el.attrib.clear() + if secret_uuid: + secret_el.set("type", "ceph") + secret_el.set("uuid", secret_uuid) + elif auth_el is not None: + # If no auth requested, ensure no stale auth remains (optional choice) + pass + diff --git a/virtnbdrestore b/virtnbdrestore index 5dfae3dd..5571a060 100755 --- a/virtnbdrestore +++ b/virtnbdrestore @@ -1,4 +1,4 @@ -#!/usr/bin/python3 +#!/usr/bin/python3.9 """ Copyright (C) 2023 Michael Ablassmeier @@ -21,6 +21,7 @@ import sys import logging import argparse from typing import List + from libvirtnbdbackup import argopt from libvirtnbdbackup import __version__ from libvirtnbdbackup import virt @@ -49,7 +50,7 @@ def main() -> None: "\t%(prog)s -i /backup/ -o dump\n" " # Verify checksums for existing data files in backup:\n" "\t%(prog)s -i /backup/ -o verify\n" - " # Complete restore with all disks:\n" + " # Complete restore with all disks to a directory:\n" "\t%(prog)s -i /backup/ -o /target\n" " # Complete restore, adjust config and redefine vm after restore:\n" "\t%(prog)s -cD -i /backup/ -o /target\n" @@ -62,12 +63,14 @@ def main() -> None: " # Restore and process specific file sequence:\n" "\t%(prog)s -i /backup/ -o /target " "--sequence vdb.full.data,vdb.inc.virtnbdbackup.1.data\n" - " # Restore to remote system:\n" - "\t%(prog)s -U qemu+ssh://root@remotehost/system" - " --ssh-user root -i /backup/ -o /remote_target" + " # Restore to remote system (file target):\n" + "\t%(prog)s -U qemu+ssh://root@remotehost/system --ssh-user root -i /backup/ -o /remote_target\n" + " # Restore to Ceph RBD (no directory required):\n" + "\t%(prog)s -U qemu+ssh://root@remotehost/system -i /backup/ -o rbd:/ -c --auto-register\n" ), formatter_class=argparse.RawTextHelpFormatter, ) + opt = parser.add_argument_group("General options") opt.add_argument( "-a", @@ -86,7 +89,11 @@ def main() -> None: help="Directory including a backup set", ) opt.add_argument( - "-o", "--output", required=True, type=str, help="Restore target directory" + "-o", + "--output", + required=True, + type=str, + help="Restore target (absolute directory for file restores, or RBD URL like rbd:[/])", ) opt.add_argument( "-u", @@ -147,6 +154,16 @@ def main() -> None: action="store_true", help="Register/define VM after restore. (default: %(default)s)", ) + opt.add_argument( + "--auto-register", + default=False, + action="store_true", + help=( + "When used with -D/--define, do not fail if a domain with the same name already exists; " + "warn and keep the existing registration. If --autostart is also set, autostart will be " + "applied to the existing domain." + ), + ) opt.add_argument( "-C", "--config-file", @@ -190,6 +207,7 @@ def main() -> None: # default values for common usage of lib.getDomainDisks args.exclude = None args.include = args.disk + lib.setThreadName() stream = streamer.SparseStream(types) fileLog = lib.getLogFile(args.logfile) or sys.exit(1) @@ -237,54 +255,97 @@ def main() -> None: logging.error("Unable to connect libvirt: [%s]", e) sys.exit(1) - if virtClient.remoteHost: - if not args.output.startswith("/"): - logging.error( - "Absolute target path required for restore to remote system" - ) - sys.exit(1) + # Determine if the target is RBD early so we can handle remote/local setup + is_rbd_output = isinstance(getattr(args, "output", None), str) and args.output.startswith("rbd:") - args.sshClient = lib.sshSession( - args, virtClient.remoteHost, mode=Mode.UPLOAD - ) - if not args.sshClient: - logging.error("Remote restore detected but ssh session setup failed") - sys.exit(1) - if not args.sshClient.exists(args.output): - logging.info("Create target directory: [%s]", args.output) - args.sshClient.sftp.mkdir(args.output) - else: - output.target.Directory().create(args.output) + # Remote file restores require an absolute directory path; RBD targets do not. + if virtClient.remoteHost and not is_rbd_output: + if not args.output.startswith("/"): + logging.error("Absolute target path required for restore to remote system (file restores)") + sys.exit(1) - ConfigFiles = lib.getLatest(args.input, "vmconfig*.xml") - if not ConfigFiles: - logging.error("No domain config file found") + args.sshClient = lib.sshSession(args, virtClient.remoteHost, mode=Mode.UPLOAD) + if not args.sshClient: + logging.error("Remote restore detected but ssh session setup failed") sys.exit(1) - if args.until is not None: - ConfigFile = ConfigFiles[int(args.until.split(".")[-1])] + if not args.sshClient.exists(args.output): + logging.info("Create target directory: [%s]", args.output) + args.sshClient.sftp.mkdir(args.output) + else: + # For local file restores, ensure directory exists; for RBD, nothing to create + if not is_rbd_output: + output.target.Directory().create(args.output) + + # Select VM config file from backup + ConfigFiles = lib.getLatest(args.input, "vmconfig*.xml") + if not ConfigFiles: + logging.error("No domain config file found") + sys.exit(1) + if args.until is not None: + ConfigFile = ConfigFiles[int(args.until.split(".")[-1])] + else: + ConfigFile = ConfigFiles[-1] + logging.info("Using config file: [%s]", ConfigFile) + + # Autostart marker (from backup metadata) + autoStart = False + if lib.getLatest(args.input, "autostart.*", -1): + autoStart = True + + # Do the restore + restConfig: bytes = b"" + try: + if args.sequence is not None: + sequence.restore(args, dataFiles, virtClient) else: - ConfigFile = ConfigFiles[-1] - logging.info("Using config file: [%s]", ConfigFile) + restConfig = disk.restore(args, ConfigFile, virtClient) + except RestoreError as errmsg: + logging.error("Disk restore failed: [%s]", errmsg) + sys.exit(1) - autoStart = False - if lib.getLatest(args.input, "autostart.*", -1): - autoStart = True + # Restore auxiliary files (e.g. OVMF/NVRAM if applicable) + files.restore(args, ConfigFile, virtClient) - restConfig: bytes = b"" - try: - if args.sequence is not None: - sequence.restore(args, dataFiles, virtClient) - else: - restConfig = disk.restore(args, ConfigFile, virtClient) - except RestoreError as errmsg: - logging.error("Disk restore failed: [%s]", errmsg) - sys.exit(1) + # Re-evaluate is_rbd_output (unchanged, but keep logic clear) + is_rbd_output = isinstance(getattr(args, "output", None), str) and args.output.startswith("rbd:") + + # Finalize: write or register the adjusted VM config + if is_rbd_output: + # For RBD targets, do NOT try to write "vmconfig.xml" into the RBD URL. + adjusted_xml = restConfig if isinstance(restConfig, (bytes, bytearray)) else restConfig.encode() + + # Define/register immediately if requested (use backup's autostart marker) + if getattr(args, "auto_register", False): + ok = virtClient.defineDomain( + adjusted_xml, + autoStart=autoStart, + allowExisting=True, + ) + if not ok: + logging.error("Failed to define domain from adjusted config for RBD target.") + sys.exit(1) + logging.info("Domain defined from adjusted config (RBD target).") + + # Always save a local copy of the adjusted XML for auditing + tmpdir = getattr(args, "tmpdir", "/var/tmp") + os.makedirs(tmpdir, exist_ok=True) + local_xml = os.path.join(tmpdir, "virtnbdrestore.vmconfig.adjusted.xml") + with open(local_xml, "wb") as fh: + fh.write(adjusted_xml) + logging.info("Saved adjusted VM XML to [%s].", local_xml) - files.restore(args, ConfigFile, virtClient) + else: + # Filesystem targets keep the original behavior (writes .../vmconfig.xml) vmconfig.restore(args, ConfigFile, restConfig, args.config_file) + + # Refresh storage pool for file-based images and optionally define the VM virtClient.refreshPool(args.output) if args.define is True: - if not virtClient.defineDomain(restConfig, autoStart): + if not virtClient.defineDomain( + restConfig, + autoStart=autoStart, + allowExisting=getattr(args, "auto_register", False), + ): sys.exit(1)