Skip to content

Backup: support StorPool volumes in NAS backup provider on KVM - #14203

Open
slavkap wants to merge 2 commits into
apache:mainfrom
storpool:sp-nas-backup
Open

slavkap wants to merge 2 commits into
apache:mainfrom
storpool:sp-nas-backup

Conversation

@slavkap

@slavkap slavkap commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Description

Volume pool/path info is now sent to the agent whenever a VM has a StorPool volume, not just when the VM looks Stopped, since nasbackup.sh re-checks the VM's actual liveness itself right before acting and needs the StorPool path to clone a backup source disk if it finds the VM already stopped.

  • StorPoolStorageAdaptor.createPhysicalDisk() now creates and attaches a StorPool volume (previously a no-op returning null), used when restoring a backup provisions a new volume.
  • nasbackup.sh clones the live StorPool volume into a point-in-time volume before reading from it for a cold backup, and cleans it up afterwards (with an EXIT trap as a safety net).
  • Backup size is now reported via an explicit BACKUP_SIZE_TOTAL= marker on stdout instead of being inferred from output position/shape, which broke down once StorPool added a third code shape; take-backup errors are now always returned as a BackupAnswer rather than letting an uncaught exception surface as a plain Answer.
  • Restore reports back the volume path StorPool actually assigned (BackupAnswer.restoredVolumePath) instead of the path CloudStack guessed, since StorPool controls the device path itself.
  • Backup file identifiers are normalized to the qcow2 basename so StorPool's full device path matches what nasbackup.sh expects.

Types of changes

  • Breaking change (fix or feature that would cause existing functionality to change)
  • New feature (non-breaking change which adds functionality)
  • Bug fix (non-breaking change which fixes an issue)
  • Enhancement (improves an existing feature and functionality)
  • Cleanup (Code refactoring and cleanup, that may add test cases)
  • Build/CI
  • Test (unit or integration test code)

Feature/Enhancement Scale or Bug Severity

Feature/Enhancement Scale

  • Major
  • Minor

How Has This Been Tested?

Manual testing and smoke tests with StorPool as a primary storage, KVM hypervisors

Volume pool/path info is now sent to the agent whenever a VM has a
StorPool volume, not just when the VM looks Stopped, since nasbackup.sh
re-checks the VM's actual liveness itself right before acting and needs
the StorPool path to clone a backup source disk if it finds the VM
already stopped.

- StorPoolStorageAdaptor.createPhysicalDisk() now creates and attaches
  a StorPool volume (previously a no-op returning null), used when
  restoring a backup provisions a new volume.
- nasbackup.sh clones the live StorPool volume into a point-in-time
  volume before reading from it for a cold backup, and cleans it up
  afterwards (with an EXIT trap as a safety net).
- Backup size is now reported via an explicit BACKUP_SIZE_TOTAL=<bytes>
  marker on stdout instead of being inferred from output position/shape,
  which broke down once StorPool added a third code shape; take-backup
  errors are now always returned as a BackupAnswer rather than letting
  an uncaught exception surface as a plain Answer.
- Restore reports back the volume path StorPool actually assigned
  (BackupAnswer.restoredVolumePath) instead of the path CloudStack
  guessed, since StorPool controls the device path itself.
- Backup file identifiers are normalized to the qcow2 basename so
  StorPool's full device path matches what nasbackup.sh expects.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Restore attachment compatibility and StorPool volume cleanup failures remain unresolved.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds StorPool support to KVM NAS backup and restore workflows.

Changes:

  • Adds StorPool volume provisioning and restored-path reporting.
  • Adds point-in-time cloning for cold backups.
  • Improves backup size reporting, cleanup, and error handling.
  • Expands integration and provider tests.
File summaries
File Summary
test/integration/plugins/storpool/TestNasBackupStorPool.py StorPool NAS backup/restore tests
test/integration/plugins/storpool/sp_util.py StorPool test helpers
scripts/vm/hypervisor/kvm/nasbackup.sh StorPool cloning and cleanup
plugins/storage/volume/storpool/src/main/java/com/cloud/hypervisor/kvm/storage/StorPoolStorageAdaptor.java StorPool volume provisioning
plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtTakeBackupCommandWrapper.java Backup output and size parsing
plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRestoreBackupCommandWrapper.java StorPool restore handling
plugins/backup/nas/src/test/java/org/apache/cloudstack/backup/NASBackupProviderTest.java Provider restore-path tests
plugins/backup/nas/src/main/java/org/apache/cloudstack/backup/NASBackupProvider.java Backup and restore path propagation
core/src/main/java/org/apache/cloudstack/backup/BackupAnswer.java Restored-volume path response
Review details

