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)