This is an automated email from the ASF dual-hosted git repository.
weizhouapache pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/cloudstack.git
The following commit(s) were added to refs/heads/main by this push:
new 42322a59c7d feat(backup): incremental NAS backup support for KVM
(#13074)
42322a59c7d is described below
commit 42322a59c7d68247db03a538582e4c9ea1b259d5
Author: James Peru Mmbono <[email protected]>
AuthorDate: Wed Jul 8 15:06:41 2026 +0300
feat(backup): incremental NAS backup support for KVM (#13074)
Implements incremental backup support for the NAS backup provider on KVM,
using QEMU dirty bitmaps and libvirt's backup-begin API. RFC:
apache/cloudstack#12899.
For large VMs this reduces daily backup storage 80–95% and shortens backup
windows from hours to minutes (e.g. a 500 GB VM with moderate writes goes from
~500 GB/day to ~5–15 GB/day after the initial full backup).
Signed-off-by: James Peru <[email protected]>
Co-authored-by: jmsperu <[email protected]>
Co-authored-by: Abhisar Sinha <[email protected]>
---
.../java/org/apache/cloudstack/backup/Backup.java | 6 +-
.../apache/cloudstack/backup/BackupProvider.java | 12 +
.../org/apache/cloudstack/backup/BackupAnswer.java | 23 +
.../cloudstack/backup/TakeBackupCommand.java | 43 ++
.../org/apache/cloudstack/backup/BackupVO.java | 8 +-
.../cloudstack/backup/NASBackupChainKeys.java | 62 ++
.../cloudstack/backup/NASBackupProvider.java | 687 ++++++++++++++++++++-
.../cloudstack/backup/NASBackupProviderTest.java | 452 ++++++++++++++
.../LibvirtRestoreBackupCommandWrapper.java | 26 +
.../wrapper/LibvirtTakeBackupCommandWrapper.java | 152 ++++-
.../LibvirtRestoreBackupCommandWrapperTest.java | 2 +
.../java/com/cloud/hypervisor/guru/VMwareGuru.java | 2 +-
scripts/vm/hypervisor/kvm/nasbackup.sh | 252 +++++++-
.../main/java/com/cloud/hypervisor/KVMGuru.java | 2 +-
.../cloudstack/backup/BackupManagerImpl.java | 15 +-
test/integration/smoke/test_backup_recovery_nas.py | 320 ++++++++++
16 files changed, 2019 insertions(+), 45 deletions(-)
diff --git a/api/src/main/java/org/apache/cloudstack/backup/Backup.java
b/api/src/main/java/org/apache/cloudstack/backup/Backup.java
index 2d68f18b953..865d657a7a4 100644
--- a/api/src/main/java/org/apache/cloudstack/backup/Backup.java
+++ b/api/src/main/java/org/apache/cloudstack/backup/Backup.java
@@ -39,7 +39,11 @@ public interface Backup extends ControlledEntity,
InternalIdentity, Identity {
Long getHostId();
enum Status {
- Allocated, Queued, BackingUp, ReadyForImageTransfer,
FinalizingImageTransfer, BackedUp, Error, Failed, Restoring, Removed, Expunged
+ Allocated, Queued, BackingUp, ReadyForImageTransfer,
FinalizingImageTransfer, BackedUp, Error, Failed, Restoring, Removed, Expunged,
+ // Hidden: a chain backup kept as a tombstone after the user deleted
it while it still has
+ // live descendants (incremental chains). Excluded from listBackups
and from all backup
+ // operations (which require BackedUp); swept from the DB once its
last descendant is gone.
+ Hidden
}
class Metric {
diff --git a/api/src/main/java/org/apache/cloudstack/backup/BackupProvider.java
b/api/src/main/java/org/apache/cloudstack/backup/BackupProvider.java
index 23b8092425d..4ae9148113a 100644
--- a/api/src/main/java/org/apache/cloudstack/backup/BackupProvider.java
+++ b/api/src/main/java/org/apache/cloudstack/backup/BackupProvider.java
@@ -87,6 +87,18 @@ public interface BackupProvider {
*/
boolean deleteBackup(Backup backup, boolean forced);
+ /**
+ * Whether {@link #deleteBackup(Backup, boolean)} owns DB-row removal and
resource-count /
+ * usage accounting for every backup it physically removes. Providers that
manage incremental
+ * chains (e.g. NAS) delete several backups per call — the leaf plus swept
delete-pending
+ * ancestors — and decrement once per removed backup themselves, so the
manager must NOT
+ * decrement or remove the row again. Defaults to {@code false}: the
manager does the
+ * single-backup accounting (the historical behaviour for non-chain
providers).
+ */
+ default boolean handlesChainDeleteResourceAccounting() {
+ return false;
+ }
+
Pair<Boolean, String> restoreBackupToVM(VirtualMachine vm, Backup backup,
String hostIp, String dataStoreUuid);
/**
diff --git a/core/src/main/java/org/apache/cloudstack/backup/BackupAnswer.java
b/core/src/main/java/org/apache/cloudstack/backup/BackupAnswer.java
index ffc67b628a7..abe78ee5553 100644
--- a/core/src/main/java/org/apache/cloudstack/backup/BackupAnswer.java
+++ b/core/src/main/java/org/apache/cloudstack/backup/BackupAnswer.java
@@ -29,6 +29,12 @@ public class BackupAnswer extends Answer {
private Long virtualSize;
private Map<String, String> volumes;
Boolean needsCleanup;
+ // Set by the NAS backup provider after a checkpoint/bitmap was created
during this backup.
+ // The provider persists it in backup_details under
NASBackupChainKeys.BITMAP_NAME.
+ private String bitmapCreated;
+ // Set when an incremental was requested but the agent had to fall back to
a full
+ // (e.g. VM was stopped). Provider should record this backup as type=full.
+ private Boolean incrementalFallback;
public BackupAnswer(final Command command, final boolean success, final
String details) {
super(command, success, details);
@@ -68,4 +74,21 @@ public class BackupAnswer extends Answer {
public void setNeedsCleanup(Boolean needsCleanup) {
this.needsCleanup = needsCleanup;
}
+
+ public String getBitmapCreated() {
+ return bitmapCreated;
+ }
+
+ public void setBitmapCreated(String bitmapCreated) {
+ this.bitmapCreated = bitmapCreated;
+ }
+
+ public Boolean getIncrementalFallback() {
+ return incrementalFallback != null && incrementalFallback;
+ }
+
+ public void setIncrementalFallback(Boolean incrementalFallback) {
+ this.incrementalFallback = incrementalFallback;
+ }
+
}
diff --git
a/core/src/main/java/org/apache/cloudstack/backup/TakeBackupCommand.java
b/core/src/main/java/org/apache/cloudstack/backup/TakeBackupCommand.java
index 5402b6b2476..34f8d7b8bcd 100644
--- a/core/src/main/java/org/apache/cloudstack/backup/TakeBackupCommand.java
+++ b/core/src/main/java/org/apache/cloudstack/backup/TakeBackupCommand.java
@@ -36,6 +36,17 @@ public class TakeBackupCommand extends Command {
@LogLevel(LogLevel.Log4jLevel.Off)
private String mountOptions;
+ // Incremental backup fields (NAS provider; null/empty for legacy
full-only callers).
+ private String mode; // "full" or "incremental"; null => legacy
behaviour (script default)
+ private String bitmapNew; // Checkpoint/bitmap name to create with
this backup (timestamp-based)
+ private String bitmapParent; // Incremental: parent bitmap to read
changes since
+
+ // Per-volume parent backup file paths (one per VM volume, ordered by
deviceId — same
+ // order as volumePaths). The script rebases each new qcow2 onto the
matching parent.
+ // Backup file UUIDs differ across volumes, so a single parentPath would
have rebased
+ // every data disk onto the root file. New callers MUST populate
parentPaths.
+ private List<String> parentPaths;
+
public TakeBackupCommand(String vmName, String backupPath) {
super();
this.vmName = vmName;
@@ -106,6 +117,38 @@ public class TakeBackupCommand extends Command {
this.quiesce = quiesce;
}
+ public String getMode() {
+ return mode;
+ }
+
+ public void setMode(String mode) {
+ this.mode = mode;
+ }
+
+ public String getBitmapNew() {
+ return bitmapNew;
+ }
+
+ public void setBitmapNew(String bitmapNew) {
+ this.bitmapNew = bitmapNew;
+ }
+
+ public String getBitmapParent() {
+ return bitmapParent;
+ }
+
+ public void setBitmapParent(String bitmapParent) {
+ this.bitmapParent = bitmapParent;
+ }
+
+ public List<String> getParentPaths() {
+ return parentPaths;
+ }
+
+ public void setParentPaths(List<String> parentPaths) {
+ this.parentPaths = parentPaths;
+ }
+
@Override
public boolean executeInSequence() {
return true;
diff --git
a/engine/schema/src/main/java/org/apache/cloudstack/backup/BackupVO.java
b/engine/schema/src/main/java/org/apache/cloudstack/backup/BackupVO.java
index d589f9e6bef..7754c2440c0 100644
--- a/engine/schema/src/main/java/org/apache/cloudstack/backup/BackupVO.java
+++ b/engine/schema/src/main/java/org/apache/cloudstack/backup/BackupVO.java
@@ -66,7 +66,7 @@ public class BackupVO implements Backup {
private String externalId;
@Column(name = "type")
- private String backupType;
+ private String type;
@Column(name = "date")
@Temporal(value = TemporalType.DATE)
@@ -125,7 +125,7 @@ public class BackupVO implements Backup {
@Override
public String toString() {
return String.format("Backup %s",
ReflectionToStringBuilderUtils.reflectOnlySelectedFields(
- this, "id", "uuid", "vmId", "backupType", "externalId"));
+ this, "id", "uuid", "vmId", "type", "externalId"));
}
@Override
@@ -157,11 +157,11 @@ public class BackupVO implements Backup {
}
public String getType() {
- return backupType;
+ return type;
}
public void setType(String type) {
- this.backupType = type;
+ this.type = type;
}
@Override
diff --git
a/plugins/backup/nas/src/main/java/org/apache/cloudstack/backup/NASBackupChainKeys.java
b/plugins/backup/nas/src/main/java/org/apache/cloudstack/backup/NASBackupChainKeys.java
new file mode 100644
index 00000000000..511f0ccb711
--- /dev/null
+++
b/plugins/backup/nas/src/main/java/org/apache/cloudstack/backup/NASBackupChainKeys.java
@@ -0,0 +1,62 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+package org.apache.cloudstack.backup;
+
+/**
+ * Keys used by the NAS backup provider when storing incremental-chain metadata
+ * in the existing {@code backup_details} key/value table. Stored here (not on
+ * the {@code backups} table) so other providers do not need a schema change to
+ * support their own incremental implementations.
+ */
+public final class NASBackupChainKeys {
+
+ /** UUID of the parent backup (full or previous incremental). Empty for
full backups. */
+ public static final String PARENT_BACKUP_ID = "nas.parent_backup_id";
+
+ /** QEMU dirty-bitmap name created by this backup, used as the {@code
<incremental>} reference for the next one. */
+ public static final String BITMAP_NAME = "nas.bitmap_name";
+
+ /** Identifier shared by every backup in the same chain (the full anchors
a chain; its incrementals inherit the id). */
+ public static final String CHAIN_ID = "nas.chain_id";
+
+ /** Position within the chain: 0 for the full, 1 for the first
incremental, and so on. */
+ public static final String CHAIN_POSITION = "nas.chain_position";
+
+ /**
+ * In-memory chain-mode sentinels used by {@code ChainDecision.mode}. The
persisted
+ * full-vs-incremental backup type lives on the {@code backup.type} column
(set in
+ * {@code takeBackup}) — single source of truth. Not duplicated into
backup_details.
+ */
+ public static final String TYPE_FULL = "full";
+ public static final String TYPE_INCREMENTAL = "incremental";
+ // Feature disabled: behave exactly like the pre-incremental full-only
backup — no QEMU
+ // bitmap/checkpoint is created and no chain metadata is persisted.
Matches nasbackup.sh's
+ // "legacy-full" mode token (which sets make_checkpoint=0).
+ public static final String TYPE_LEGACY_FULL = "legacy-full";
+
+ /**
+ * VM-scoped detail (stored in {@code vm_instance_details}) holding the
QEMU dirty-bitmap
+ * name that currently exists on the running VM and is therefore the only
valid parent
+ * for the next incremental backup. Written by {@link #BITMAP_NAME} on
each successful
+ * backup; cleared on restore (the restored disk image has no bitmap, so
the next backup
+ * must be a fresh full). When the VM has no detail, {@code decideChain}
forces full.
+ */
+ public static final String VM_ACTIVE_CHECKPOINT_ID =
"nas.active_checkpoint_id";
+
+ private NASBackupChainKeys() {
+ }
+}
diff --git
a/plugins/backup/nas/src/main/java/org/apache/cloudstack/backup/NASBackupProvider.java
b/plugins/backup/nas/src/main/java/org/apache/cloudstack/backup/NASBackupProvider.java
index a7121f149e3..fe56fbf7c1a 100644
---
a/plugins/backup/nas/src/main/java/org/apache/cloudstack/backup/NASBackupProvider.java
+++
b/plugins/backup/nas/src/main/java/org/apache/cloudstack/backup/NASBackupProvider.java
@@ -19,6 +19,7 @@ package org.apache.cloudstack.backup;
import com.cloud.agent.AgentManager;
import com.cloud.exception.AgentUnavailableException;
import com.cloud.exception.OperationTimedoutException;
+import com.cloud.configuration.Resource;
import com.cloud.host.Host;
import com.cloud.host.HostVO;
import com.cloud.host.Status;
@@ -38,8 +39,11 @@ import com.cloud.storage.dao.VolumeDao;
import com.cloud.utils.Pair;
import com.cloud.utils.component.AdapterBase;
import com.cloud.utils.exception.CloudRuntimeException;
+import com.cloud.user.ResourceLimitService;
+import com.cloud.vm.VMInstanceDetailVO;
import com.cloud.vm.VirtualMachine;
import com.cloud.vm.dao.VMInstanceDao;
+import com.cloud.vm.dao.VMInstanceDetailsDao;
import com.cloud.vm.snapshot.VMSnapshot;
import com.cloud.vm.snapshot.VMSnapshotDetailsVO;
import com.cloud.vm.snapshot.VMSnapshotVO;
@@ -48,6 +52,7 @@ import com.cloud.vm.snapshot.dao.VMSnapshotDetailsDao;
import org.apache.cloudstack.backup.dao.BackupDao;
+import org.apache.cloudstack.backup.dao.BackupDetailsDao;
import org.apache.cloudstack.backup.dao.BackupRepositoryDao;
import org.apache.cloudstack.engine.subsystem.api.storage.DataStore;
import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreManager;
@@ -85,6 +90,29 @@ public class NASBackupProvider extends AdapterBase
implements BackupProvider, Co
true,
BackupFrameworkEnabled.key());
+ ConfigKey<Integer> NASBackupFullEvery = new ConfigKey<>("Advanced",
Integer.class,
+ "nas.backup.full.every",
+ "10",
+ "Take a full NAS backup every Nth backup; remaining backups in
between are incremental. " +
+ "Counts backups, not days, so it works for hourly, daily,
and ad-hoc schedules. " +
+ "Set to 1 to disable incrementals (every backup is full).",
+ true,
+ ConfigKey.Scope.Zone,
+ BackupFrameworkEnabled.key());
+
+ ConfigKey<Boolean> NASBackupIncrementalEnabled = new
ConfigKey<>("Advanced", Boolean.class,
+ "nas.backup.incremental.enabled",
+ "false",
+ "Master switch for NAS incremental backups. Defaults to false so
existing zones keep the " +
+ "legacy full-only behavior on upgrade; opt in per-zone
when ready to use chains. " +
+ "When false, every NAS backup is taken as a full
regardless of nas.backup.full.every. " +
+ "Toggling this is safe at any time: switching off forces
the next backup to be a fresh " +
+ "full anchor (existing chains stay restorable), switching
back on resumes incrementals " +
+ "on the next full + incremental cycle.",
+ true,
+ ConfigKey.Scope.Zone,
+ BackupFrameworkEnabled.key());
+
@Inject
private BackupDao backupDao;
@@ -106,6 +134,9 @@ public class NASBackupProvider extends AdapterBase
implements BackupProvider, Co
@Inject
private VMInstanceDao vmInstanceDao;
+ @Inject
+ private VMInstanceDetailsDao vmInstanceDetailsDao;
+
@Inject
private PrimaryDataStoreDao primaryDataStoreDao;
@@ -115,6 +146,9 @@ public class NASBackupProvider extends AdapterBase
implements BackupProvider, Co
@Inject
private AgentManager agentManager;
+ @Inject
+ private ResourceLimitService resourceLimitMgr;
+
@Inject
private VMSnapshotDao vmSnapshotDao;
@@ -130,6 +164,9 @@ public class NASBackupProvider extends AdapterBase
implements BackupProvider, Co
@Inject
private DiskOfferingDao diskOfferingDao;
+ @Inject
+ private BackupDetailsDao backupDetailsDao;
+
private Long getClusterIdFromRootVolume(VirtualMachine vm) {
VolumeVO rootVolume = volumeDao.getInstanceRootVolume(vm.getId());
StoragePoolVO rootDiskPool =
primaryDataStoreDao.findById(rootVolume.getPoolId());
@@ -168,6 +205,330 @@ public class NASBackupProvider extends AdapterBase
implements BackupProvider, Co
return
resourceManager.findOneRandomRunningHostByHypervisor(Hypervisor.HypervisorType.KVM,
vm.getDataCenterId());
}
+ /**
+ * Returned by {@link #decideChain(VirtualMachine)} to describe the next
backup's place in
+ * the chain: full vs incremental, the bitmap name to create, and (for
incrementals) the
+ * parent bitmap and parent file path.
+ */
+ static final class ChainDecision {
+ final String mode; // "full" or "incremental"
+ final String bitmapNew;
+ final String bitmapParent; // null for full
+ // Per-volume parent backup file paths, one per current VM volume in
deviceId order.
+ // null/empty for full. Each volume needs its own parent file because
backup files
+ // are named after each volume's own UUID (root.<uuid>.qcow2 /
datadisk.<uuid>.qcow2).
+ final List<String> parentPaths;
+ final String chainId; // chain identifier this backup belongs
to
+ final int chainPosition; // 0 for full, N for the Nth incremental
in the chain
+
+ private ChainDecision(String mode, String bitmapNew, String
bitmapParent, List<String> parentPaths,
+ String chainId, int chainPosition) {
+ this.mode = mode;
+ this.bitmapNew = bitmapNew;
+ this.bitmapParent = bitmapParent;
+ this.parentPaths = parentPaths;
+ this.chainId = chainId;
+ this.chainPosition = chainPosition;
+ }
+
+ static ChainDecision fullStart(String bitmapName) {
+ return new ChainDecision(NASBackupChainKeys.TYPE_FULL, bitmapName,
null, null,
+ UUID.randomUUID().toString(), 0);
+ }
+
+ /**
+ * Decision used when the incremental feature is disabled: a plain
full backup that
+ * creates no bitmap and carries no chain identity, so nothing
chain/checkpoint-related
+ * is sent to the agent or persisted. Keeps the feature-off path
byte-for-byte legacy.
+ */
+ static ChainDecision legacyFull() {
+ return new ChainDecision(NASBackupChainKeys.TYPE_LEGACY_FULL,
null, null, null, null, 0);
+ }
+
+ static ChainDecision incremental(String bitmapNew, String
bitmapParent, List<String> parentPaths,
+ String chainId, int chainPosition) {
+ return new ChainDecision(NASBackupChainKeys.TYPE_INCREMENTAL,
bitmapNew, bitmapParent,
+ parentPaths, chainId, chainPosition);
+ }
+
+ boolean isIncremental() {
+ return NASBackupChainKeys.TYPE_INCREMENTAL.equals(mode);
+ }
+
+ boolean isLegacyFull() {
+ return NASBackupChainKeys.TYPE_LEGACY_FULL.equals(mode);
+ }
+ }
+
+ /**
+ * Decides whether the next backup for {@code vm} should be a fresh full
or an incremental
+ * appended to the existing chain. Stopped VMs are always full (libvirt
{@code backup-begin}
+ * requires a running QEMU process). The {@code nas.backup.full.every}
ConfigKey controls
+ * how many backups (full + incrementals) form one chain before a new full
is forced.
+ *
+ * <p>The decision is anchored on the VM's {@code
nas.active_checkpoint_id} detail, which
+ * records the bitmap that currently exists on the running QEMU. After a
restore that
+ * detail is cleared, so the next backup is automatically full — even
though there may be
+ * a more recent "last backup taken" row in the database. The decision
deliberately avoids
+ * relying on "last backup taken", because that row is misleading after a
restore.</p>
+ */
+ protected ChainDecision decideChain(VirtualMachine vm) {
+ // Master switch — when the operator disables incrementals at the zone
level the backup
+ // behaves exactly like the pre-incremental full-only path: no bitmap
is generated and no
+ // chain/checkpoint metadata is created, sent to the agent, or
persisted (legacy-full).
+ Boolean incrementalEnabled =
NASBackupIncrementalEnabled.valueIn(vm.getDataCenterId());
+ if (incrementalEnabled == null || !incrementalEnabled) {
+ return ChainDecision.legacyFull();
+ }
+
+ // Incremental backups rely on QEMU dirty bitmaps / libvirt
checkpoints, which only exist
+ // on file-based qcow2 storage. Storage such as Ceph-RBD and Linstor
cannot carry per-disk
+ // checkpoints, so a VM with any volume on such a pool must stay on
the full-only (legacy)
+ // path — otherwise an incremental attempt would fail or regress those
storages.
+ if (!allVolumesOnCheckpointCapableStorage(vm)) {
+ return ChainDecision.legacyFull();
+ }
+
+ final String newBitmap = "backup-" + System.currentTimeMillis() /
1000L;
+
+ // Stopped VMs cannot do incrementals — script will also fall back,
but we make the
+ // decision here so we register the right type up-front.
+ if (VirtualMachine.State.Stopped.equals(vm.getState())) {
+ return ChainDecision.fullStart(newBitmap);
+ }
+
+ Integer fullEvery = NASBackupFullEvery.valueIn(vm.getDataCenterId());
+ if (fullEvery == null || fullEvery <= 1) {
+ // Disabled or every-backup-is-full mode.
+ return ChainDecision.fullStart(newBitmap);
+ }
+
+ // 1. If the VM has no active_checkpoint_id, there is no bitmap on the
host to use as
+ // a parent. This is the case after restore (we clear it), after VM
was just assigned
+ // to the offering, or on the very first backup.
+ String activeCheckpoint = readVmActiveCheckpoint(vm.getId());
+ if (activeCheckpoint == null) {
+ return ChainDecision.fullStart(newBitmap);
+ }
+
+ // 2. The most-recent BackedUp backup is the only safe parent — after
restore the
+ // next backup is always a fresh full, so anything older has a
rotated-out bitmap.
+ // If the latest backup's bitmap doesn't match the VM's
active_checkpoint_id, the
+ // chain is broken: force a full.
+ Backup parent = findLatestBackedUpBackup(vm.getId());
+ if (parent == null || !activeCheckpoint.equals(readDetail(parent,
NASBackupChainKeys.BITMAP_NAME))) {
+ LOG.debug("VM {} latest backup does not match
active_checkpoint_id={} — forcing full",
+ vm.getInstanceName(), activeCheckpoint);
+ return ChainDecision.fullStart(newBitmap);
+ }
+
+ String parentChainId = readDetail(parent, NASBackupChainKeys.CHAIN_ID);
+ int parentChainPosition = chainPosition(parent);
+ if (parentChainId == null || parentChainPosition == Integer.MAX_VALUE)
{
+ return ChainDecision.fullStart(newBitmap);
+ }
+
+ // Force a fresh full when the chain has reached the configured length.
+ if (parentChainPosition + 1 >= fullEvery) {
+ return ChainDecision.fullStart(newBitmap);
+ }
+
+ // The script needs the parent backup's on-NAS file path PER VOLUME so
it can rebase
+ // each new qcow2 onto the matching parent. The paths are stored
relative to the NAS
+ // mount root — the script resolves them inside its mount session.
When alignment
+ // fails (volume count changed, etc.) compose returns null and we fall
back to full
+ // so we don't risk corrupting the chain.
+ List<String> parentPaths = composeParentBackupPaths(parent,
vm.getId());
+ if (parentPaths == null) {
+ LOG.debug("VM {} parent backup {} volume layout no longer matches
current VM — forcing full",
+ vm.getInstanceName(), parent.getUuid());
+ return ChainDecision.fullStart(newBitmap);
+ }
+ return ChainDecision.incremental(newBitmap, activeCheckpoint,
parentPaths,
+ parentChainId, parentChainPosition + 1);
+ }
+
+ /**
+ * Incremental backups require QEMU dirty bitmaps / libvirt checkpoints,
which are only
+ * possible on file-based qcow2 storage. Returns {@code true} only when
EVERY volume of the
+ * VM sits on HOST-scope local, {@code SharedMountPoint}, or {@code
NetworkFilesystem} (NFS)
+ * storage. Ceph-RBD, Linstor, and any other pool that cannot carry a
per-disk checkpoint
+ * make this return {@code false} so the caller falls back to the legacy
full-only path. A
+ * volume whose pool can no longer be resolved is treated as incapable
(safe default).
+ */
+ protected boolean allVolumesOnCheckpointCapableStorage(VirtualMachine vm) {
+ List<VolumeVO> volumes = volumeDao.findByInstance(vm.getId());
+ if (volumes == null) {
+ return true;
+ }
+ for (VolumeVO volume : volumes) {
+ StoragePoolVO pool =
primaryDataStoreDao.findById(volume.getPoolId());
+ if (pool == null) {
+ LOG.debug("VM {} volume {} has no resolvable storage pool —
forcing legacy full",
+ vm.getInstanceName(), volume.getUuid());
+ return false;
+ }
+ boolean checkpointCapable = ScopeType.HOST.equals(pool.getScope())
+ ||
Storage.StoragePoolType.SharedMountPoint.equals(pool.getPoolType())
+ ||
Storage.StoragePoolType.NetworkFilesystem.equals(pool.getPoolType());
+ if (!checkpointCapable) {
+ LOG.debug("VM {} volume {} is on {} (scope {}) which cannot
carry checkpoints — forcing legacy full",
+ vm.getInstanceName(), volume.getUuid(),
pool.getPoolType(), pool.getScope());
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Read the {@code nas.active_checkpoint_id} VM detail. Returns {@code
null} when no detail
+ * exists (post-restore, first backup, or after explicit reset).
+ */
+ private String readVmActiveCheckpoint(long vmId) {
+ VMInstanceDetailVO d = vmInstanceDetailsDao.findDetail(vmId,
NASBackupChainKeys.VM_ACTIVE_CHECKPOINT_ID);
+ if (d == null) {
+ return null;
+ }
+ String v = d.getValue();
+ return (v == null || v.isEmpty()) ? null : v;
+ }
+
+ /**
+ * Locate the most-recent {@code BackedUp} backup for {@code vmId}. The
chain invariant
+ * guarantees the latest backup is the only valid incremental parent —
after restore the
+ * next backup is always a fresh full, and {@link #decideChain} checks the
bitmap matches.
+ */
+ private Backup findLatestBackedUpBackup(long vmId) {
+ List<Backup> history = backupDao.listByVmId(null, vmId);
+ if (history == null || history.isEmpty()) {
+ return null;
+ }
+ return history.stream()
+ .filter(b -> Backup.Status.BackedUp.equals(b.getStatus()))
+ .max(Comparator.comparing(Backup::getDate))
+ .orElse(null);
+ }
+
+ private String readDetail(Backup backup, String key) {
+ BackupDetailVO d = backupDetailsDao.findDetail(backup.getId(), key);
+ return d == null ? null : d.getValue();
+ }
+
+ /**
+ * Compose the on-NAS path of EVERY parent backup file (one per VM volume)
in the same
+ * order the script will iterate the current VM's disks (deviceId asc).
Relative to the
+ * NAS mount, matches the layout written by {@code nasbackup.sh}:
+ * first disk -> {@code <backupPath>/root.<volUuid>.qcow2}
+ * others -> {@code <backupPath>/datadisk.<volUuid>.qcow2}
+ *
+ * Returns {@code null} if the parent's stored volume count doesn't match
the current VM's
+ * volume count. Volume attach/detach is blocked while a VM is assigned to
a backup offering;
+ * if the offering was removed and re-assigned the active checkpoint is
cleared in
+ * {@link #removeVMFromBackupOffering}, so this method doesn't need to
revalidate volume
+ * identities — a count mismatch is the only way to reach this branch with
a non-null
+ * active_checkpoint_id.
+ */
+ private List<String> composeParentBackupPaths(Backup parent, long vmId) {
+ // backupPath is stored as externalId by createBackupObject — e.g.
+ // "i-2-1234-VM/2026.04.27.13.45.00".
+ String dir = parent.getExternalId();
+ if (dir == null || dir.isEmpty()) {
+ return null;
+ }
+
+ List<Backup.VolumeInfo> parentVols = parent.getBackedUpVolumes();
+ if (parentVols == null || parentVols.isEmpty()) {
+ return null;
+ }
+
+ List<VolumeVO> currentVols = volumeDao.findByInstance(vmId);
+ if (currentVols == null || currentVols.size() != parentVols.size()) {
+ return null;
+ }
+
+ // parentVols is in deviceId order at the time the parent was taken.
The script names the
+ // per-disk files from the volume PATH basename (root.<path>.qcow2 /
datadisk.<path>.qcow2,
+ // see nasbackup.sh: volUuid="${fullpath##*/}"). Use getPath(), NOT
getUuid(): after a
+ // volume migration the uuid and the on-disk path diverge, and the
backup file is named by
+ // path — a uuid-based parent path then fails to resolve for the
incremental (test 13).
+ List<String> paths = new ArrayList<>(parentVols.size());
+ for (int i = 0; i < parentVols.size(); i++) {
+ String volPath = parentVols.get(i).getPath();
+ String prefix = (i == 0) ? "root" : "datadisk";
+ paths.add(dir + "/" + prefix + "." + volPath + ".qcow2");
+ }
+ return paths;
+ }
+
+ /**
+ * Persist chain metadata under backup_details. Stored here (not on the
backups table) so
+ * other providers can implement their own chain semantics without schema
changes.
+ */
+ private void persistChainMetadata(Backup backup, ChainDecision decision,
String bitmapFromAgent) {
+ // Only persist nas.bitmap_name when the agent confirmed the bitmap
exists on the host.
+ // The agent wrapper sets bitmapFromAgent=null when nasbackup.sh exits
+ // EXIT_BITMAP_NOT_SEEDED (=22) — currently only the stopped-VM path
where qemu-img
+ // bitmap --add failed on every source disk. Anchoring the next
incremental on a
+ // bitmap that doesn't exist would force a non-recoverable failure, so
we leave the
+ // detail empty and let the next backup start a fresh full chain.
+ if (bitmapFromAgent != null && !bitmapFromAgent.isEmpty()) {
+ backupDetailsDao.persist(new BackupDetailVO(backup.getId(),
NASBackupChainKeys.BITMAP_NAME, bitmapFromAgent, true));
+ }
+ backupDetailsDao.persist(new BackupDetailVO(backup.getId(),
NASBackupChainKeys.CHAIN_ID, decision.chainId, true));
+ backupDetailsDao.persist(new BackupDetailVO(backup.getId(),
NASBackupChainKeys.CHAIN_POSITION,
+ String.valueOf(decision.chainPosition), true));
+ // Backup full-vs-incremental type lives on backup.type (set by
takeBackup) — single
+ // source of truth. Not duplicated into backup_details.
+ if (decision.isIncremental()) {
+ // Resolve the parent backup's UUID so restore can walk the chain
by id, not by path.
+ String parentUuid = lookupParentBackupUuid(backup.getVmId(),
decision.bitmapParent);
+ if (parentUuid != null) {
+ backupDetailsDao.persist(new BackupDetailVO(backup.getId(),
NASBackupChainKeys.PARENT_BACKUP_ID, parentUuid, true));
+ }
+ }
+ }
+
+ /**
+ * Upsert the VM's {@code nas.active_checkpoint_id} detail to {@code
bitmapName}. Called
+ * after every successful backup so the next backup's parent-bitmap
decision is anchored
+ * on what actually exists on QEMU, not on "last backup taken".
+ */
+ private void upsertVmActiveCheckpoint(long vmId, String bitmapName) {
+ VMInstanceDetailVO existing = vmInstanceDetailsDao.findDetail(vmId,
NASBackupChainKeys.VM_ACTIVE_CHECKPOINT_ID);
+ if (existing == null) {
+ vmInstanceDetailsDao.addDetail(vmId,
NASBackupChainKeys.VM_ACTIVE_CHECKPOINT_ID, bitmapName, false);
+ return;
+ }
+ existing.setValue(bitmapName);
+ vmInstanceDetailsDao.update(existing.getId(), existing);
+ }
+
+ /**
+ * Remove the VM's {@code nas.active_checkpoint_id} detail. Called from
the restore paths:
+ * after restore the disk image has no QEMU bitmap attached, so any future
incremental
+ * would be based on stale state. Clearing forces the next backup to be a
fresh full.
+ */
+ private void clearVmActiveCheckpoint(long vmId) {
+ VMInstanceDetailVO existing = vmInstanceDetailsDao.findDetail(vmId,
NASBackupChainKeys.VM_ACTIVE_CHECKPOINT_ID);
+ if (existing != null) {
+ vmInstanceDetailsDao.removeDetail(vmId,
NASBackupChainKeys.VM_ACTIVE_CHECKPOINT_ID);
+ LOG.debug("Cleared nas.active_checkpoint_id for VM id={} (was
{})", vmId, existing.getValue());
+ }
+ }
+
+ private String lookupParentBackupUuid(long vmId, String parentBitmap) {
+ if (parentBitmap == null) {
+ return null;
+ }
+ for (Backup b : backupDao.listByVmId(null, vmId)) {
+ String bm = readDetail(b, NASBackupChainKeys.BITMAP_NAME);
+ if (parentBitmap.equals(bm)) {
+ return b.getUuid();
+ }
+ }
+ return null;
+ }
+
protected Host getVMHypervisorHostForBackup(VirtualMachine vm) {
Long hostId = vm.getHostId();
if (hostId == null &&
VirtualMachine.State.Running.equals(vm.getState())) {
@@ -205,12 +566,20 @@ public class NASBackupProvider extends AdapterBase
implements BackupProvider, Co
final String backupPath = String.format("%s/%s", vm.getInstanceName(),
new
SimpleDateFormat("yyyy.MM.dd.HH.mm.ss").format(creationDate));
- BackupVO backupVO = createBackupObject(vm, backupPath);
+ // Decide full vs incremental for this backup. Stopped VMs are always
full
+ // (libvirt backup-begin requires a running QEMU process).
+ ChainDecision decision = decideChain(vm);
+
+ BackupVO backupVO = createBackupObject(vm, backupPath,
decision.isIncremental() ? "INCREMENTAL" : "FULL");
TakeBackupCommand command = new
TakeBackupCommand(vm.getInstanceName(), backupPath);
command.setBackupRepoType(backupRepository.getType());
command.setBackupRepoAddress(backupRepository.getAddress());
command.setMountOptions(backupRepository.getMountOptions());
command.setQuiesce(quiesceVM);
+ command.setMode(decision.mode);
+ command.setBitmapNew(decision.bitmapNew);
+ command.setBitmapParent(decision.bitmapParent);
+ command.setParentPaths(decision.parentPaths);
if (VirtualMachine.State.Stopped.equals(vm.getState())) {
List<VolumeVO> vmVolumes = volumeDao.findByInstance(vm.getId());
@@ -239,9 +608,31 @@ public class NASBackupProvider extends AdapterBase
implements BackupProvider, Co
backupVO.setDate(new Date());
backupVO.setSize(answer.getSize());
backupVO.setStatus(Backup.Status.BackedUp);
+ // If the agent fell back to full (stopped VM mid-incremental
cycle), record this
+ // backup as a full and start a new chain.
+ ChainDecision effective = decision;
+ if (answer.getIncrementalFallback()) {
+ effective = ChainDecision.fullStart(decision.bitmapNew);
+ backupVO.setType("FULL");
+ }
List<Volume> volumes = new
ArrayList<>(volumeDao.findByInstance(vm.getId()));
backupVO.setBackedUpVolumes(backupManager.createVolumeInfoFromVolumes(volumes));
if (backupDao.update(backupVO.getId(), backupVO)) {
+ // Legacy-full (incremental feature disabled): persist no
chain/checkpoint metadata
+ // and do not touch the VM's active_checkpoint_id — keep the
feature-off path legacy.
+ if (!decision.isLegacyFull()) {
+ persistChainMetadata(backupVO, effective,
answer.getBitmapCreated());
+ // Pin the VM's active_checkpoint_id to whichever bitmap
the agent actually
+ // created — the only valid parent for the next
incremental (see decideChain).
+ // If the agent reports no bitmap (bitmapCreated=null),
clear any stale detail
+ // so the next backup starts a fresh full.
+ String confirmedBitmap = answer.getBitmapCreated();
+ if (confirmedBitmap != null) {
+ upsertVmActiveCheckpoint(vm.getId(), confirmedBitmap);
+ } else {
+ clearVmActiveCheckpoint(vm.getId());
+ }
+ }
return new Pair<>(true, backupVO);
} else {
throw new CloudRuntimeException("Failed to update backup");
@@ -260,11 +651,11 @@ public class NASBackupProvider extends AdapterBase
implements BackupProvider, Co
}
}
- private BackupVO createBackupObject(VirtualMachine vm, String backupPath) {
+ private BackupVO createBackupObject(VirtualMachine vm, String backupPath,
String type) {
BackupVO backup = new BackupVO();
backup.setVmId(vm.getId());
backup.setExternalId(backupPath);
- backup.setType("FULL");
+ backup.setType(type);
backup.setDate(new Date());
long virtualSize = 0L;
for (final Volume volume: volumeDao.findByInstance(vm.getId())) {
@@ -333,6 +724,11 @@ public class NASBackupProvider extends AdapterBase
implements BackupProvider, Co
} catch (OperationTimedoutException e) {
throw new CloudRuntimeException("Operation to restore backup timed
out, please try again");
}
+ // After a restore the QEMU dirty-bitmap chain is gone — clear
active_checkpoint_id so
+ // the next backup is taken as a fresh full and starts a new chain.
See decideChain.
+ if (answer != null && answer.getResult()) {
+ clearVmActiveCheckpoint(vm.getId());
+ }
return new Pair<>(answer.getResult(), answer.getDetails());
}
@@ -457,6 +853,13 @@ public class NASBackupProvider extends AdapterBase
implements BackupProvider, Co
} catch (Exception e) {
throw new CloudRuntimeException("Unable to create restored
volume due to: " + e);
}
+ // The restored volume is attached to this VM with no QEMU bitmap
on its image, so the
+ // VM's tracked checkpoint is now stale; clear it to force the
next backup to be a full
+ // (mirrors the full restore paths
restoreVMFromBackup/restoreBackupToVM).
+ VirtualMachine restoreTargetVm =
vmInstanceDao.findVMByInstanceName(vmNameAndState.first());
+ if (restoreTargetVm != null) {
+ clearVmActiveCheckpoint(restoreTargetVm.getId());
+ }
}
return new Pair<>(answer.getResult(), answer.getDetails());
@@ -477,6 +880,14 @@ public class NASBackupProvider extends AdapterBase
implements BackupProvider, Co
.orElse(null);
}
+ @Override
+ public boolean handlesChainDeleteResourceAccounting() {
+ // This provider deletes whole incremental chains (leaf + swept
delete-pending ancestors)
+ // and decrements resource count/usage once per physically-removed
backup itself, so the
+ // manager must not also decrement or remove the DB row.
+ return true;
+ }
+
@Override
public boolean deleteBackup(Backup backup, boolean forced) {
final BackupRepository backupRepository =
backupRepositoryDao.findByBackupOfferingId(backup.getBackupOfferingId());
@@ -495,9 +906,42 @@ public class NASBackupProvider extends AdapterBase
implements BackupProvider, Co
throw new CloudRuntimeException(String.format("Unable to find a
running KVM host in zone %d to delete backup %s", backup.getZoneId(),
backup.getUuid()));
}
- DeleteBackupCommand command = new
DeleteBackupCommand(backup.getExternalId(), backupRepository.getType(),
- backupRepository.getAddress(),
backupRepository.getMountOptions());
+ // Backups outside any tracked chain (legacy or non-incremental
providers) are
+ // deleted straight away — no children semantics apply.
+ if (readDetail(backup, NASBackupChainKeys.CHAIN_ID) == null) {
+ return deleteBackupFileAndRow(backup, backupRepository, host);
+ }
+
+ // Snapshot-style cascade: defer the on-NAS rm + DB row while there
are live children,
+ // mark this backup as delete-pending, and let the leaf's deletion
sweep it up later.
+ // See DefaultSnapshotStrategy#deleteSnapshotChain for the same
pattern on incremental
+ // snapshots. forced=true means the caller wants the entire subtree
gone right now.
+ if (forced) {
+ return cascadeDeleteSubtree(backup, backupRepository, host);
+ }
+
+ List<Backup> liveChildren = findLiveChildren(backup);
+ if (!liveChildren.isEmpty()) {
+ markDeletePending(backup);
+ LOG.debug("Backup {} has {} live child backup(s); marking as
delete-pending. The on-NAS file " +
+ "and DB row will be removed once the last
descendant is gone, or pass forced=true.",
+ backup.getUuid(), liveChildren.size());
+ return true;
+ }
+
+ // No live children — physically delete this backup, then walk up the
chain and
+ // collect any ancestors that were left in delete-pending state.
+ return deleteLeafBackupAndSweepPendingAncestors(backup,
backupRepository, host);
+ }
+ /**
+ * The single physical-delete step: rm the on-NAS directory, then remove
the DB row.
+ * Returns {@code false} (and leaves both intact) if the agent reports
failure, so the
+ * caller's recursion stops cleanly.
+ */
+ private boolean deleteBackupFileAndRow(Backup backup, BackupRepository
repo, Host host) {
+ DeleteBackupCommand command = new
DeleteBackupCommand(backup.getExternalId(), repo.getType(),
+ repo.getAddress(), repo.getMountOptions());
BackupAnswer answer;
try {
answer = (BackupAnswer) agentManager.send(host.getId(), command);
@@ -506,13 +950,229 @@ public class NASBackupProvider extends AdapterBase
implements BackupProvider, Co
} catch (OperationTimedoutException e) {
throw new CloudRuntimeException("Operation to delete backup timed
out, please try again");
}
+ if (answer == null || !answer.getResult()) {
+ logger.warn("Failed to delete backup file for {} ({}); leaving DB
row intact",
+ backup.getUuid(), backup.getExternalId());
+ return false;
+ }
+ // Capture the deleted backup's bitmap before the row (and its
backup_details) are removed.
+ String deletedBitmap = readDetail(backup,
NASBackupChainKeys.BITMAP_NAME);
+ backupDao.remove(backup.getId());
+ // If this backup owned the bitmap the VM's active_checkpoint_id
points to, that on-host QEMU
+ // dirty-bitmap is gone with it — clear active_checkpoint_id so the
next backup starts a fresh
+ // full chain instead of anchoring an incremental on a deleted
checkpoint (test: delete last backup).
+ if (deletedBitmap != null &&
deletedBitmap.equals(readVmActiveCheckpoint(backup.getVmId()))) {
+ clearVmActiveCheckpoint(backup.getVmId());
+ }
+ // Exactly-once resource accounting: decrement at the single point a
backup row + file are
+ // physically removed. This runs for the leaf and for every swept
delete-pending ancestor,
+ // so a chain delete decrements once per actually-removed backup. The
manager skips its own
+ // accounting for this provider (see
handlesChainDeleteResourceAccounting()).
+ long size = backup.getSize() != null ? backup.getSize() : 0L;
+ resourceLimitMgr.decrementResourceCount(backup.getAccountId(),
Resource.ResourceType.backup);
+ resourceLimitMgr.decrementResourceCount(backup.getAccountId(),
Resource.ResourceType.backup_storage, size);
+ return true;
+ }
- if (answer != null && answer.getResult()) {
- return backupDao.remove(backup.getId());
+ /**
+ * Tombstone {@code backup} by moving it to {@link Backup.Status#Hidden}.
Idempotent.
+ * The row stays in the DB so the chain GC can sweep it once its last
descendant is deleted
+ * ({@code listByVmId} is status-agnostic), but it disappears from the
user-facing list
+ * ({@link BackupManagerImpl#listBackups} filters Hidden) and all backup
operations refuse it
+ * (they require {@code BackedUp}). Replaces the previous {@code
nas.delete_pending} detail.
+ */
+ private void markDeletePending(Backup backup) {
+ if (Backup.Status.Hidden.equals(backup.getStatus())) {
+ return;
+ }
+ BackupVO vo = backupDao.findById(backup.getId());
+ if (vo != null) {
+ vo.setStatus(Backup.Status.Hidden);
+ backupDao.update(vo.getId(), vo);
}
+ }
- logger.debug("There was an error removing the backup with id {}",
backup.getId());
- return false;
+ /**
+ * @return true if this backup is a tombstone (Hidden) awaiting chain
sweep.
+ */
+ private boolean isDeletePending(Backup backup) {
+ return Backup.Status.Hidden.equals(backup.getStatus());
+ }
+
+ /**
+ * Return the live (not delete-pending, not Removed) children of {@code
parent} within the
+ * same chain. Equivalent to "incrementals whose parent_backup_id points
at parent".
+ */
+ private List<Backup> findLiveChildren(Backup parent) {
+ String parentUuid = parent.getUuid();
+ String chainId = readDetail(parent, NASBackupChainKeys.CHAIN_ID);
+ if (parentUuid == null || chainId == null) {
+ return Collections.emptyList();
+ }
+ List<Backup> children = new ArrayList<>();
+ for (Backup b : backupDao.listByVmId(null, parent.getVmId())) {
+ if (b.getId() == parent.getId()) {
+ continue;
+ }
+ if (!chainId.equals(readDetail(b, NASBackupChainKeys.CHAIN_ID))) {
+ continue;
+ }
+ if (!parentUuid.equals(readDetail(b,
NASBackupChainKeys.PARENT_BACKUP_ID))) {
+ continue;
+ }
+ if (isDeletePending(b)) {
+ // Tombstoned children don't keep us alive — they're already
on the way out.
+ continue;
+ }
+ children.add(b);
+ }
+ return children;
+ }
+
+ /**
+ * Look up this backup's immediate parent in the chain (by {@code
PARENT_BACKUP_ID}).
+ * Returns {@code null} if this is the full (no parent) or the parent row
is gone.
+ *
+ * <p>Prefer {@link #getChainOrderedLeafToRoot(Backup)} when walking the
whole chain —
+ * this method hits the DB on each call and is O(N²) when used in a loop.
+ */
+ private Backup findChainParent(Backup backup) {
+ String parentUuid = readDetail(backup,
NASBackupChainKeys.PARENT_BACKUP_ID);
+ if (parentUuid == null || parentUuid.isEmpty()) {
+ return null;
+ }
+ for (Backup b : backupDao.listByVmId(null, backup.getVmId())) {
+ if (parentUuid.equals(b.getUuid())) {
+ return b;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Return the chain containing {@code member}, ordered leaf-first
+ * (highest {@code CHAIN_POSITION} → root).
+ *
+ * <p>Materialises the chain via a single {@link BackupDao#listByVmId}
call. Callers that
+ * previously walked the chain by repeatedly calling {@link
#findChainParent} were doing
+ * O(N) {@code listByVmId} calls (one per ancestor); this collapses that
to one.
+ *
+ * <p>If {@code member} has no {@code CHAIN_ID} metadata it is returned as
a one-element
+ * list (it is its own degenerate chain).
+ */
+ private List<Backup> getChainOrderedLeafToRoot(Backup member) {
+ String chainId = readDetail(member, NASBackupChainKeys.CHAIN_ID);
+ if (chainId == null) {
+ return Collections.singletonList(member);
+ }
+ List<Backup> chain = new ArrayList<>();
+ for (Backup b : backupDao.listByVmId(null, member.getVmId())) {
+ if (chainId.equals(readDetail(b, NASBackupChainKeys.CHAIN_ID))) {
+ chain.add(b);
+ }
+ }
+ // Descending CHAIN_POSITION = leaf-first (highest position = furthest
from root).
+ chain.sort((a, b) -> Integer.compare(chainPosition(b),
chainPosition(a)));
+ return chain;
+ }
+
+ /**
+ * Physically delete the leaf {@code backup}, then walk up the chain while
each ancestor
+ * is in delete-pending state. Mirrors the snapshot subsystem pattern:
once a leaf is
+ * gone, garbage-collect any tombstoned parents.
+ *
+ * <p>Caller must guarantee {@code backup} is a leaf (no live children).
Each tombstoned
+ * ancestor is by definition childless once its sole child is deleted
here, so no extra
+ * live-children check is needed inside the loop.
+ */
+ private boolean deleteLeafBackupAndSweepPendingAncestors(Backup backup,
BackupRepository repo, Host host) {
+ // Snapshot the chain BEFORE the leaf delete — deleteBackupFileAndRow
removes the row,
+ // after which the in-memory list still resolves but the DB no longer
would.
+ List<Backup> chain = getChainOrderedLeafToRoot(backup);
+ if (!deleteBackupFileAndRow(backup, repo, host)) {
+ return false;
+ }
+ // Walk the snapshot from leaf+1 upward, deleting tombstoned ancestors
until a live
+ // one is reached or the root is past.
+ int leafIdx = indexOfBackupById(chain, backup.getId());
+ if (leafIdx < 0) {
+ // Leaf wasn't in its own CHAIN_ID list — degenerate case, nothing
more to sweep.
+ return true;
+ }
+ for (int i = leafIdx + 1; i < chain.size(); i++) {
+ Backup ancestor = chain.get(i);
+ if (!isDeletePending(ancestor)) {
+ break;
+ }
+ if (!deleteBackupFileAndRow(ancestor, repo, host)) {
+ // Stop the sweep; the rest of the tombstoned chain will be
collected on a
+ // future delete that re-runs the sweep.
+ return true;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Forced delete of {@code root}'s entire chain, leaf-first. NAS backups
form a linear
+ * chain (full → inc → inc → …), not a tree, so we just walk the ordered
chain and
+ * delete each member without re-querying parents.
+ */
+ private boolean cascadeDeleteSubtree(Backup root, BackupRepository repo,
Host host) {
+ for (Backup b : getChainOrderedLeafToRoot(root)) {
+ if (!deleteBackupFileAndRow(b, repo, host)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static int indexOfBackupById(List<Backup> chain, long id) {
+ for (int i = 0; i < chain.size(); i++) {
+ if (chain.get(i).getId() == id) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ /**
+ * Return the backup with the highest {@code CHAIN_POSITION} sharing
{@code root}'s
+ * {@code CHAIN_ID}. Returns {@code root} if it has no chain metadata or
is itself the tail.
+ */
+ private Backup findChainTail(Backup root) {
+ String chainId = readDetail(root, NASBackupChainKeys.CHAIN_ID);
+ if (chainId == null) {
+ return root;
+ }
+ Backup tail = root;
+ int tailPos = chainPosition(root);
+ for (Backup b : backupDao.listByVmId(null, root.getVmId())) {
+ if (b.getId() == root.getId()) {
+ continue;
+ }
+ if (!chainId.equals(readDetail(b, NASBackupChainKeys.CHAIN_ID))) {
+ continue;
+ }
+ int pos = chainPosition(b);
+ if (pos > tailPos) {
+ tail = b;
+ tailPos = pos;
+ }
+ }
+ return tail;
+ }
+
+ private int chainPosition(Backup b) {
+ String s = readDetail(b, NASBackupChainKeys.CHAIN_POSITION);
+ if (s == null) {
+ return Integer.MAX_VALUE; // no metadata => sort to end
+ }
+ try {
+ return Integer.parseInt(s);
+ } catch (NumberFormatException e) {
+ return Integer.MAX_VALUE;
+ }
}
public void syncBackupMetrics(Long zoneId) {
@@ -543,6 +1203,11 @@ public class NASBackupProvider extends AdapterBase
implements BackupProvider, Co
@Override
public boolean removeVMFromBackupOffering(VirtualMachine vm) {
+ // Clear the VM's active checkpoint so any future re-assignment to a
backup offering
+ // starts a fresh chain. Without this, a detach-volume +
attach-different-volume cycle
+ // while the offering is unassigned would lead to the next backup
trying to rebase
+ // onto a stale parent (different volume identity, same VM id).
+ clearVmActiveCheckpoint(vm.getId());
return true;
}
@@ -629,7 +1294,9 @@ public class NASBackupProvider extends AdapterBase
implements BackupProvider, Co
@Override
public ConfigKey<?>[] getConfigKeys() {
return new ConfigKey[]{
- NASBackupRestoreMountTimeout
+ NASBackupRestoreMountTimeout,
+ NASBackupFullEvery,
+ NASBackupIncrementalEnabled
};
}
diff --git
a/plugins/backup/nas/src/test/java/org/apache/cloudstack/backup/NASBackupProviderTest.java
b/plugins/backup/nas/src/test/java/org/apache/cloudstack/backup/NASBackupProviderTest.java
index a512292cd28..3ba7dbad041 100644
---
a/plugins/backup/nas/src/test/java/org/apache/cloudstack/backup/NASBackupProviderTest.java
+++
b/plugins/backup/nas/src/test/java/org/apache/cloudstack/backup/NASBackupProviderTest.java
@@ -28,6 +28,7 @@ import com.cloud.vm.snapshot.dao.VMSnapshotDao;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Mockito;
@@ -38,22 +39,35 @@ import org.springframework.test.util.ReflectionTestUtils;
import com.cloud.agent.AgentManager;
import com.cloud.exception.AgentUnavailableException;
import com.cloud.exception.OperationTimedoutException;
+import com.cloud.configuration.Resource;
import com.cloud.host.Host;
import com.cloud.host.HostVO;
import com.cloud.host.Status;
import com.cloud.host.dao.HostDao;
import com.cloud.hypervisor.Hypervisor;
import com.cloud.resource.ResourceManager;
+import com.cloud.storage.DiskOfferingVO;
+import com.cloud.storage.ScopeType;
+import com.cloud.storage.Storage;
import com.cloud.storage.Volume;
import com.cloud.storage.VolumeVO;
+import com.cloud.storage.dao.DiskOfferingDao;
import com.cloud.storage.dao.VolumeDao;
+import com.cloud.user.ResourceLimitService;
import com.cloud.utils.Pair;
+import com.cloud.vm.VMInstanceDetailVO;
import com.cloud.vm.VMInstanceVO;
+import com.cloud.vm.VirtualMachine;
import com.cloud.vm.dao.VMInstanceDao;
+import com.cloud.vm.dao.VMInstanceDetailsDao;
+
+import com.google.gson.Gson;
import org.apache.cloudstack.backup.dao.BackupDao;
+import org.apache.cloudstack.backup.dao.BackupDetailsDao;
import org.apache.cloudstack.backup.dao.BackupRepositoryDao;
import org.apache.cloudstack.backup.dao.BackupOfferingDao;
+import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreManager;
import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao;
import org.apache.cloudstack.storage.datastore.db.StoragePoolVO;
@@ -96,6 +110,21 @@ public class NASBackupProviderTest {
@Mock
private VMSnapshotDao vmSnapshotDaoMock;
+ @Mock
+ private BackupDetailsDao backupDetailsDao;
+
+ @Mock
+ private VMInstanceDetailsDao vmInstanceDetailsDao;
+
+ @Mock
+ private DiskOfferingDao diskOfferingDao;
+
+ @Mock
+ private DataStoreManager dataStoreMgr;
+
+ @Mock
+ private ResourceLimitService resourceLimitMgr;
+
@Test
public void testDeleteBackup() throws OperationTimedoutException,
AgentUnavailableException {
Long hostId = 1L;
@@ -353,4 +382,427 @@ public class NASBackupProviderTest {
Mockito.verify(hostDao).findHypervisorHostInCluster(clusterId);
Mockito.verify(resourceManager).findOneRandomRunningHostByHypervisor(Hypervisor.HypervisorType.KVM,
zoneId);
}
+
+ // -- nas.backup.incremental.enabled master switch
------------------------------------
+
+ /**
+ * When the operator sets nas.backup.incremental.enabled=false at the zone
level, every
+ * backup must be a fresh full anchor, regardless of VM state or
nas.backup.full.every.
+ * This is a single toggle the
+ * operator can flip without having to count remaining backups in a chain.
+ */
+ @Test
+ public void decideChainReturnsLegacyFullWhenIncrementalDisabled() {
+ Long zoneId = 1L;
+ VMInstanceVO vm = mock(VMInstanceVO.class);
+ Mockito.lenient().when(vm.getDataCenterId()).thenReturn(zoneId);
+
+ // Stub the master switch to false. ConfigKey.valueIn delegates to the
framework's
+ // ConfigDepot at runtime; for the unit test we override the in-memory
value via the
+ // ConfigKey's local override (set by ReflectionTestUtils on the spy
provider).
+ ReflectionTestUtils.setField(nasBackupProvider,
"NASBackupIncrementalEnabled",
+ new
org.apache.cloudstack.framework.config.ConfigKey<>("Advanced", Boolean.class,
+ "nas.backup.incremental.enabled", "false",
+ "test override — disabled", true,
+
org.apache.cloudstack.framework.config.ConfigKey.Scope.Zone));
+
+ NASBackupProvider.ChainDecision decision =
nasBackupProvider.decideChain(vm);
+ Assert.assertNotNull(decision);
+ Assert.assertEquals(NASBackupChainKeys.TYPE_LEGACY_FULL,
decision.mode);
+ Assert.assertNull("legacy-full must not carry a bitmap",
decision.bitmapNew);
+ Assert.assertNull(decision.bitmapParent);
+ Assert.assertNull("legacy-full must not start a chain",
decision.chainId);
+ Assert.assertEquals(0, decision.chainPosition);
+ }
+
+ // -- decideChain anchored on VM's active_checkpoint_id
-------------------------------
+
+ /**
+ * No active_checkpoint_id on the VM (post-restore, first-ever backup, or
detail purged) =>
+ * decideChain must return a fresh full. Relying on the last backup taken
as the parent
+ * breaks after a restore, so the decision is anchored on the active
checkpoint instead.
+ */
+ @Test
+ public void decideChainReturnsFullWhenVmHasNoActiveCheckpoint() {
+ Long zoneId = 1L;
+ Long vmId = 42L;
+ VMInstanceVO vm = mock(VMInstanceVO.class);
+ Mockito.when(vm.getId()).thenReturn(vmId);
+ Mockito.when(vm.getDataCenterId()).thenReturn(zoneId);
+ Mockito.when(vm.getState()).thenReturn(VMInstanceVO.State.Running);
+
+ // Master switch defaults to false (opt-in by zone) — explicitly
enable it for this
+ // test so we exercise the "no active_checkpoint_id" branch rather
than short-circuit
+ // at the master-switch gate.
+ ReflectionTestUtils.setField(nasBackupProvider,
"NASBackupIncrementalEnabled",
+ new
org.apache.cloudstack.framework.config.ConfigKey<>("Advanced", Boolean.class,
+ "nas.backup.incremental.enabled", "true",
+ "test override — enabled", true,
+
org.apache.cloudstack.framework.config.ConfigKey.Scope.Zone));
+
+ Mockito.when(vmInstanceDetailsDao.findDetail(vmId,
NASBackupChainKeys.VM_ACTIVE_CHECKPOINT_ID)).thenReturn(null);
+
+ NASBackupProvider.ChainDecision decision =
nasBackupProvider.decideChain(vm);
+ Assert.assertNotNull(decision);
+ Assert.assertEquals(NASBackupChainKeys.TYPE_FULL, decision.mode);
+ Assert.assertNull(decision.bitmapParent);
+ Assert.assertEquals(0, decision.chainPosition);
+ }
+
+ // -- incremental storage-capability guard (Ceph-RBD / Linstor stay on
legacy full) ----
+
+ /**
+ * Incremental checkpoints are only possible on file-based qcow2 storage.
A VM whose every
+ * volume sits on NFS / HOST-scope local / SharedMountPoint is
checkpoint-capable.
+ */
+ @Test
+ public void
allVolumesOnCheckpointCapableStorageTrueForNfsHostAndSharedMount() {
+ Long vmId = 55L;
+ VMInstanceVO vm = mock(VMInstanceVO.class);
+ Mockito.when(vm.getId()).thenReturn(vmId);
+
+ VolumeVO nfsVol = mock(VolumeVO.class);
+ Mockito.when(nfsVol.getPoolId()).thenReturn(1L);
+ VolumeVO hostVol = mock(VolumeVO.class);
+ Mockito.when(hostVol.getPoolId()).thenReturn(2L);
+ VolumeVO smpVol = mock(VolumeVO.class);
+ Mockito.when(smpVol.getPoolId()).thenReturn(3L);
+
Mockito.when(volumeDao.findByInstance(vmId)).thenReturn(List.of(nfsVol,
hostVol, smpVol));
+
+ StoragePoolVO nfs = mock(StoragePoolVO.class);
+
Mockito.when(nfs.getPoolType()).thenReturn(Storage.StoragePoolType.NetworkFilesystem);
+ StoragePoolVO host = mock(StoragePoolVO.class);
+ Mockito.when(host.getScope()).thenReturn(ScopeType.HOST);
+ StoragePoolVO smp = mock(StoragePoolVO.class);
+
Mockito.when(smp.getPoolType()).thenReturn(Storage.StoragePoolType.SharedMountPoint);
+ Mockito.when(storagePoolDao.findById(1L)).thenReturn(nfs);
+ Mockito.when(storagePoolDao.findById(2L)).thenReturn(host);
+ Mockito.when(storagePoolDao.findById(3L)).thenReturn(smp);
+
+
Assert.assertTrue(nasBackupProvider.allVolumesOnCheckpointCapableStorage(vm));
+ }
+
+ /**
+ * A single volume on Ceph-RBD (or any pool that cannot carry a per-disk
checkpoint) forces
+ * the whole VM onto the legacy full-only path — avoids regressing
RBD/Linstor storages.
+ */
+ @Test
+ public void allVolumesOnCheckpointCapableStorageFalseWhenAnyVolumeOnRbd() {
+ Long vmId = 56L;
+ VMInstanceVO vm = mock(VMInstanceVO.class);
+ Mockito.when(vm.getId()).thenReturn(vmId);
+
+ VolumeVO nfsVol = mock(VolumeVO.class);
+ Mockito.when(nfsVol.getPoolId()).thenReturn(1L);
+ VolumeVO rbdVol = mock(VolumeVO.class);
+ Mockito.when(rbdVol.getPoolId()).thenReturn(9L);
+
Mockito.when(volumeDao.findByInstance(vmId)).thenReturn(List.of(nfsVol,
rbdVol));
+
+ StoragePoolVO nfs = mock(StoragePoolVO.class);
+
Mockito.when(nfs.getPoolType()).thenReturn(Storage.StoragePoolType.NetworkFilesystem);
+ StoragePoolVO rbd = mock(StoragePoolVO.class);
+
Mockito.when(rbd.getPoolType()).thenReturn(Storage.StoragePoolType.RBD);
+ Mockito.when(storagePoolDao.findById(1L)).thenReturn(nfs);
+ Mockito.when(storagePoolDao.findById(9L)).thenReturn(rbd);
+
+
Assert.assertFalse(nasBackupProvider.allVolumesOnCheckpointCapableStorage(vm));
+ }
+
+ /** A volume whose storage pool can no longer be resolved is treated as
incapable (safe). */
+ @Test
+ public void
allVolumesOnCheckpointCapableStorageFalseWhenPoolUnresolvable() {
+ Long vmId = 57L;
+ VMInstanceVO vm = mock(VMInstanceVO.class);
+ Mockito.when(vm.getId()).thenReturn(vmId);
+ VolumeVO vol = mock(VolumeVO.class);
+ Mockito.when(vol.getPoolId()).thenReturn(1L);
+ Mockito.when(volumeDao.findByInstance(vmId)).thenReturn(List.of(vol));
+ Mockito.when(storagePoolDao.findById(1L)).thenReturn(null);
+
+
Assert.assertFalse(nasBackupProvider.allVolumesOnCheckpointCapableStorage(vm));
+ }
+
+ // -- restore clears active_checkpoint_id
---------------------------------------------
+
+ /**
+ * After a successful restoreVMFromBackup, decideChain on the next backup
must produce
+ * a full. We verify this end-to-end by checking that
vmInstanceDetailsDao.removeDetail
+ * is called with the active_checkpoint_id key.
+ */
+ @Test
+ public void restoreClearsActiveCheckpointDetail() throws
AgentUnavailableException, OperationTimedoutException {
+ Long vmId = 7L;
+ Long hostId = 8L;
+ Long backupOfferingId = 9L;
+
+ VMInstanceVO vm = mock(VMInstanceVO.class);
+ Mockito.when(vm.getId()).thenReturn(vmId);
+ Mockito.when(vm.getLastHostId()).thenReturn(hostId);
+ Mockito.when(vm.getRemoved()).thenReturn(null);
+ Mockito.when(vm.getName()).thenReturn("vm7");
+
+ HostVO host = mock(HostVO.class);
+ Mockito.when(host.getStatus()).thenReturn(Status.Up);
+ Mockito.when(host.getId()).thenReturn(hostId);
+ Mockito.when(hostDao.findById(hostId)).thenReturn(host);
+
+ BackupVO backup = new BackupVO();
+ backup.setVmId(vmId);
+ backup.setBackupOfferingId(backupOfferingId);
+ backup.setExternalId("i-2-7-VM/2026.05.16.10.00.00");
+ ReflectionTestUtils.setField(backup, "id", 100L);
+ // backedUpVolumes defaults to null => BackupVO.getBackedUpVolumes
returns emptyList().
+
+ BackupRepositoryVO repo = new BackupRepositoryVO(1L, "nas",
"test-repo",
+ "nfs", "address", "sync", 1024L, null);
+
Mockito.when(backupRepositoryDao.findByBackupOfferingId(backupOfferingId)).thenReturn(repo);
+
+
Mockito.when(volumeDao.findByInstance(vmId)).thenReturn(Collections.emptyList());
+
+ BackupAnswer answer = mock(BackupAnswer.class);
+ Mockito.when(answer.getResult()).thenReturn(true);
+ Mockito.when(agentManager.send(Mockito.anyLong(),
Mockito.any(RestoreBackupCommand.class))).thenReturn(answer);
+
+ // Pre-existing checkpoint detail so removeDetail has something to
"clear".
+ VMInstanceDetailVO existing = mock(VMInstanceDetailVO.class);
+ Mockito.when(existing.getValue()).thenReturn("backup-1715000000");
+ Mockito.when(vmInstanceDetailsDao.findDetail(vmId,
NASBackupChainKeys.VM_ACTIVE_CHECKPOINT_ID)).thenReturn(existing);
+
+ boolean ok = nasBackupProvider.restoreVMFromBackup(vm, backup);
+ Assert.assertTrue(ok);
+ Mockito.verify(vmInstanceDetailsDao).removeDetail(vmId,
NASBackupChainKeys.VM_ACTIVE_CHECKPOINT_ID);
+ }
+
+ /**
+ * Single-volume restore (restoreBackedUpVolume) must also clear the
target VM's
+ * active_checkpoint_id, so the next backup of that VM is a fresh full —
the restored
+ * volume's image carries no QEMU bitmap.
+ */
+ @Test
+ public void restoreBackedUpVolumeClearsTargetVmActiveCheckpoint()
+ throws AgentUnavailableException, OperationTimedoutException {
+ Long targetVmId = 42L;
+ Long backupOfferingId = 9L;
+ String targetVmName = "i-2-42-VM";
+ String volUuid = "vol-uuid-1";
+ String hostIp = "10.0.0.5";
+ String dsUuid = "ds-uuid-1";
+
+ VolumeVO srcVolume = mock(VolumeVO.class);
+ Mockito.when(srcVolume.getUuid()).thenReturn(volUuid);
+ Mockito.when(srcVolume.getName()).thenReturn("data1");
+ Mockito.when(volumeDao.findByUuid(volUuid)).thenReturn(srcVolume);
+
+ DiskOfferingVO diskOffering = mock(DiskOfferingVO.class);
+ Mockito.when(diskOffering.getId()).thenReturn(5L);
+
Mockito.when(diskOffering.getProvisioningType()).thenReturn(Storage.ProvisioningType.THIN);
+
Mockito.when(diskOfferingDao.findByUuid(Mockito.anyString())).thenReturn(diskOffering);
+
+ StoragePoolVO pool = mock(StoragePoolVO.class);
+ Mockito.when(pool.getId()).thenReturn(11L);
+
Mockito.when(pool.getPoolType()).thenReturn(Storage.StoragePoolType.NetworkFilesystem);
+ Mockito.when(storagePoolDao.findByUuid(dsUuid)).thenReturn(pool);
+
+ HostVO host = mock(HostVO.class);
+ Mockito.when(host.getId()).thenReturn(8L);
+ Mockito.when(hostDao.findByIp(hostIp)).thenReturn(host);
+
+ Backup.VolumeInfo backedUp = new Backup.VolumeInfo(volUuid,
"i-2-99-VM/2026/data1.qcow2",
+ Volume.Type.DATADISK, 1024L, 1L, "disk-offering-uuid", null,
null);
+
+ BackupVO backup = new BackupVO();
+ backup.setVmId(99L);
+ backup.setBackupOfferingId(backupOfferingId);
+ backup.setExternalId("i-2-99-VM/2026.06.22.10.00.00");
+ backup.setSize(1024L);
+ backup.setBackedUpVolumes(new
Gson().toJson(Collections.singletonList(backedUp)));
+ ReflectionTestUtils.setField(backup, "id", 200L);
+
+ BackupRepositoryVO repo = new BackupRepositoryVO(1L, "nas",
"test-repo",
+ "nfs", "address", "sync", 1024L, null);
+
Mockito.when(backupRepositoryDao.findByBackupOfferingId(backupOfferingId)).thenReturn(repo);
+
+ BackupAnswer answer = mock(BackupAnswer.class);
+ Mockito.when(answer.getResult()).thenReturn(true);
+ Mockito.when(agentManager.send(Mockito.anyLong(),
Mockito.any(RestoreBackupCommand.class))).thenReturn(answer);
+
+ VMInstanceVO targetVm = mock(VMInstanceVO.class);
+ Mockito.when(targetVm.getId()).thenReturn(targetVmId);
+
Mockito.when(vmInstanceDao.findVMByInstanceName(targetVmName)).thenReturn(targetVm);
+
+ VMInstanceDetailVO existing = mock(VMInstanceDetailVO.class);
+ Mockito.when(existing.getValue()).thenReturn("backup-1718000000");
+ Mockito.when(vmInstanceDetailsDao.findDetail(targetVmId,
NASBackupChainKeys.VM_ACTIVE_CHECKPOINT_ID)).thenReturn(existing);
+
+ Pair<Boolean, String> result = nasBackupProvider.restoreBackedUpVolume(
+ backup, backedUp, hostIp, dsUuid, new Pair<>(targetVmName,
VirtualMachine.State.Stopped));
+
+ Assert.assertTrue(result.first());
+ Mockito.verify(vmInstanceDetailsDao).removeDetail(targetVmId,
NASBackupChainKeys.VM_ACTIVE_CHECKPOINT_ID);
+ }
+
+ // -- delete-pending cascade
----------------------------------------------------------
+
+ /**
+ * Deleting an incremental that has a live child must mark the incremental
as
+ * delete-pending in backup_details and NOT touch the on-NAS file or the
backups row.
+ * A parent with live children is soft-deleted (delete-pending) rather
than removed.
+ */
+ @Test
+ public void deleteWithLiveChildMarksDeletePendingAndPreservesFile()
+ throws AgentUnavailableException, OperationTimedoutException {
+ Long zoneId = 1L;
+ Long vmId = 2L;
+ Long hostId = 3L;
+ Long offeringId = 4L;
+
+ BackupVO parent = new BackupVO();
+ parent.setVmId(vmId);
+ parent.setBackupOfferingId(offeringId);
+ parent.setExternalId("i-2-2-VM/2026.05.10.10.00.00");
+ parent.setZoneId(zoneId);
+ ReflectionTestUtils.setField(parent, "id", 50L);
+ ReflectionTestUtils.setField(parent, "uuid", "parent-uuid");
+
+ VMInstanceVO vm = mock(VMInstanceVO.class);
+ Mockito.when(vm.getLastHostId()).thenReturn(hostId);
+ HostVO host = mock(HostVO.class);
+ Mockito.when(host.getStatus()).thenReturn(Status.Up);
+ // Note: host.getId() is intentionally not stubbed — the live-child
path never
+ // contacts the agent (verified below), so the stub would be
unnecessary.
+ Mockito.when(hostDao.findById(hostId)).thenReturn(host);
+
+ BackupRepositoryVO repo = new BackupRepositoryVO(1L, "nas",
"test-repo",
+ "nfs", "address", "sync", 1024L, null);
+
Mockito.when(backupRepositoryDao.findByBackupOfferingId(offeringId)).thenReturn(repo);
+
Mockito.when(vmInstanceDao.findByIdIncludingRemoved(vmId)).thenReturn(vm);
+
+ // CHAIN_ID on the parent => not the no-chain fast path.
+ BackupDetailVO chainIdDetail = new BackupDetailVO(50L,
NASBackupChainKeys.CHAIN_ID, "chain-1", true);
+ Mockito.when(backupDetailsDao.findDetail(50L,
NASBackupChainKeys.CHAIN_ID)).thenReturn(chainIdDetail);
+
+ // A live child references parent-uuid via PARENT_BACKUP_ID.
+ BackupVO child = new BackupVO();
+ child.setVmId(vmId);
+ child.setBackupOfferingId(offeringId);
+ child.setExternalId("i-2-2-VM/2026.05.10.10.30.00");
+ child.setZoneId(zoneId);
+ child.setStatus(Backup.Status.BackedUp);
+ ReflectionTestUtils.setField(child, "id", 51L);
+ ReflectionTestUtils.setField(child, "uuid", "child-uuid");
+
+ BackupDetailVO childChainId = new BackupDetailVO(51L,
NASBackupChainKeys.CHAIN_ID, "chain-1", true);
+ BackupDetailVO childParent = new BackupDetailVO(51L,
NASBackupChainKeys.PARENT_BACKUP_ID, "parent-uuid", true);
+ Mockito.when(backupDetailsDao.findDetail(51L,
NASBackupChainKeys.CHAIN_ID)).thenReturn(childChainId);
+ Mockito.when(backupDetailsDao.findDetail(51L,
NASBackupChainKeys.PARENT_BACKUP_ID)).thenReturn(childParent);
+
+ Mockito.when(backupDao.listByVmId(null,
vmId)).thenReturn(List.of(parent, child));
+ // markDeletePending loads the row to flip its status to Hidden.
+ Mockito.when(backupDao.findById(50L)).thenReturn(parent);
+
+ boolean result = nasBackupProvider.deleteBackup(parent, false);
+ Assert.assertTrue(result);
+
+ // No agent traffic — the on-NAS file must be preserved while children
are alive.
+ Mockito.verify(agentManager, Mockito.never()).send(Mockito.anyLong(),
Mockito.any(DeleteBackupCommand.class));
+ // No DB row removal — the row is the tombstone marker.
+ Mockito.verify(backupDao, Mockito.never()).remove(50L);
+ // A tombstoned backup is NOT decremented — its space is still
occupied until swept.
+ Mockito.verify(resourceLimitMgr,
Mockito.never()).decrementResourceCount(Mockito.anyLong(),
Mockito.eq(Resource.ResourceType.backup));
+ // The tombstoned backup is moved to Status.Hidden (replaces the old
DELETE_PENDING detail).
+ ArgumentCaptor<BackupVO> captor =
ArgumentCaptor.forClass(BackupVO.class);
+ Mockito.verify(backupDao).update(Mockito.eq(50L), captor.capture());
+ Assert.assertEquals(Backup.Status.Hidden,
captor.getValue().getStatus());
+ Mockito.verify(backupDetailsDao,
Mockito.never()).persist(Mockito.any(BackupDetailVO.class));
+ }
+
+ /**
+ * Deleting a leaf incremental whose parent is delete-pending must (a)
delete the leaf and
+ * then (b) sweep up the tombstoned parent. Mirrors
DefaultSnapshotStrategy's
+ * "delete leaf, then walk up while parent is destroying-and-childless".
+ */
+ @Test
+ public void deletingLeafSweepsUpDeletePendingParent()
+ throws AgentUnavailableException, OperationTimedoutException {
+ Long zoneId = 1L;
+ Long vmId = 2L;
+ Long hostId = 3L;
+ Long offeringId = 4L;
+
+ BackupVO leaf = new BackupVO();
+ leaf.setVmId(vmId);
+ leaf.setBackupOfferingId(offeringId);
+ leaf.setExternalId("i-2-2-VM/2026.05.10.11.00.00");
+ leaf.setZoneId(zoneId);
+ ReflectionTestUtils.setField(leaf, "id", 51L);
+ ReflectionTestUtils.setField(leaf, "uuid", "leaf-uuid");
+
+ BackupVO parent = new BackupVO();
+ parent.setVmId(vmId);
+ parent.setBackupOfferingId(offeringId);
+ parent.setExternalId("i-2-2-VM/2026.05.10.10.30.00");
+ parent.setZoneId(zoneId);
+ ReflectionTestUtils.setField(parent, "id", 50L);
+ ReflectionTestUtils.setField(parent, "uuid", "parent-uuid");
+
+ VMInstanceVO vm = mock(VMInstanceVO.class);
+ Mockito.when(vm.getLastHostId()).thenReturn(hostId);
+ HostVO host = mock(HostVO.class);
+ Mockito.when(host.getStatus()).thenReturn(Status.Up);
+ Mockito.when(host.getId()).thenReturn(hostId);
+ Mockito.when(hostDao.findById(hostId)).thenReturn(host);
+
+ BackupRepositoryVO repo = new BackupRepositoryVO(1L, "nas",
"test-repo",
+ "nfs", "address", "sync", 1024L, null);
+
Mockito.when(backupRepositoryDao.findByBackupOfferingId(offeringId)).thenReturn(repo);
+
Mockito.when(vmInstanceDao.findByIdIncludingRemoved(vmId)).thenReturn(vm);
+
+ // Leaf details. CHAIN_POSITION=1 puts the leaf after the full anchor
in the
+ // ordered chain — getChainOrderedLeafToRoot sorts by CHAIN_POSITION
descending.
+ BackupDetailVO leafChainId = new BackupDetailVO(51L,
NASBackupChainKeys.CHAIN_ID, "chain-1", true);
+ BackupDetailVO leafChainPos = new BackupDetailVO(51L,
NASBackupChainKeys.CHAIN_POSITION, "1", true);
+ Mockito.when(backupDetailsDao.findDetail(51L,
NASBackupChainKeys.CHAIN_ID)).thenReturn(leafChainId);
+ Mockito.when(backupDetailsDao.findDetail(51L,
NASBackupChainKeys.CHAIN_POSITION)).thenReturn(leafChainPos);
+
+ // Parent is the tombstoned full anchor (CHAIN_POSITION=0).
+ BackupDetailVO parentChainId = new BackupDetailVO(50L,
NASBackupChainKeys.CHAIN_ID, "chain-1", true);
+ BackupDetailVO parentChainPos = new BackupDetailVO(50L,
NASBackupChainKeys.CHAIN_POSITION, "0", true);
+ // The parent is the tombstone — now represented by Status.Hidden (was
the DELETE_PENDING detail).
+ parent.setStatus(Backup.Status.Hidden);
+ Mockito.when(backupDetailsDao.findDetail(50L,
NASBackupChainKeys.CHAIN_ID)).thenReturn(parentChainId);
+ Mockito.when(backupDetailsDao.findDetail(50L,
NASBackupChainKeys.CHAIN_POSITION)).thenReturn(parentChainPos);
+ // Parent has no parent of its own (it's the full anchor).
+ Mockito.when(backupDetailsDao.findDetail(50L,
NASBackupChainKeys.PARENT_BACKUP_ID)).thenReturn(null);
+
+ // listByVmId is called once now (chain snapshot taken before the leaf
delete).
+ // We still use a mutable list + remove() answer so the DAO contract
is realistic.
+ java.util.List<Backup> liveBackups = new
java.util.ArrayList<>(List.of(parent, leaf));
+ Mockito.when(backupDao.listByVmId(null, vmId)).thenAnswer(inv -> new
java.util.ArrayList<>(liveBackups));
+
+ // Agent acknowledges every delete.
+ Mockito.when(agentManager.send(Mockito.anyLong(),
Mockito.any(DeleteBackupCommand.class)))
+ .thenReturn(new BackupAnswer(new DeleteBackupCommand(null,
null, null, null), true, "ok"));
+ // backupDao.remove(id) drops the corresponding row from the live list
so the next
+ // listByVmId call reflects post-delete state — mirrors the real DAO
contract.
+ Mockito.when(backupDao.remove(Mockito.anyLong())).thenAnswer(inv -> {
+ Long id = inv.getArgument(0);
+ liveBackups.removeIf(b -> b.getId() == id);
+ return true;
+ });
+
+ boolean result = nasBackupProvider.deleteBackup(leaf, false);
+ Assert.assertTrue(result);
+
+ // Both backups must be physically deleted (leaf first, then
tombstoned parent).
+ Mockito.verify(agentManager, Mockito.times(2))
+ .send(Mockito.anyLong(),
Mockito.any(DeleteBackupCommand.class));
+ Mockito.verify(backupDao).remove(51L);
+ Mockito.verify(backupDao).remove(50L);
+ // Exactly-once resource accounting: decremented for BOTH
physically-removed backups
+ // (leaf + swept ancestor), not just one.
+ Mockito.verify(resourceLimitMgr, Mockito.times(2))
+ .decrementResourceCount(Mockito.anyLong(),
Mockito.eq(Resource.ResourceType.backup));
+ Mockito.verify(resourceLimitMgr, Mockito.times(2))
+ .decrementResourceCount(Mockito.anyLong(),
Mockito.eq(Resource.ResourceType.backup_storage), Mockito.any());
+ }
}
diff --git
a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRestoreBackupCommandWrapper.java
b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRestoreBackupCommandWrapper.java
index 22dbfbdd67a..cc2a0868fe1 100644
---
a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRestoreBackupCommandWrapper.java
+++
b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRestoreBackupCommandWrapper.java
@@ -60,6 +60,15 @@ public class LibvirtRestoreBackupCommandWrapper extends
CommandWrapper<RestoreBa
private static final String ATTACH_RBD_DISK_XML_COMMAND = " virsh
attach-device %s /dev/stdin <<EOF%sEOF";
private static final String CURRRENT_DEVICE = "virsh domblklist --domain
%s | tail -n 3 | head -n 1 | awk '{print $1}'";
private static final String RSYNC_COMMAND = "rsync -az %s %s";
+ // Flattens the backing-file chain into a single self-contained qcow2
written to the
+ // destination volume path. Used when the source backup is an incremental
whose qcow2
+ // has a backing reference to its parent (chain set up by nasbackup.sh's
qemu-img rebase).
+ private static final String QEMU_IMG_FLATTEN_COMMAND = "qemu-img convert
-O qcow2 %s %s";
+ // Detects whether a qcow2 file references a parent in its backing-file
metadata.
+ // Returns 0 (true) when a backing file is present, 1 when not. Uses
--output=json
+ // so the test is robust to qemu-img version differences in human-readable
output.
+ private static final String QEMU_IMG_HAS_BACKING_COMMAND =
+ "qemu-img info --output=json %s 2>/dev/null | grep -q
'\"backing-filename\"'";
private String getVolumeUuidFromPath(String volumePath, PrimaryDataStoreTO
volumePool) {
if (Storage.StoragePoolType.Linstor.equals(volumePool.getPoolType())) {
@@ -270,10 +279,27 @@ public class LibvirtRestoreBackupCommandWrapper extends
CommandWrapper<RestoreBa
return replaceBlockDeviceWithBackup(storagePoolMgr, volumePool,
volumePath, backupPath, timeout, createTargetVolume, size);
}
+ // For NAS-backed incremental backups, the source qcow2 has a
backing-file
+ // reference to its parent (set by nasbackup.sh's qemu-img rebase). A
plain
+ // rsync would copy only the differential blocks, leaving a volume that
+ // depends on a backing file the primary storage doesn't have. Flatten
the
+ // chain via qemu-img convert, which follows the backing-file links and
+ // produces a single self-contained qcow2.
+ if (hasBackingChain(backupPath)) {
+ int flattenExit = Script.runSimpleBashScriptForExitValue(
+ String.format(QEMU_IMG_FLATTEN_COMMAND, backupPath,
volumePath), timeout, false);
+ return flattenExit == 0;
+ }
+
int exitValue =
Script.runSimpleBashScriptForExitValue(String.format(RSYNC_COMMAND, backupPath,
volumePath), timeout, false);
return exitValue == 0;
}
+ private boolean hasBackingChain(String qcow2Path) {
+ return Script.runSimpleBashScriptForExitValue(
+ String.format(QEMU_IMG_HAS_BACKING_COMMAND, qcow2Path)) == 0;
+ }
+
private boolean replaceBlockDeviceWithBackup(KVMStoragePoolManager
storagePoolMgr, PrimaryDataStoreTO volumePool, String volumePath, String
backupPath, int timeout, boolean createTargetVolume, Long size) {
KVMStoragePool volumeStoragePool =
storagePoolMgr.getStoragePool(volumePool.getPoolType(), volumePool.getUuid());
QemuImg qemu;
diff --git
a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtTakeBackupCommandWrapper.java
b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtTakeBackupCommandWrapper.java
index 42953aa9f83..106fe31a0f1 100644
---
a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtTakeBackupCommandWrapper.java
+++
b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtTakeBackupCommandWrapper.java
@@ -42,6 +42,16 @@ import java.util.Objects;
@ResourceWrapper(handles = TakeBackupCommand.class)
public class LibvirtTakeBackupCommandWrapper extends
CommandWrapper<TakeBackupCommand, Answer, LibvirtComputingResource> {
private static final Integer EXIT_CLEANUP_FAILED = 20;
+ // nasbackup.sh prints this on stdout when it could not proceed as an
incremental and
+ // completed a full backup instead; the orchestrator then records the
backup as a full.
+ private static final String INCREMENTAL_FALLBACK_MARKER =
"INCREMENTAL_FALLBACK=true";
+
+ private static final String MODE_FULL = "full";
+ private static final String MODE_INCREMENTAL = "incremental";
+ // Incremental feature disabled: plain full backup with no QEMU
bitmap/checkpoint and no
+ // chain metadata. Matches nasbackup.sh's "legacy-full" mode
(make_checkpoint=0).
+ private static final String MODE_LEGACY_FULL = "legacy-full";
+
@Override
public Answer execute(TakeBackupCommand command, LibvirtComputingResource
libvirtComputingResource) {
final String vmName = command.getVmName();
@@ -54,6 +64,13 @@ public class LibvirtTakeBackupCommandWrapper extends
CommandWrapper<TakeBackupCo
KVMStoragePoolManager storagePoolMgr =
libvirtComputingResource.getStoragePoolMgr();
int timeout = command.getWait() > 0 ? command.getWait() * 1000 :
libvirtComputingResource.getCmdsTimeout();
+ // Pre-validate incremental args here rather than relying on the
script to error out.
+ // Keeps the script agnostic to caller policy (it just does what it's
told).
+ String validationError = validateBackupArgs(command);
+ if (validationError != null) {
+ return new BackupAnswer(command, false, validationError);
+ }
+
List<String> diskPaths = new ArrayList<>();
if (Objects.nonNull(volumePaths)) {
for (int idx = 0; idx < volumePaths.size(); idx++) {
@@ -69,8 +86,63 @@ public class LibvirtTakeBackupCommandWrapper extends
CommandWrapper<TakeBackupCo
}
}
- List<String[]> commands = new ArrayList<>();
- commands.add(new String[]{
+ Pair<Integer, String> result =
runBackupScript(libvirtComputingResource, command, vmName, backupRepoType,
backupRepoAddress,
+ mountOptions, backupPath, diskPaths, command.getMode(),
+ command.getBitmapNew(), command.getBitmapParent(),
command.getParentPaths(), timeout);
+
+ if (result.first() != 0) {
+ logger.debug("Failed to take VM backup: " + result.second());
+ BackupAnswer answer = new BackupAnswer(command, false,
result.second().trim());
+ if (EXIT_CLEANUP_FAILED.equals(result.first())) {
+ logger.debug("Backup cleanup failed");
+ answer.setNeedsCleanup(true);
+ }
+ return answer;
+ }
+
+ // 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, then strip the marker line before parsing the
backup size.
+ String rawStdout = result.second();
+ boolean incrementalFallback =
rawStdout.contains(INCREMENTAL_FALLBACK_MARKER);
+ String stdout = stripMarkerLines(rawStdout).trim();
+ long backupSize = parseBackupSize(stdout, diskPaths);
+
+ BackupAnswer answer = new BackupAnswer(command, true, stdout);
+ answer.setSize(backupSize);
+ // A successful run always created command.getBitmapNew() (full and
incremental both do;
+ // it is null for legacy-full, which the orchestrator treats as "no
bitmap").
+ answer.setBitmapCreated(command.getBitmapNew());
+ answer.setIncrementalFallback(incrementalFallback);
+ return answer;
+ }
+
+ /** Remove nasbackup.sh's stdout signalling marker lines so they don't
pollute size parsing. */
+ private String stripMarkerLines(String stdout) {
+ if (stdout == null || stdout.isEmpty()) {
+ return "";
+ }
+ StringBuilder sb = new StringBuilder();
+ for (String line : stdout.split("\n", -1)) {
+ if (line.contains(INCREMENTAL_FALLBACK_MARKER)) {
+ continue;
+ }
+ if (sb.length() > 0) {
+ sb.append('\n');
+ }
+ sb.append(line);
+ }
+ return sb.toString();
+ }
+
+ /**
+ * Run nasbackup.sh once with the given args. Returns the exit code +
captured stdout.
+ */
+ private Pair<Integer, String> runBackupScript(LibvirtComputingResource
libvirtComputingResource,
+ TakeBackupCommand command, String vmName, String backupRepoType,
String backupRepoAddress,
+ String mountOptions, String backupPath, List<String> diskPaths,
String mode,
+ String bitmapNew, String bitmapParent, List<String> parentPaths,
int timeout) {
+ List<String> argv = new ArrayList<>(Arrays.asList(
libvirtComputingResource.getNasBackupPath(),
"-o", "backup",
"-v", vmName,
@@ -80,35 +152,79 @@ public class LibvirtTakeBackupCommandWrapper extends
CommandWrapper<TakeBackupCo
"-p", backupPath,
"-q", command.getQuiesce() != null && command.getQuiesce() ?
"true" : "false",
"-d", diskPaths.isEmpty() ? "" : String.join(",", diskPaths)
- });
+ ));
+ if (mode != null && !mode.isEmpty()) {
+ argv.add("-M");
+ argv.add(mode);
+ }
+ if (bitmapNew != null && !bitmapNew.isEmpty()) {
+ argv.add("--bitmap-new");
+ argv.add(bitmapNew);
+ }
+ if (bitmapParent != null && !bitmapParent.isEmpty()) {
+ argv.add("--bitmap-parent");
+ argv.add(bitmapParent);
+ }
+ if (parentPaths != null && !parentPaths.isEmpty()) {
+ argv.add("--parent-paths");
+ argv.add(String.join(",", parentPaths));
+ }
- Pair<Integer, String> result = Script.executePipedCommands(commands,
timeout);
+ List<String[]> commands = new ArrayList<>();
+ commands.add(argv.toArray(new String[0]));
+ return Script.executePipedCommands(commands, timeout);
+ }
- if (result.first() != 0) {
- logger.debug("Failed to take VM backup: " + result.second());
- BackupAnswer answer = new BackupAnswer(command, false,
result.second().trim());
- if (result.first() == EXIT_CLEANUP_FAILED) {
- logger.debug("Backup cleanup failed");
- answer.setNeedsCleanup(true);
+ /**
+ * Return a human-readable validation error string, or {@code null} if the
command's
+ * incremental-backup args are internally consistent.
+ */
+ private String validateBackupArgs(TakeBackupCommand command) {
+ String mode = command.getMode();
+ if (mode == null || mode.isEmpty()) {
+ return null; // legacy full-only — no extra args expected
+ }
+ if (MODE_INCREMENTAL.equals(mode)) {
+ if (command.getBitmapNew() == null ||
command.getBitmapNew().isEmpty()) {
+ return "incremental mode requires bitmapNew";
}
- return answer;
+ if (command.getBitmapParent() == null ||
command.getBitmapParent().isEmpty()) {
+ return "incremental mode requires bitmapParent";
+ }
+ if (command.getParentPaths() == null ||
command.getParentPaths().isEmpty()) {
+ return "incremental mode requires parentPaths";
+ }
+ return null;
+ }
+ if (MODE_FULL.equals(mode)) {
+ if (command.getBitmapNew() == null ||
command.getBitmapNew().isEmpty()) {
+ return "full mode requires bitmapNew (the bitmap to create for
the next incremental)";
+ }
+ return null;
+ }
+ if (MODE_LEGACY_FULL.equals(mode)) {
+ return null; // feature-off full backup — no bitmap or chain args
expected
}
+ return "Unknown backup mode: " + mode;
+ }
+ /**
+ * Sum the per-disk size lines emitted by nasbackup.sh. Single-volume mode
emits one
+ * line containing just the byte count; multi-volume mode emits one line
per disk
+ * whose first whitespace-separated token is the byte count.
+ */
+ private long parseBackupSize(String stdout, List<String> diskPaths) {
long backupSize = 0L;
if (CollectionUtils.isNullOrEmpty(diskPaths)) {
- List<String> outputLines =
Arrays.asList(result.second().trim().split("\n"));
+ List<String> outputLines = Arrays.asList(stdout.split("\n"));
if (!outputLines.isEmpty()) {
backupSize = Long.parseLong(outputLines.get(outputLines.size()
- 1).trim());
}
} else {
- String[] outputLines = result.second().trim().split("\n");
- for(String line : outputLines) {
+ for (String line : stdout.split("\n")) {
backupSize = backupSize + Long.parseLong(line.split("
")[0].trim());
}
}
-
- BackupAnswer answer = new BackupAnswer(command, true,
result.second().trim());
- answer.setSize(backupSize);
- return answer;
+ return backupSize;
}
}
diff --git
a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRestoreBackupCommandWrapperTest.java
b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRestoreBackupCommandWrapperTest.java
index ef6b5c08189..fd8a3b02e0a 100644
---
a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRestoreBackupCommandWrapperTest.java
+++
b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRestoreBackupCommandWrapperTest.java
@@ -407,6 +407,8 @@ public class LibvirtRestoreBackupCommandWrapperTest {
return 0; // File exists
} else if (command.contains("qemu-img check")) {
return 0; // File is valid
+ } else if (command.contains("qemu-img info") &&
command.contains("backing-filename")) {
+ return 1; // No backing chain — exercise the
rsync path (full backups)
}
return 0; // Other commands success
});
diff --git
a/plugins/hypervisors/vmware/src/main/java/com/cloud/hypervisor/guru/VMwareGuru.java
b/plugins/hypervisors/vmware/src/main/java/com/cloud/hypervisor/guru/VMwareGuru.java
index 1a8d9f7b59e..287601d47d6 100644
---
a/plugins/hypervisors/vmware/src/main/java/com/cloud/hypervisor/guru/VMwareGuru.java
+++
b/plugins/hypervisors/vmware/src/main/java/com/cloud/hypervisor/guru/VMwareGuru.java
@@ -1164,7 +1164,7 @@ public class VMwareGuru extends HypervisorGuruBase
implements HypervisorGuru, Co
@Override
public VirtualMachine importVirtualMachineFromBackup(long zoneId, long
domainId, long accountId, long userId, String vmInternalName, Backup backup)
throws Exception {
logger.debug(String.format("Trying to import VM [vmInternalName: %s]
from Backup [%s].", vmInternalName,
-
ReflectionToStringBuilderUtils.reflectOnlySelectedFields(backup, "id", "uuid",
"vmId", "externalId", "backupType")));
+
ReflectionToStringBuilderUtils.reflectOnlySelectedFields(backup, "id", "uuid",
"vmId", "externalId", "type")));
DatacenterMO dcMo = getDatacenterMO(zoneId);
VirtualMachineMO vmToImport = dcMo.findVm(vmInternalName);
if (vmToImport == null) {
diff --git a/scripts/vm/hypervisor/kvm/nasbackup.sh
b/scripts/vm/hypervisor/kvm/nasbackup.sh
index b35908a433e..d252b1f4e97 100755
--- a/scripts/vm/hypervisor/kvm/nasbackup.sh
+++ b/scripts/vm/hypervisor/kvm/nasbackup.sh
@@ -33,6 +33,15 @@ MOUNT_OPTS=""
BACKUP_DIR=""
DISK_PATHS=""
QUIESCE=""
+# Incremental backup parameters (all optional; legacy callers omit them)
+MODE="" # "full" or "incremental"; empty => legacy full-only
behavior (no checkpoint created)
+BITMAP_NEW="" # Bitmap/checkpoint name to create with this backup
(e.g. "backup-1711586400")
+BITMAP_PARENT="" # For incremental: parent bitmap name to read changes
since
+PARENT_PATHS="" # For incremental: comma-separated list of parent backup
file paths,
+ # one per VM volume in the same order as DISK_PATHS.
Each new qcow2
+ # is rebased onto its corresponding parent file.
Required because
+ # data-disk backup files don't share the root volume's
UUID, so
+ # each disk must be rebased onto its own parent.
logFile="/var/log/cloudstack/agent/agent.log"
UNMOUNT_TIMEOUT=60
EXIT_CLEANUP_FAILED=20
@@ -113,20 +122,117 @@ backup_running_vm() {
mount_operation
mkdir -p "$dest" || { echo "Failed to create backup directory $dest"; exit
1; }
+ # Determine effective mode for this run.
+ # Legacy callers (no -M argument) get the original full-only behavior with
no checkpoint.
+ # The Java wrapper (LibvirtTakeBackupCommandWrapper) pre-validates required
args before
+ # invoking the script; the case below is a defensive fallback for direct
invocations.
+ local effective_mode="${MODE:-legacy-full}"
+ local make_checkpoint=0
+ case "$effective_mode" in
+ incremental|full)
+ make_checkpoint=1
+ ;;
+ legacy-full)
+ make_checkpoint=0
+ ;;
+ *)
+ echo "Unknown mode: $effective_mode"
+ cleanup
+ exit 1
+ ;;
+ esac
+
+ # Incremental needs the parent checkpoint registered with libvirt.
CloudStack rebuilds the
+ # domain XML on every VM start, wiping libvirt's checkpoint registry while
the dirty bitmap
+ # persists on the qcow2, so a fresh checkpoint-create fails with "Bitmap
already exists".
+ # Re-register the parent with --redefine (needs only a name + creationTime)
via a minimal
+ # synthesized XML. If the parent bitmap is missing from the qcow2 (e.g.
after a migration),
+ # fall back to a full backup instead of letting backup-begin fail below.
+ if [[ "$effective_mode" == "incremental" ]]; then
+ # The parent bitmap must be present on EVERY disk, not just one. A
snapshot restore or partial
+ # migration can wipe it on some disks; require it on all by comparing the
disk count to the
+ # number of disks that carry it.
+ disk_count=$(virsh -c qemu:///system domblklist "$VM" --details
2>/dev/null | awk '$2=="disk"{c++} END{print c+0}')
+ # Count per-device (one per inserted.file whose dirty-bitmaps holds the
parent), mirroring
+ # getVmDiskPathHasFromCheckpointMap(): query-block lists a bitmap under
multiple nodes, so raw
+ # name matches double-count. "|| echo 0" keeps a no-match from aborting
under "set -eo pipefail"
+ # before the fallback runs.
+ bitmap_count=$(virsh -c qemu:///system qemu-monitor-command "$VM"
'{"execute":"query-block"}' 2>/dev/null | python3 -c '
+import sys, json
+target = sys.argv[1]
+try:
+ data = json.load(sys.stdin)
+except Exception:
+ print(0); sys.exit(0)
+files = set()
+for dev in data.get("return", []) or []:
+ inserted = dev.get("inserted") or {}
+ f = inserted.get("file")
+ if not f:
+ continue
+ if any((b or {}).get("name") == target for b in
(inserted.get("dirty-bitmaps") or [])):
+ files.add(f)
+print(len(files))
+' "$BITMAP_PARENT" 2>/dev/null || echo 0)
+ if [[ "$disk_count" -eq 0 || "$bitmap_count" -lt "$disk_count" ]]; then
+ log -e "incremental: parent bitmap $BITMAP_PARENT present on
$bitmap_count/$disk_count disk(s) — falling back to full"
+ echo "INCREMENTAL_FALLBACK=true"
+ effective_mode="full"
+ fi
+ fi
+
+ if [[ "$effective_mode" == "incremental" ]]; then
+ if ! virsh -c qemu:///system checkpoint-list "$VM" --name 2>/dev/null |
grep -qx "$BITMAP_PARENT"; then
+ redefine_xml=$(mktemp)
+ printf
'<domaincheckpoint><name>%s</name><creationTime>%s</creationTime></domaincheckpoint>'
\
+ "$BITMAP_PARENT" "$(date +%s)" > "$redefine_xml"
+ if virsh -c qemu:///system checkpoint-create "$VM" --xmlfile
"$redefine_xml" --redefine > /dev/null 2>&1; then
+ rm -f "$redefine_xml" # parent checkpoint re-registered; the
incremental can proceed against it
+ else
+ rm -f "$redefine_xml"
+ # Parent checkpoint could not be re-registered — fall back to a full
backup in place so
+ # the chain restarts cleanly instead of failing. Emit a stdout marker
so the wrapper
+ # records this backup as a full (incrementalFallback=true).
+ log -e "incremental: parent checkpoint $BITMAP_PARENT could not be
re-registered — falling back to full"
+ echo "INCREMENTAL_FALLBACK=true"
+ effective_mode="full"
+ fi
+ fi
+ fi
+
+ # Build backup XML (and matching checkpoint XML when applicable).
name="root"
- echo "<domainbackup mode='push'><disks>" > $dest/backup.xml
+ echo "<domainbackup mode='push'>" > $dest/backup.xml
+ if [[ "$effective_mode" == "incremental" ]]; then
+ echo "<incremental>$BITMAP_PARENT</incremental>" >> $dest/backup.xml
+ fi
+ echo "<disks>" >> $dest/backup.xml
+ if [[ $make_checkpoint -eq 1 ]]; then
+ echo "<domaincheckpoint><name>$BITMAP_NEW</name><disks>" >
$dest/checkpoint.xml
+ fi
while read -r disk fullpath; do
if [[ "$fullpath" == /dev/drbd/by-res/* ]]; then
volUuid=$(get_linstor_uuid_from_path "$fullpath")
else
volUuid="${fullpath##*/}"
fi
- echo "<disk name='$disk' backup='yes' type='file'
backupmode='full'><driver type='qcow2'/><target
file='$dest/$name.$volUuid.qcow2' /></disk>" >> $dest/backup.xml
+ if [[ "$effective_mode" == "incremental" ]]; then
+ # Incremental disk entry — no backupmode attr, libvirt picks it up from
<incremental>.
+ echo "<disk name='$disk' backup='yes' type='file'><driver
type='qcow2'/><target file='$dest/$name.$volUuid.qcow2' /></disk>" >>
$dest/backup.xml
+ else
+ echo "<disk name='$disk' backup='yes' type='file'
backupmode='full'><driver type='qcow2'/><target
file='$dest/$name.$volUuid.qcow2' /></disk>" >> $dest/backup.xml
+ fi
+ if [[ $make_checkpoint -eq 1 ]]; then
+ echo "<disk name='$disk'/>" >> $dest/checkpoint.xml
+ fi
name="datadisk"
done < <(
virsh -c qemu:///system domblklist "$VM" --details 2>/dev/null | awk
'$2=="disk"{print $3, $4}'
)
echo "</disks></domainbackup>" >> $dest/backup.xml
+ if [[ $make_checkpoint -eq 1 ]]; then
+ echo "</disks></domaincheckpoint>" >> $dest/checkpoint.xml
+ fi
local thaw=0
if [[ ${QUIESCE} == "true" ]]; then
@@ -135,14 +241,22 @@ backup_running_vm() {
fi
fi
- # Start push backup
+ # Start push backup, atomically registering the new checkpoint when
applicable.
local backup_begin=0
- if virsh -c qemu:///system backup-begin --domain $VM --backupxml
$dest/backup.xml 2>&1 > /dev/null; then
- backup_begin=1;
+ if [[ $make_checkpoint -eq 1 ]]; then
+ # Order matters: redirect stdout to /dev/null first, then merge stderr
into stdout.
+ # The reversed `2>&1 > /dev/null` form leaves stderr pointing at the
original tty.
+ if virsh -c qemu:///system backup-begin --domain $VM --backupxml
$dest/backup.xml --checkpointxml $dest/checkpoint.xml > /dev/null 2>&1; then
+ backup_begin=1;
+ fi
+ else
+ if virsh -c qemu:///system backup-begin --domain $VM --backupxml
$dest/backup.xml > /dev/null 2>&1; then
+ backup_begin=1;
+ fi
fi
if [[ $thaw -eq 1 ]]; then
- if ! response=$(virsh -c qemu:///system qemu-agent-command "$VM"
'{"execute":"guest-fsfreeze-thaw"}' 2>&1 > /dev/null); then
+ if ! response=$(virsh -c qemu:///system qemu-agent-command "$VM"
'{"execute":"guest-fsfreeze-thaw"}' 2>&1); then
echo "Failed to thaw the filesystem for vm $VM: $response"
cleanup
exit 1
@@ -173,9 +287,47 @@ backup_running_vm() {
sleep 5
done
- # Use qemu-img convert to sparsify linstor backups which get bloated due to
virsh backup-begin.
+ # Sparsify behavior:
+ # - For LINSTOR backups (existing): qemu-img convert sparsifies the bloated
output.
+ # - For INCREMENTAL: rebase the resulting thin qcow2 onto its parent so the
chain is self-describing
+ # (so a future restore can flatten without external chain metadata).
name="root"
+ # PARENT_PATHS arrives as a comma-separated list, one entry per VM volume in
the same
+ # order as DISK_PATHS. Split into a bash array so we can index by disk
position.
+ local -a parent_paths_arr=()
+ if [[ "$effective_mode" == "incremental" && -n "$PARENT_PATHS" ]]; then
+ IFS=',' read -ra parent_paths_arr <<< "$PARENT_PATHS"
+ fi
+ local disk_idx=0
while read -r disk fullpath; do
+ if [[ "$effective_mode" == "incremental" ]]; then
+ volUuid="${fullpath##*/}"
+ # Pick this disk's specific parent file. Each volume's backup is named
after its
+ # own UUID, so a single PARENT_PATH would wrongly rebase data disks onto
the root
+ # parent.
+ if [[ $disk_idx -ge ${#parent_paths_arr[@]} ]]; then
+ echo "PARENT_PATHS list shorter than DISK_PATHS — missing parent for
disk index $disk_idx"
+ cleanup
+ exit 1
+ fi
+ local this_parent_rel="${parent_paths_arr[$disk_idx]}"
+ local parent_abs="$mount_point/$this_parent_rel"
+ if [[ ! -f "$parent_abs" ]]; then
+ echo "Parent backup file does not exist on NAS: $parent_abs"
+ cleanup
+ exit 1
+ fi
+ local parent_rel
+ parent_rel=$(realpath --relative-to="$dest" "$parent_abs")
+ if ! qemu-img rebase -u -b "$parent_rel" -F qcow2
"$dest/$name.$volUuid.qcow2" >> "$logFile" 2> >(cat >&2); then
+ echo "qemu-img rebase failed for $dest/$name.$volUuid.qcow2 onto
$parent_rel"
+ cleanup
+ exit 1
+ fi
+ name="datadisk"
+ disk_idx=$((disk_idx + 1))
+ continue
+ fi
if [[ "$fullpath" != /dev/drbd/by-res/* ]]; then
continue
fi
@@ -192,9 +344,43 @@ backup_running_vm() {
virsh -c qemu:///system domblklist "$VM" --details 2>/dev/null | awk
'$2=="disk"{print $3, $4}'
)
- rm -f $dest/backup.xml
+ rm -f $dest/backup.xml $dest/checkpoint.xml
sync
+ # Free the parent bitmap now that the incremental is written and rebased:
its delta is captured
+ # here and BITMAP_NEW tracks changes going forward, so it only accrues
metadata/IO cost over a
+ # long chain. Remove it per-disk with block-dirty-bitmap-remove (a clean
free) rather than
+ # checkpoint-delete, which would merge its bits into BITMAP_NEW and re-copy
backed-up regions.
+ # Best-effort: a failure here does not fail the backup, the bitmap is
reclaimed on a later run.
+ if [[ "$effective_mode" == "incremental" && -n "$BITMAP_PARENT" ]]; then
+ while read -r node; do
+ [[ -z "$node" ]] && continue
+ if ! virsh -c qemu:///system qemu-monitor-command "$VM" \
+
"{\"execute\":\"block-dirty-bitmap-remove\",\"arguments\":{\"node\":\"$node\",\"name\":\"$BITMAP_PARENT\"}}"
\
+ > /dev/null 2>>"$logFile"; then
+ log -e "cleanup: failed to remove parent bitmap $BITMAP_PARENT on node
$node (non-fatal)"
+ fi
+ done < <(
+ virsh -c qemu:///system qemu-monitor-command "$VM"
'{"execute":"query-block"}' 2>/dev/null | python3 -c '
+import sys, json
+target = sys.argv[1]
+try:
+ data = json.load(sys.stdin)
+except Exception:
+ sys.exit(0)
+seen = set()
+for dev in data.get("return", []) or []:
+ inserted = dev.get("inserted") or {}
+ node = inserted.get("node-name")
+ if not node or node in seen:
+ continue
+ if any((b or {}).get("name") == target for b in
(inserted.get("dirty-bitmaps") or [])):
+ seen.add(node)
+ print(node)
+' "$BITMAP_PARENT" 2>/dev/null || true
+ )
+ fi
+
# Print statistics
virsh -c qemu:///system domjobinfo $VM --completed
backup_size=$(du -sb "$dest" 2>>"$logFile" | cut -f1) || { log -ne "WARNING:
du failed for $dest, reporting size as 0"; backup_size=0; }
@@ -204,6 +390,8 @@ backup_running_vm() {
}
backup_stopped_vm() {
+ # Stopped VMs cannot use libvirt's backup-begin (no QEMU process); take a
full backup via
+ # qemu-img convert. The orchestrator never sends incremental mode for a
stopped VM.
mount_operation
mkdir -p "$dest" || { echo "Failed to create backup directory $dest"; exit
1; }
@@ -224,6 +412,23 @@ backup_stopped_vm() {
cleanup
exit 1
fi
+
+ # Pre-seed a persistent bitmap on the source disk so the NEXT backup (taken
+ # after this VM is started again) can be incremental against the qcow2 we
+ # just wrote. Without this, every backup after a stopped-VM backup would
+ # fall back to full because no parent bitmap exists on the host yet.
+ # Only applies to file-backed qcow2 sources — RBD/LINSTOR have their own
+ # snapshot mechanisms and qemu-img bitmap is not the right primitive there.
+ # bitmap --add should not fail on a file-backed qcow2; if it does, fail
the backup so the
+ # underlying problem is surfaced rather than silently degrading future
backups to full.
+ if [[ -n "$BITMAP_NEW" && "$disk" != rbd:* && "$disk" !=
/dev/drbd/by-res/* ]]; then
+ if ! qemu-img bitmap --add "$disk" "$BITMAP_NEW" 2>>"$logFile"; then
+ echo "Failed to pre-seed bitmap $BITMAP_NEW on $disk"
+ cleanup
+ exit 1
+ fi
+ fi
+
name="datadisk"
done
sync
@@ -293,6 +498,15 @@ cleanup() {
function usage {
echo ""
echo "Usage: $0 -o <operation> -v|--vm <domain name> -t <storage type> -s
<storage address> -m <mount options> -p <backup path> -d <disks path>
-q|--quiesce <true|false>"
+ echo " [-M|--mode <full|incremental>] [--bitmap-new <name>]
[--bitmap-parent <name>] [--parent-paths <p1,p2,...>]"
+ echo ""
+ echo "Incremental backup options (running VMs only; requires QEMU >= 4.2 and
libvirt >= 7.2):"
+ echo " -M|--mode full Take a full backup AND create a checkpoint
(--bitmap-new required) for future incrementals."
+ echo " -M|--mode incremental Take an incremental backup since
--bitmap-parent and create new checkpoint --bitmap-new."
+ echo " Requires --bitmap-parent, --bitmap-new, and
--parent-paths (comma-separated list, one"
+ echo " parent qcow2 path per disk:
root.<uuid>.qcow2, datadisk.<uuid>.qcow2, … same order"
+ echo " as -d|--disks)."
+ echo " Without -M, behaves as legacy full-only backup with no checkpoint
creation."
echo ""
exit 1
}
@@ -339,6 +553,26 @@ while [[ $# -gt 0 ]]; do
shift
shift
;;
+ -M|--mode)
+ MODE="$2"
+ shift
+ shift
+ ;;
+ --bitmap-new)
+ BITMAP_NEW="$2"
+ shift
+ shift
+ ;;
+ --bitmap-parent)
+ BITMAP_PARENT="$2"
+ shift
+ shift
+ ;;
+ --parent-paths)
+ PARENT_PATHS="$2"
+ shift
+ shift
+ ;;
-h|--help)
usage
shift
@@ -350,7 +584,7 @@ while [[ $# -gt 0 ]]; do
esac
done
-# Perform Initial sanity checks
+# Perform initial environment sanity checks (QEMU/libvirt version).
sanity_checks
if [ "$OP" = "backup" ]; then
diff --git a/server/src/main/java/com/cloud/hypervisor/KVMGuru.java
b/server/src/main/java/com/cloud/hypervisor/KVMGuru.java
index 6c1c3424b1b..6154522af4a 100644
--- a/server/src/main/java/com/cloud/hypervisor/KVMGuru.java
+++ b/server/src/main/java/com/cloud/hypervisor/KVMGuru.java
@@ -351,7 +351,7 @@ public class KVMGuru extends HypervisorGuruBase implements
HypervisorGuru {
@Override
public VirtualMachine importVirtualMachineFromBackup(long zoneId, long
domainId, long accountId, long userId, String vmInternalName, Backup backup) {
logger.debug(String.format("Trying to import VM [vmInternalName: %s]
from Backup [%s].", vmInternalName,
-
ReflectionToStringBuilderUtils.reflectOnlySelectedFields(backup, "id", "uuid",
"vmId", "externalId", "backupType")));
+
ReflectionToStringBuilderUtils.reflectOnlySelectedFields(backup, "id", "uuid",
"vmId", "externalId", "type")));
VMInstanceVO vm =
_instanceDao.findVMByInstanceNameIncludingRemoved(vmInternalName);
if (vm == null) {
diff --git
a/server/src/main/java/org/apache/cloudstack/backup/BackupManagerImpl.java
b/server/src/main/java/org/apache/cloudstack/backup/BackupManagerImpl.java
index f9c98cb141b..0636ffdc3c1 100644
--- a/server/src/main/java/org/apache/cloudstack/backup/BackupManagerImpl.java
+++ b/server/src/main/java/org/apache/cloudstack/backup/BackupManagerImpl.java
@@ -1137,6 +1137,9 @@ public class BackupManagerImpl extends ManagerBase
implements BackupManager {
sb.and("name", sb.entity().getName(), SearchCriteria.Op.EQ);
sb.and("zoneId", sb.entity().getZoneId(), SearchCriteria.Op.EQ);
sb.and("backupOfferingId", sb.entity().getBackupOfferingId(),
SearchCriteria.Op.EQ);
+ // Tombstoned chain backups (Status.Hidden) are never shown to users;
they exist only so the
+ // incremental chain GC can sweep them once their last descendant is
deleted.
+ sb.and("statusNeq", sb.entity().getStatus(), SearchCriteria.Op.NEQ);
sb.and("backupStatus", sb.entity().getStatus(), SearchCriteria.Op.EQ);
if (keyword != null) {
@@ -1149,6 +1152,7 @@ public class BackupManagerImpl extends ManagerBase
implements BackupManager {
SearchCriteria<BackupVO> sc = sb.create();
accountManager.buildACLSearchCriteria(sc, domainId, isRecursive,
permittedAccounts, listProjectResourcesCriteria);
+ sc.setParameters("statusNeq", Backup.Status.Hidden);
if (id != null) {
sc.setParameters("id", id);
@@ -1190,7 +1194,7 @@ public class BackupManagerImpl extends ManagerBase
implements BackupManager {
vm = guru.importVirtualMachineFromBackup(zoneId, domainId,
accountId, userId, vmInternalName, backup);
} catch (final Exception e) {
logger.error(String.format("Failed to import VM [vmInternalName:
%s] from backup restoration [%s] with hypervisor [type: %s] due to: [%s].",
vmInternalName,
-
ReflectionToStringBuilderUtils.reflectOnlySelectedFields(backup, "id", "uuid",
"vmId", "externalId", "backupType"), hypervisorType, e.getMessage()), e);
+
ReflectionToStringBuilderUtils.reflectOnlySelectedFields(backup, "id", "uuid",
"vmId", "externalId", "type"), hypervisorType, e.getMessage()), e);
ActionEventUtils.onCompletedActionEvent(User.UID_SYSTEM,
vm.getAccountId(), EventVO.LEVEL_ERROR, EventTypes.EVENT_VM_BACKUP_RESTORE,
String.format("Failed to import Instance %s from Backup %s
with hypervisor [type: %s]", vmInternalName, backup.getUuid(), hypervisorType),
vm.getId(),
ApiCommandResourceType.VirtualMachine.toString(),0);
@@ -1722,6 +1726,15 @@ public class BackupManagerImpl extends ManagerBase
implements BackupManager {
reservationDao, resourceLimitMgr)) {
boolean result = backupProvider.deleteBackup(backup, forced);
if (result) {
+ // Chain-aware providers (e.g. NAS) physically remove several
backups per call
+ // (leaf + swept delete-pending ancestors) and decrement
resource count/usage and
+ // remove each DB row themselves, exactly once per removed
backup. Decrementing or
+ // removing again here would double-handle and destroy
delete-pending tombstones,
+ // so defer entirely to the provider for those.
+ if (backupProvider.handlesChainDeleteResourceAccounting()) {
+
checkAndGenerateUsageForLastBackupDeletedAfterOfferingRemove(vm, backup);
+ return true;
+ }
resourceLimitMgr.decrementResourceCount(backup.getAccountId(),
Resource.ResourceType.backup);
resourceLimitMgr.decrementResourceCount(backup.getAccountId(),
Resource.ResourceType.backup_storage, backupSize);
if (backupDao.remove(backup.getId())) {
diff --git a/test/integration/smoke/test_backup_recovery_nas.py
b/test/integration/smoke/test_backup_recovery_nas.py
index 409a08acc9f..e55c1b6f0f9 100644
--- a/test/integration/smoke/test_backup_recovery_nas.py
+++ b/test/integration/smoke/test_backup_recovery_nas.py
@@ -265,3 +265,323 @@ class TestNASBackupAndRecovery(cloudstackTestCase):
self.assertEqual(backup_repository.crosszoneinstancecreation, True,
"Cross-Zone Instance Creation could not be enabled on the backup repository")
self.vm_backup_create_vm_from_backup_int(template.id, [network.id])
+
+ # ------------------------------------------------------------------
+ # Incremental backup tests (RFC #12899 / PR #13074)
+ # ------------------------------------------------------------------
+ # These tests exercise the incremental NAS backup chain semantics:
+ # full -> incN cadence, restore-from-incremental, delete-middle chain
+ # repair, refuse-delete-full-with-children, and stopped-VM fallback.
+ #
+ # All tests set nas.backup.full.every to a small value (3) so a chain
+ # forms quickly without needing many backup iterations. The original
+ # zone value (whatever the test environment has configured) is captured
+ # before the test runs and restored verbatim in finally, so we don't
+ # leak config changes across tests on shared environments.
+
+ def _set_full_every(self, value):
+ Configurations.update(self.apiclient, name='nas.backup.full.every',
+ value=str(value), zoneid=self.zone.id)
+
+ def _get_full_every(self):
+ """Read the current zone-scoped (or global fallback) value of
nas.backup.full.every."""
+ configs = Configurations.list(self.apiclient,
name='nas.backup.full.every',
+ zoneid=self.zone.id)
+ if configs and len(configs) > 0 and configs[0].value is not None:
+ return configs[0].value
+ # Fall back to global default — Configurations.list returns the global
value
+ # when no zone override exists. Defensive fallback to '10' (the
framework default).
+ return '10'
+
+ def _backup_type(self, backup):
+ # Backup objects expose `type`; for chained backups it's
"INCREMENTAL", else "FULL".
+ return getattr(backup, 'type', 'FULL') or 'FULL'
+
+ @attr(tags=["advanced", "backup"], required_hardware="true")
+ def test_incremental_chain_cadence(self):
+ """
+ With nas.backup.full.every=3, the sequence of backups should be
+ FULL, INCREMENTAL, INCREMENTAL, FULL, INCREMENTAL, ...
+ """
+ self.backup_offering.assignOffering(self.apiclient, self.vm.id)
+ original_full_every = self._get_full_every()
+ self._set_full_every(3)
+ try:
+ ssh_client_vm = self.vm.get_ssh_client(reconnect=True)
+ ssh_client_vm.execute("touch /root/incremental_marker_1.txt")
+
+ created = []
+ for i in range(5):
+ Backup.create(self.apiclient, self.vm.id, "inc_chain_%d" % i)
+ # write a small change so each incremental has something to
capture
+ ssh_client_vm.execute("dd if=/dev/urandom of=/root/delta_%d
bs=64k count=4 2>/dev/null" % i)
+ time.sleep(2)
+ created = Backup.list(self.apiclient, self.vm.id)
+
+ self.assertEqual(len(created), 5, "Expected 5 backups after 5
Backup.create calls")
+ # Sort oldest-first by date
+ created.sort(key=lambda b: b.created)
+
+ expected = ['FULL', 'INCREMENTAL', 'INCREMENTAL', 'FULL',
'INCREMENTAL']
+ actual = [self._backup_type(b).upper() for b in created]
+ self.assertEqual(actual, expected,
+ "With nas.backup.full.every=3, chain pattern should be %s but
was %s" % (expected, actual))
+
+ # Cleanup all backups (newest first to satisfy chain rules without
forced=true)
+ for b in reversed(created):
+ Backup.delete(self.apiclient, b.id)
+ finally:
+ self._set_full_every(original_full_every)
+ self.backup_offering.removeOffering(self.apiclient, self.vm.id)
+
+ @attr(tags=["advanced", "backup"], required_hardware="true")
+ def test_incremental_after_vm_restart(self):
+ """
+ Regression for the parent-checkpoint recreation bug (PR #13074): an
incremental
+ backup taken AFTER the VM has been restarted must still succeed and
restore
+ correctly.
+
+ A VM (re)start rebuilds the libvirt domain XML and wipes libvirt's
checkpoint
+ registry, while the dirty bitmap persists on the qcow2. The agent must
then
+ re-register the parent checkpoint with `checkpoint-create --redefine`
(from the
+ saved checkpoint XML) rather than a fresh create — a fresh create
fails with
+ "Bitmap already exists", and qemu-img cannot drop the bitmap on a
running disk.
+
+ How this was reproduced manually on a libvirt 10.0.0 host, and what
this test
+ automates:
+ FULL + marker1 -> stop/start the VM (wipes the checkpoint
registry)
+ -> INCREMENTAL + marker2 -> restore the tip -> both markers
present.
+ """
+ self.backup_offering.assignOffering(self.apiclient, self.vm.id)
+ original_full_every = self._get_full_every()
+ # High cadence so the post-restart backup is INCREMENTAL, not a
periodic FULL.
+ self._set_full_every(100)
+ backups = []
+ try:
+ ssh_client_vm = self.vm.get_ssh_client(reconnect=True)
+ ssh_client_vm.execute("echo restart-test-1 >
/root/restart_marker_1.txt; sync")
+
+ # 1) FULL anchor
+ Backup.create(self.apiclient, self.vm.id, "restart_full")
+ time.sleep(2)
+
+ # 2) Restart the VM — wipes libvirt's checkpoint registry (the bug
trigger).
+ self.vm.stop(self.apiclient)
+ self.vm.start(self.apiclient)
+ ssh_client_vm = self.vm.get_ssh_client(reconnect=True)
+ ssh_client_vm.execute("echo restart-test-2 >
/root/restart_marker_2.txt; sync")
+
+ # 3) INCREMENTAL after the restart — the previously-broken path.
+ Backup.create(self.apiclient, self.vm.id, "restart_incr")
+ time.sleep(2)
+
+ backups = Backup.list(self.apiclient, self.vm.id)
+ self.assertEqual(len(backups), 2,
+ "Expected FULL + INCREMENTAL after restart, got %d" %
len(backups))
+ backups.sort(key=lambda b: b.created)
+ self.assertEqual(self._backup_type(backups[0]).upper(), 'FULL',
+ "First backup should be FULL")
+ self.assertEqual(self._backup_type(backups[1]).upper(),
'INCREMENTAL',
+ "Backup taken after the VM restart must be INCREMENTAL, not
silently a FULL")
+
+ # 4) Restore the tip (incremental) and verify BOTH markers
survived the chain
+ # across the restart — i.e. the post-restart incremental really
captured data.
+ new_vm_name = "vm-restart-restore-" + str(int(time.time()))
+ new_vm = Backup.createVMFromBackup(
+ self.apiclient,
+ self.services["small"],
+ mode=self.services["mode"],
+ backupid=backups[1].id,
+ vmname=new_vm_name,
+ accountname=self.account.name,
+ domainid=self.account.domainid,
+ zoneid=self.zone.id
+ )
+ self.cleanup.append(new_vm)
+ self.assertIsNotNone(new_vm, "Failed to create VM from the
post-restart incremental backup")
+ self.assertEqual(new_vm.state, "Running", "Restored VM should be
Running")
+
+ ssh_new = new_vm.get_ssh_client(reconnect=True)
+ r1 = "".join(ssh_new.execute("cat /root/restart_marker_1.txt"))
+ r2 = "".join(ssh_new.execute("cat /root/restart_marker_2.txt"))
+ self.assertIn("restart-test-1", r1,
+ "Marker written before the restart is missing from the
restore")
+ self.assertIn("restart-test-2", r2,
+ "Marker written after the restart (captured by the
post-restart incremental) "
+ "is missing from the restore")
+ finally:
+ for b in reversed(backups):
+ try:
+ Backup.delete(self.apiclient, b.id)
+ except Exception:
+ pass
+ self._set_full_every(original_full_every)
+ self.backup_offering.removeOffering(self.apiclient, self.vm.id)
+
+ @attr(tags=["advanced", "backup"], required_hardware="true")
+ def test_restore_from_incremental(self):
+ """
+ Take FULL + 2 INCREMENTAL backups, each with a marker file. Restore
from the
+ latest incremental and verify all three markers are present (chain
flatten).
+ """
+ self.backup_offering.assignOffering(self.apiclient, self.vm.id)
+ original_full_every = self._get_full_every()
+ self._set_full_every(5)
+ try:
+ ssh_client_vm = self.vm.get_ssh_client(reconnect=True)
+ ssh_client_vm.execute("touch /root/marker_full.txt")
+ Backup.create(self.apiclient, self.vm.id, "rfi_full")
+ time.sleep(3)
+
+ ssh_client_vm.execute("touch /root/marker_inc1.txt")
+ Backup.create(self.apiclient, self.vm.id, "rfi_inc1")
+ time.sleep(3)
+
+ ssh_client_vm.execute("touch /root/marker_inc2.txt")
+ Backup.create(self.apiclient, self.vm.id, "rfi_inc2")
+ time.sleep(3)
+
+ backups = Backup.list(self.apiclient, self.vm.id)
+ backups.sort(key=lambda b: b.created)
+ self.assertEqual(len(backups), 3)
+ self.assertEqual(self._backup_type(backups[0]).upper(), 'FULL')
+ self.assertEqual(self._backup_type(backups[2]).upper(),
'INCREMENTAL')
+
+ new_vm_name = "vm-from-inc-" + str(int(time.time()))
+ new_vm = Backup.createVMFromBackup(self.apiclient,
self.services["small"],
+ mode=self.services["mode"], backupid=backups[2].id,
vmname=new_vm_name,
+ accountname=self.account.name, domainid=self.account.domainid,
+ zoneid=self.zone.id)
+ self.cleanup.append(new_vm)
+
+ ssh_new = new_vm.get_ssh_client(reconnect=True)
+ for marker in ("marker_full.txt", "marker_inc1.txt",
"marker_inc2.txt"):
+ result = ssh_new.execute("ls /root/%s" % marker)
+ self.assertIn(marker, result[0],
+ "Restored VM should have %s (chain flattened correctly)" %
marker)
+
+ for b in reversed(backups):
+ Backup.delete(self.apiclient, b.id)
+ finally:
+ self._set_full_every(original_full_every)
+ self.backup_offering.removeOffering(self.apiclient, self.vm.id)
+
+ @attr(tags=["advanced", "backup"], required_hardware="true")
+ def test_delete_middle_incremental_repairs_chain(self):
+ """
+ Delete a MIDDLE incremental from a FULL -> INC1 -> INC2 chain.
+ The chain repair should rebase INC2 onto FULL, and the final restore
+ should still produce a working VM with all expected blocks.
+ """
+ self.backup_offering.assignOffering(self.apiclient, self.vm.id)
+ original_full_every = self._get_full_every()
+ self._set_full_every(5)
+ try:
+ ssh_client_vm = self.vm.get_ssh_client(reconnect=True)
+ ssh_client_vm.execute("touch /root/dmi_full.txt")
+ Backup.create(self.apiclient, self.vm.id, "dmi_full")
+ time.sleep(3)
+ ssh_client_vm.execute("touch /root/dmi_inc1.txt")
+ Backup.create(self.apiclient, self.vm.id, "dmi_inc1")
+ time.sleep(3)
+ ssh_client_vm.execute("touch /root/dmi_inc2.txt")
+ Backup.create(self.apiclient, self.vm.id, "dmi_inc2")
+ time.sleep(3)
+
+ backups = Backup.list(self.apiclient, self.vm.id)
+ backups.sort(key=lambda b: b.created)
+ full, inc1, inc2 = backups[0], backups[1], backups[2]
+
+ # Delete the middle incremental — should succeed via chain repair
(no force needed)
+ Backup.delete(self.apiclient, inc1.id)
+ remaining = Backup.list(self.apiclient, self.vm.id)
+ self.assertEqual(len(remaining), 2, "After deleting middle inc,
two backups should remain")
+
+ # Restore from the remaining tail (formerly inc2) — must still
produce a usable VM
+ new_vm_name = "vm-after-mid-del-" + str(int(time.time()))
+ new_vm = Backup.createVMFromBackup(self.apiclient,
self.services["small"],
+ mode=self.services["mode"], backupid=inc2.id,
vmname=new_vm_name,
+ accountname=self.account.name, domainid=self.account.domainid,
+ zoneid=self.zone.id)
+ self.cleanup.append(new_vm)
+ ssh_new = new_vm.get_ssh_client(reconnect=True)
+ # Both the FULL marker and (importantly) the deleted-INC1 marker
should still
+ # be present, because the rebase merged INC1's blocks into INC2.
+ for marker in ("dmi_full.txt", "dmi_inc1.txt", "dmi_inc2.txt"):
+ result = ssh_new.execute("ls /root/%s" % marker)
+ self.assertIn(marker, result[0],
+ "After mid-incremental delete and rebase, %s should still
be restorable" % marker)
+
+ Backup.delete(self.apiclient, inc2.id)
+ Backup.delete(self.apiclient, full.id)
+ finally:
+ self._set_full_every(original_full_every)
+ self.backup_offering.removeOffering(self.apiclient, self.vm.id)
+
+ @attr(tags=["advanced", "backup"], required_hardware="true")
+ def test_refuse_delete_full_with_children(self):
+ """
+ Deleting a FULL that has surviving incrementals must fail without
forced=true.
+ With forced=true it must succeed and remove the entire chain.
+ """
+ self.backup_offering.assignOffering(self.apiclient, self.vm.id)
+ original_full_every = self._get_full_every()
+ self._set_full_every(5)
+ try:
+ Backup.create(self.apiclient, self.vm.id, "rdc_full")
+ time.sleep(3)
+ Backup.create(self.apiclient, self.vm.id, "rdc_inc")
+ time.sleep(3)
+
+ backups = Backup.list(self.apiclient, self.vm.id)
+ backups.sort(key=lambda b: b.created)
+ full = backups[0]
+
+ failed = False
+ try:
+ Backup.delete(self.apiclient, full.id)
+ except Exception:
+ failed = True
+ self.assertTrue(failed, "Deleting a FULL with children should be
refused without forced=true")
+
+ # Forced delete should succeed and clear the whole chain
+ Backup.delete(self.apiclient, full.id, forced=True)
+ remaining = Backup.list(self.apiclient, self.vm.id)
+ self.assertIsNone(remaining, "Forced delete of FULL should remove
the entire chain")
+ finally:
+ self._set_full_every(original_full_every)
+ self.backup_offering.removeOffering(self.apiclient, self.vm.id)
+
+ @attr(tags=["advanced", "backup"], required_hardware="true")
+ def test_stopped_vm_falls_back_to_full(self):
+ """
+ When a backup is requested while the VM is stopped, even if the chain
cadence
+ would call for an incremental, the agent must fall back to a full and
start a
+ new chain. The incrementalFallback flag should be reflected in
backup.type=FULL.
+ """
+ self.backup_offering.assignOffering(self.apiclient, self.vm.id)
+ original_full_every = self._get_full_every()
+ self._set_full_every(2) # next backup after the first should be
incremental
+ try:
+ Backup.create(self.apiclient, self.vm.id, "svf_first")
+ time.sleep(3)
+
+ # Stop the VM and trigger another backup — should fall back to FULL
+ self.vm.stop(self.apiclient)
+ time.sleep(5)
+ Backup.create(self.apiclient, self.vm.id, "svf_second")
+ time.sleep(3)
+
+ backups = Backup.list(self.apiclient, self.vm.id)
+ backups.sort(key=lambda b: b.created)
+ self.assertEqual(len(backups), 2)
+ self.assertEqual(self._backup_type(backups[0]).upper(), 'FULL')
+ self.assertEqual(self._backup_type(backups[1]).upper(), 'FULL',
+ "Stopped-VM backup must be a FULL even when cadence would have
asked for an INCREMENTAL")
+
+ self.vm.start(self.apiclient)
+ for b in reversed(backups):
+ Backup.delete(self.apiclient, b.id)
+ finally:
+ self._set_full_every(original_full_every)
+ self.backup_offering.removeOffering(self.apiclient, self.vm.id)