Suppressed comments (3)

plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRestoreBackupCommandWrapper.java:362

  • If qemu-img convert fails after this StorPool volume has been created and attached, replaceBlockDeviceWithBackup only returns a failed answer; it never detaches or deletes the newly provisioned volume. Because the restore does not persist a CloudStack volume on this path, every failed restore can leave an attached, orphaned StorPool volume. Track the created disk and clean it up on all subsequent failure paths (or add an explicit rollback around the conversion).
                    disk = volumeStoragePool.createPhysicalDisk(volumeUuid, QemuImg.PhysicalDiskFormat.RAW, Storage.ProvisioningType.THIN, size, null);
                    if (disk == null) {
                        throw new CloudRuntimeException(String.format("Failed to provision a %s volume for restore [%s]", poolType, volumeUuid));
                    }
                    if (Storage.StoragePoolType.StorPool.equals(poolType)) {
                        volumePath = disk.getPath();

plugins/storage/volume/storpool/src/main/java/com/cloud/hypervisor/kvm/storage/StorPoolStorageAdaptor.java:490

  • attachOrDetachVolume() throws CloudRuntimeException when the StorPool attach command fails, so the if (!...) branch is not entered in that failure mode. The newly created volume is therefore left behind even though restore reports failure, leaking an unattached StorPool volume on every attach exception; wrap the attach in cleanup logic that deletes globalId for both a false return and an exception.
        if (!attachOrDetachVolume("attach", "volume", volumePath)) {
            volumeDelete(globalId);
            throw new CloudRuntimeException(String.format("Could not attach newly created StorPool volume %s", volumePath));

scripts/vm/hypervisor/kvm/nasbackup.sh:582

  • Because read_disk=$(sp_create_backup_source_disk ...) executes the function in a command-substitution subshell, the function's append to SP_CLEANUP_VOLUMES is lost when it returns. If qemu-img convert or a later per-disk step fails, the parent EXIT trap therefore has no clone name to release, leaving the attached backup-source StorPool volume orphaned. Return the path through a variable/file without command substitution (or otherwise register the clone in the parent shell) before proceeding.
      if ! read_disk=$(sp_create_backup_source_disk "$disk"); then
        log -ne "Failed to create a StorPool backup source volume for $disk"
        echo "Failed to create a StorPool backup source volume for $disk"
        cleanup
        exit 1
      fi
      sp_clone_name=$(sp_volume_name_from_path "$read_disk")
  • Files reviewed: 9/9 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +318 to 319
if (List.of(Storage.StoragePoolType.RBD, Storage.StoragePoolType.Linstor, Storage.StoragePoolType.StorPool).contains(volumePool.getPoolType())) {
return replaceBlockDeviceWithBackup(storagePoolMgr, volumePool, volumePath, backupPath, timeout, createTargetVolume, size);
Comment thread scripts/vm/hypervisor/kvm/nasbackup.sh Outdated
Comment on lines +595 to +602
if [[ -n "$sp_clone_name" ]]; then
sp_delete_backup_source_disk "$sp_clone_name"
local -a remaining_clones=()
local tracked_clone
for tracked_clone in "${SP_CLEANUP_VOLUMES[@]}"; do
[[ "$tracked_clone" == "$sp_clone_name" ]] || remaining_clones+=("$tracked_clone")
done
SP_CLEANUP_VOLUMES=("${remaining_clones[@]}")
@nvazquez nvazquez added this to the 24.0 milestone Sep 18, 2026
@codecov

codecov Bot commented Sep 18, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 19.87578% with 129 lines in your changes missing coverage. Please review.
✅ Project coverage is 20.21%. Comparing base (5812290) to head (93238b0).
⚠️ Report is 10 commits behind head on main.

Files with missing lines Patch % Lines
...ource/wrapper/LibvirtTakeBackupCommandWrapper.java 0.00% 44 Missing ⚠️
...hypervisor/kvm/storage/StorPoolStorageAdaptor.java 0.00% 39 Missing ⚠️
...ce/wrapper/LibvirtRestoreBackupCommandWrapper.java 30.00% 24 Missing and 4 partials ⚠️
...rg/apache/cloudstack/backup/NASBackupProvider.java 56.25% 9 Missing and 5 partials ⚠️
...ava/org/apache/cloudstack/backup/BackupAnswer.java 33.33% 4 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main   #14203      +/-   ##
============================================
+ Coverage     19.90%   20.21%   +0.31%     
- Complexity    20171    20755     +584     
============================================
  Files          6372     6426      +54     
  Lines        577180   580269    +3089     
  Branches      70693    71051     +358     
============================================
+ Hits         114869   117310    +2441     
- Misses       449755   450242     +487     
- Partials      12556    12717     +161     
Flag Coverage Δ
uitests 3.69% <ø> (-0.03%) ⬇️
unittests 21.51% <19.87%> (+0.34%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

* pool/path info must be sent to the agent even though the VM currently looks Running — see
* the caller in {@link #takeBackup}.
*/
private boolean hasStorPoolVolume(List<VolumeVO> volumes) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would suggest to pass pooltype as a parameter of the method

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for example
hasVolumeWithStorageType(volumes, StorPool)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thank you @weizhouapache, for the clarification! I already did that as you suggested, but I didn't have time to test the changes and push them.

@weizhouapache

weizhouapache commented Sep 18, 2026 •

Copy link
Copy Markdown
Member

this needs testing on NFS, Ceph,Linstor and Storpool to ensure there is no regression

cc @abh1sar @rp- @slavkap
I will run some tests on ceph

@rp-

rp- commented Sep 29, 2026

Copy link
Copy Markdown
Contributor

Just tested this against #14194

And nothing breaks here against Linstor (didn't test anything else).

just that whatever patch lands first, the final merge will need this:

diff --git a/scripts/vm/hypervisor/kvm/nasbackup.sh b/scripts/vm/hypervisor/kvm/nasbackup.sh
index 0d166dbc79..3658ba7972 100755
--- a/scripts/vm/hypervisor/kvm/nasbackup.sh
+++ b/scripts/vm/hypervisor/kvm/nasbackup.sh
@@ -345,11 +345,10 @@ backup_running_vm_pull() {
   rm -rf "$scratch_dir"
   sync
 
-  # Statistics -- the size on the last stdout line is parsed by the Java wrapper.
-  du -sb $dest | cut -f1
-
-  umount $mount_point
-  rmdir $mount_point
+  backup_size=$(du -sb "$dest" 2>>"$logFile" | cut -f1) || { log -ne "WARNING: du failed for $dest, reporting size as 0"; backup_size=0; }
+  timeout "$UNMOUNT_TIMEOUT" umount "$mount_point" 2>>"$logFile" || { log "WARNING: umount of $mount_point failed or timed out"; true; }
+  rmdir "$mount_point" 2>>"$logFile" || { log "WARNING: rmdir of $mount_point failed"; true; }
+  echo "BACKUP_SIZE_TOTAL=$backup_size"
 }
 
 # StorPool volume name (with the "~globalId" form the storpool/storpool_req CLIs expect) from

if (Storage.StoragePoolType.Linstor.equals(poolType)) {
storagePoolMgr.connectPhysicalDisk(poolType, volumePool.getUuid(), volumeUuid, null);
} else if (Storage.StoragePoolType.StorPool.equals(poolType)) {
storagePoolMgr.connectPhysicalDisk(poolType, volumePool.getUuid(), volumePath, null);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if-else can be optimized with switch as you did in below changes

Copilot AI lite review requested due to automatic review settings October 12, 2026 16:22

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on lines +389 to +400
private boolean hasVolumeOnPoolType(List<VolumeVO> volumes, Storage.StoragePoolType poolType) {
if (volumes == null) {
return false;
}
for (VolumeVO volume : volumes) {
StoragePoolVO pool = primaryDataStoreDao.findById(volume.getPoolId());
if (pool != null && poolType.equals(pool.getPoolType())) {
return true;
}
}
return false;
}
if (passphrase != null) {
throw new CloudRuntimeException("Encrypted StorPool volumes are not supported");
}

Comment on lines +495 to +499
KVMPhysicalDisk disk = new KVMPhysicalDisk(volumePath, name, pool);
disk.setFormat(PhysicalDiskFormat.RAW);
disk.setSize(size);
disk.setVirtualSize(size);
return disk;
Comment on lines +203 to +217
def _get_or_create_disk_offering(cls, tag, displaytext):
disk_offerings = list_disk_offering(cls.apiclient, name=tag)
if disk_offerings is None:
disk_offering = DiskOffering.create(
cls.apiclient,
{
"name": tag,
"displaytext": displaytext,
"disksize": 5,
"storagetype": "shared",
"tags": tag,
})
cls._cleanup.append(disk_offering)
return disk_offering
return disk_offerings[0]
Comment on lines +679 to +695
def _get_or_create_nfs_service_offering(cls):
service_offerings = list_service_offering(cls.apiclient, name=cls.nfs_tag)
if service_offerings is None:
service_offering = ServiceOffering.create(
cls.apiclient,
{
"name": cls.nfs_tag,
"displaytext": "NFS NAS offering (%s)" % cls.nfs_tag,
"cpunumber": 1,
"cpuspeed": 500,
"memory": 512,
"storagetype": "shared",
"tags": cls.nfs_tag,
})
cls._cleanup.append(service_offering)
return service_offering
return service_offerings[0]
DiskOfferingVO diskOffering = mock(DiskOfferingVO.class);
Mockito.when(diskOffering.getId()).thenReturn(diskOfferingId);
Mockito.when(diskOffering.getProvisioningType()).thenReturn(Storage.ProvisioningType.THIN);
Mockito.when(diskOfferingDao.findByUuid(Mockito.anyString())).thenReturn(diskOffering);
logging.debug(spvolume.iops)
logging.debug(volume.maxiops)
if spvolume.iops != volume.maxiops:
raise Exception("Storpool volume size is not the same as CloudStack db size")
Copilot AI lite review requested due to automatic review settings October 13, 2026 11:32

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Copilot review overview

16 open findings

🧠 Review effort: Lite

cleanup
exit 1
fi
sp_clone_name=$(sp_volume_name_from_path "$read_disk")
Comment on lines +389 to +400
private boolean hasVolumeOnPoolType(List<VolumeVO> volumes, Storage.StoragePoolType poolType) {
if (volumes == null) {
return false;
}
for (VolumeVO volume : volumes) {
StoragePoolVO pool = primaryDataStoreDao.findById(volume.getPoolId());
if (pool != null && poolType.equals(pool.getPoolType())) {
return true;
}
}
return false;
}
Comment on lines +114 to +124
// The script self-heals to a full backup when an incremental can't proceed (e.g. the
// parent checkpoint can't be re-registered) and signals it with INCREMENTAL_FALLBACK
// on stdout. Detect it and the reported size from the raw output, then strip both
// marker lines before using stdout as the answer's human-facing details.
String rawStdout = result.second();
boolean incrementalFallback = StringUtils.contains(rawStdout, INCREMENTAL_FALLBACK_MARKER);
long backupSize = extractBackupSize(rawStdout);
String stdout = stripMarkerLines(rawStdout).trim();

BackupAnswer answer = new BackupAnswer(command, true, stdout);
answer.setSize(backupSize);
Comment on lines +561 to +566
local -a disk_arr=()
IFS=',' read -ra disk_arr <<< "$DISK_PATHS"

name="root"
for disk in $DISK_PATHS; do
for disk in "${disk_arr[@]}"; do
local read_disk="$disk"
logging.debug(spvolume.iops)
logging.debug(volume.maxiops)
if spvolume.iops != volume.maxiops:
raise Exception("Storpool volume size is not the same as CloudStack db size")
@classmethod
def verify_storpool_volume(cls, spapi, volume, check_size=True):
"""Assert the CloudStack volume exists on StorPool; optionally compare size."""
if not volume.path or not volume.path.startswith("/dev/storpool"):
Comment on lines +959 to +964
storage_pools = zone.primaryStorages
nfs_pools = []
for storage in storage_pools:
url = storage['url'] or ""
if not storage['provider'] and str(url).lower().startswith("nfs://"):
nfs_pools.append(storage)
log -ne "StorPool: created backup source volume $clone_name ($clone_path), attaching"
if ! sp_attach_detach_volume "attach" "$clone_name"; then
log -ne "StorPool: failed to attach backup source volume $clone_name, deleting it"
storpool_req -P -M VolumeDelete "$clone_name" >>"$logFile" 2>&1 || true

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if the attach works but the device doesnt show up in time, wont this delete fail on a still attached clone? it isnt in the cleanup list yet so it stays behind

if (!replaceVolumeWithBackup(storagePoolMgr, volumePool, volumePath, bkpPath, timeout, true, size)) {
Pair<Boolean, String> restoreResult = replaceVolumeWithBackup(storagePoolMgr, volumePool, volumePath, bkpPath, timeout, true, size);
if (!restoreResult.first()) {
throw new CloudRuntimeException(String.format("Unable to restore contents from the backup volume [%s].", volumeUuid));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if the copy fails here, does anything delete the new storpool volume? cloudstack never gets its id back so it looks like it stays behind

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants