This is an automated email from the ASF dual-hosted git repository.

sureshanaparti pushed a commit to branch 4.22
in repository https://gitbox.apache.org/repos/asf/cloudstack.git


The following commit(s) were added to refs/heads/4.22 by this push:
     new 0d380a015a5 Linstor add support for incremental nasbackup (#14194)
0d380a015a5 is described below

commit 0d380a015a588da5e0e09700b1711bec4e672a21
Author: Rene Peinthor <[email protected]>
AuthorDate: Fri Oct 23 10:41:25 2026 +0200

    Linstor add support for incremental nasbackup (#14194)
---
 .../cloudstack/backup/TakeBackupCommand.java       |   9 +
 .../cloudstack/backup/NASBackupChainKeys.java      |  18 ++
 .../cloudstack/backup/NASBackupProvider.java       | 147 ++++++++-
 .../cloudstack/backup/NASBackupProviderTest.java   | 226 ++++++++++++++
 .../LibvirtRestoreBackupCommandWrapper.java        |  14 +-
 .../wrapper/LibvirtTakeBackupCommandWrapper.java   | 213 ++++++++++++-
 .../hypervisor/kvm/storage/KVMStoragePool.java     |  33 ++
 .../LibvirtRestoreBackupCommandWrapperTest.java    |  67 ++++
 .../LibvirtTakeBackupCommandWrapperTest.java       | 280 +++++++++++++++++
 .../kvm/storage/LinstorStorageAdaptor.java         | 225 +++++++++++++-
 .../hypervisor/kvm/storage/LinstorStoragePool.java |  21 ++
 .../kvm/storage/LinstorStorageAdaptorTest.java     | 344 +++++++++++++++++++++
 scripts/vm/hypervisor/kvm/nasbackup.sh             | 285 ++++++++++++++++-
 13 files changed, 1854 insertions(+), 28 deletions(-)

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 7c2d3fc76fc..3928b63dc77 100644
--- a/core/src/main/java/org/apache/cloudstack/backup/TakeBackupCommand.java
+++ b/core/src/main/java/org/apache/cloudstack/backup/TakeBackupCommand.java
@@ -39,6 +39,7 @@ public class TakeBackupCommand extends Command {
 
     // 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 method;        // "qcow2" (bitmaps, push mode) or "content" 
(pull mode); null => "qcow2"
     private String bitmapNew;     // Checkpoint/bitmap name to create with 
this backup (timestamp-based)
     private String bitmapParent;  // Incremental: parent bitmap to read 
changes since
 
@@ -134,6 +135,14 @@ public class TakeBackupCommand extends Command {
         this.mode = mode;
     }
 
+    public String getMethod() {
+        return method;
+    }
+
+    public void setMethod(String method) {
+        this.method = method;
+    }
+
     public String getBitmapNew() {
         return bitmapNew;
     }
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
index 511f0ccb711..28779311449 100644
--- 
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
@@ -48,6 +48,24 @@ public final class NASBackupChainKeys {
     // "legacy-full" mode token (which sets make_checkpoint=0).
     public static final String TYPE_LEGACY_FULL = "legacy-full";
 
+    /**
+     * In-memory backup-method sentinels used by {@code ChainDecision.method}: 
how a backup is
+     * taken, independent of whether it is a full or an incremental ({@code 
ChainDecision.mode}).
+     * Both methods write the same chain of qcow2 files, so restore and delete 
do not care.
+     *
+     * <p>{@link #METHOD_QCOW2}: libvirt push-mode backup; incrementals come 
from QEMU persistent
+     * dirty bitmaps (libvirt checkpoints), which only qcow2 volumes can 
carry.</p>
+     */
+    public static final String METHOD_QCOW2 = "qcow2";
+
+    /**
+     * Content-based libvirt pull-mode backup, used for raw block-device 
storage such as
+     * LINSTOR/DRBD which cannot carry persistent dirty bitmaps. A full is 
written as a sparse
+     * qcow2 in a single pass; an incremental is a qcow2 overlay on the disk's 
NBD export,
+     * safe-rebased onto the parent so it holds only the clusters that differ.
+     */
+    public static final String METHOD_CONTENT = "content";
+
     /**
      * 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
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 60c9983f016..ff59e0f587a 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
@@ -224,11 +224,12 @@ public class NASBackupProvider extends AdapterBase 
implements BackupProvider, Co
 
     /**
      * 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.
+     * the chain: full vs incremental, how it is taken, 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 method;          // NASBackupChainKeys.METHOD_QCOW2 or 
METHOD_CONTENT
         final String bitmapNew;
         final String bitmapParent;    // null for full
         // Per-volume parent backup file paths, one per current VM volume in 
deviceId order.
@@ -237,15 +238,26 @@ public class NASBackupProvider extends AdapterBase 
implements BackupProvider, Co
         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
+        // Content-based chains have no bitmap to look the parent up by, so 
the parent backup's
+        // uuid is resolved when the decision is made. null for bitmap-based 
decisions.
+        final String parentBackupUuid;
 
         private ChainDecision(String mode, String bitmapNew, String 
bitmapParent, List<String> parentPaths,
                               String chainId, int chainPosition) {
+            this(mode, NASBackupChainKeys.METHOD_QCOW2, bitmapNew, 
bitmapParent, parentPaths, chainId,
+                    chainPosition, null);
+        }
+
+        private ChainDecision(String mode, String method, String bitmapNew, 
String bitmapParent,
+                              List<String> parentPaths, String chainId, int 
chainPosition, String parentBackupUuid) {
             this.mode = mode;
+            this.method = method;
             this.bitmapNew = bitmapNew;
             this.bitmapParent = bitmapParent;
             this.parentPaths = parentPaths;
             this.chainId = chainId;
             this.chainPosition = chainPosition;
+            this.parentBackupUuid = parentBackupUuid;
         }
 
         static ChainDecision fullStart(String bitmapName) {
@@ -268,10 +280,30 @@ public class NASBackupProvider extends AdapterBase 
implements BackupProvider, Co
                     parentPaths, chainId, chainPosition);
         }
 
+        /**
+         * Full backup on the content-based (pull mode) path: no bitmap, no 
checkpoint, but it
+         * does anchor a chain so later content-incrementals can hang off it.
+         */
+        static ChainDecision contentFull() {
+            return new ChainDecision(NASBackupChainKeys.TYPE_FULL, 
NASBackupChainKeys.METHOD_CONTENT, null, null,
+                    null, UUID.randomUUID().toString(), 0, null);
+        }
+
+        static ChainDecision contentIncremental(List<String> parentPaths, 
String chainId,
+                                                int chainPosition, String 
parentBackupUuid) {
+            return new ChainDecision(NASBackupChainKeys.TYPE_INCREMENTAL, 
NASBackupChainKeys.METHOD_CONTENT, null,
+                    null, parentPaths, chainId, chainPosition, 
parentBackupUuid);
+        }
+
         boolean isIncremental() {
             return NASBackupChainKeys.TYPE_INCREMENTAL.equals(mode);
         }
 
+        /** True for the pull-mode content-diff path (no dirty bitmaps 
involved). */
+        boolean isContentBased() {
+            return NASBackupChainKeys.METHOD_CONTENT.equals(method);
+        }
+
         boolean isLegacyFull() {
             return NASBackupChainKeys.TYPE_LEGACY_FULL.equals(mode);
         }
@@ -293,15 +325,24 @@ public class NASBackupProvider extends AdapterBase 
implements BackupProvider, Co
         // 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).
+        // This holds for every storage, so enabling incrementals is also the 
opt-in to the
+        // content-based path below and the scratch space it takes from the 
primary storage.
         Boolean incrementalEnabled = 
NASBackupIncrementalEnabled.valueIn(vm.getDataCenterId());
         if (incrementalEnabled == null || !incrementalEnabled) {
             return ChainDecision.legacyFull();
         }
 
+        // Raw block-device storage (LINSTOR/DRBD) cannot carry QEMU 
persistent dirty bitmaps, so
+        // libvirt checkpoints — and with them the push-mode incremental path 
— are unavailable.
+        // Those VMs take the content-based pull-mode path instead.
+        if (allVolumesOnContentDiffCapableStorage(vm)) {
+            return decideContentChain(vm);
+        }
+
         // 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.
+        // on file-based qcow2 storage. Storage such as Ceph-RBD cannot carry 
per-disk checkpoints
+        // and has no content-diff path either, so such a VM stays on the 
full-only (legacy) path —
+        // otherwise an incremental attempt would fail or regress those 
storages.
         if (!allVolumesOnCheckpointCapableStorage(vm)) {
             return ChainDecision.legacyFull();
         }
@@ -365,6 +406,74 @@ public class NASBackupProvider extends AdapterBase 
implements BackupProvider, Co
                 parentChainId, parentChainPosition + 1);
     }
 
+    /**
+     * Chain decision for content-based (pull mode) storage such as 
LINSTOR/DRBD.
+     *
+     * <p>Unlike the bitmap path there is no host-side state to anchor on. The 
delta is derived by
+     * comparing the disk's point-in-time NBD export against the parent 
backup, so the chain cannot
+     * be invalidated by a VM restart, live migration or restore — the 
comparison is stateless. The
+     * parent is therefore simply the most recent BackedUp backup of the same 
chain, and the agent
+     * independently verifies the parent files still exist on the NAS, 
degrading to a full if they
+     * do not (INCREMENTAL_FALLBACK).</p>
+     */
+    protected ChainDecision decideContentChain(VirtualMachine vm) {
+        // Stopped VMs are backed up straight from the disk with qemu-img 
convert; that path has no
+        // point-in-time export to diff against a parent, so it is always a 
full.
+        if (VirtualMachine.State.Stopped.equals(vm.getState())) {
+            return ChainDecision.contentFull();
+        }
+
+        Integer fullEvery = NASBackupFullEvery.valueIn(vm.getDataCenterId());
+        if (fullEvery == null || fullEvery <= 1) {
+            return ChainDecision.contentFull();
+        }
+
+        Backup parent = findLatestBackedUpBackup(vm.getId());
+        if (parent == null) {
+            return ChainDecision.contentFull();
+        }
+
+        String parentChainId = readDetail(parent, NASBackupChainKeys.CHAIN_ID);
+        int parentChainPosition = chainPosition(parent);
+        if (parentChainId == null || parentChainPosition == Integer.MAX_VALUE) 
{
+            return ChainDecision.contentFull();
+        }
+
+        // Force a fresh full when the chain has reached the configured length.
+        if (parentChainPosition + 1 >= fullEvery) {
+            return ChainDecision.contentFull();
+        }
+
+        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.contentFull();
+        }
+        return ChainDecision.contentIncremental(parentPaths, parentChainId, 
parentChainPosition + 1,
+                parent.getUuid());
+    }
+
+    /**
+     * True when EVERY volume of the VM sits on storage whose backups must be 
derived by content
+     * comparison instead of dirty bitmaps — currently LINSTOR, whose volumes 
are raw DRBD block
+     * devices and so cannot persist a bitmap (persistence is a qcow2-only 
feature). A VM with a
+     * mix of such volumes and others is not eligible, since one backup run 
uses a single mode.
+     */
+    protected boolean allVolumesOnContentDiffCapableStorage(VirtualMachine vm) 
{
+        List<VolumeVO> volumes = volumeDao.findByInstance(vm.getId());
+        if (CollectionUtils.isEmpty(volumes)) {
+            return false;
+        }
+        for (VolumeVO volume : volumes) {
+            StoragePoolVO pool = 
primaryDataStoreDao.findById(volume.getPoolId());
+            if (pool == null || 
!Storage.StoragePoolType.Linstor.equals(pool.getPoolType())) {
+                return false;
+            }
+        }
+        return true;
+    }
+
     /**
      * 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
@@ -502,7 +611,10 @@ public class NASBackupProvider extends AdapterBase 
implements BackupProvider, Co
         // 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);
+            // Content-based decisions carry the parent uuid directly (no 
bitmap to look it up by).
+            String parentUuid = decision.parentBackupUuid != null
+                    ? decision.parentBackupUuid
+                    : lookupParentBackupUuid(backup.getVmId(), 
decision.bitmapParent);
             if (parentUuid != null) {
                 backupDetailsDao.persist(new BackupDetailVO(backup.getId(), 
NASBackupChainKeys.PARENT_BACKUP_ID, parentUuid, true));
             }
@@ -593,11 +705,14 @@ public class NASBackupProvider extends AdapterBase 
implements BackupProvider, Co
         command.setQuiesce(quiesceVM);
         
command.setQuiesceTimeout(NASBackupQuiesceAgentTimeout.valueIn(vm.getDataCenterId()));
         command.setMode(decision.mode);
+        command.setMethod(decision.method);
         command.setBitmapNew(decision.bitmapNew);
         command.setBitmapParent(decision.bitmapParent);
         command.setParentPaths(decision.parentPaths);
 
-        if (VirtualMachine.State.Stopped.equals(vm.getState())) {
+        // A stopped VM is copied straight from its volumes. A running VM 
backed up with the content method
+        // takes the scratch space of its pull-mode backup from the volumes' 
primary storage.
+        if (VirtualMachine.State.Stopped.equals(vm.getState()) || 
decision.isContentBased()) {
             List<VolumeVO> vmVolumes = volumeDao.findByInstance(vm.getId());
             vmVolumes.sort(Comparator.comparing(Volume::getDeviceId));
             Pair<List<PrimaryDataStoreTO>, List<String>> volumePoolsAndPaths = 
getVolumePoolsAndPaths(vmVolumes);
@@ -628,7 +743,9 @@ public class NASBackupProvider extends AdapterBase 
implements BackupProvider, Co
             // backup as a full and start a new chain.
             ChainDecision effective = decision;
             if (answer.getIncrementalFallback()) {
-                effective = ChainDecision.fullStart(decision.bitmapNew);
+                effective = decision.isContentBased()
+                        ? ChainDecision.contentFull()
+                        : ChainDecision.fullStart(decision.bitmapNew);
                 backupVO.setType("FULL");
             }
             List<Volume> volumes = new 
ArrayList<>(volumeDao.findByInstance(vm.getId()));
@@ -642,11 +759,15 @@ public class NASBackupProvider extends AdapterBase 
implements BackupProvider, Co
                     // 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());
+                    // The content-based path creates no bitmaps, so there is 
no active
+                    // checkpoint to track — its chain is anchored purely on 
backup history.
+                    if (!decision.isContentBased()) {
+                        String confirmedBitmap = answer.getBitmapCreated();
+                        if (confirmedBitmap != null) {
+                            upsertVmActiveCheckpoint(vm.getId(), 
confirmedBitmap);
+                        } else {
+                            clearVmActiveCheckpoint(vm.getId());
+                        }
                     }
                 }
                 return new Pair<>(true, backupVO);
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 fbd681e726f..857fd167d82 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
@@ -19,6 +19,7 @@ package org.apache.cloudstack.backup;
 import static org.mockito.ArgumentMatchers.anyLong;
 import static org.mockito.Mockito.mock;
 
+import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 import java.util.Objects;
@@ -49,6 +50,7 @@ import com.cloud.resource.ResourceManager;
 import com.cloud.storage.DiskOfferingVO;
 import com.cloud.storage.ScopeType;
 import com.cloud.storage.Storage;
+import java.util.Date;
 import com.cloud.storage.Volume;
 import com.cloud.storage.VolumeVO;
 import com.cloud.storage.dao.DiskOfferingDao;
@@ -275,6 +277,37 @@ public class NASBackupProviderTest {
         Mockito.verify(agentManager).send(anyLong(), 
Mockito.any(TakeBackupCommand.class));
     }
 
+    @Test
+    public void takeBackupSendsVolumePoolsForContentMethodOnRunningVm() throws 
AgentUnavailableException, OperationTimedoutException {
+        VMInstanceVO vm = mockRunningVmForBackup(1L, 2L, 3L, 4L, 5L, 6L);
+        
Mockito.doReturn(NASBackupProvider.ChainDecision.contentFull()).when(nasBackupProvider).decideChain(vm);
+        VolumeVO volume = volumeDao.findByInstance(1L).get(0);
+        Mockito.when(volume.getPoolId()).thenReturn(11L);
+        Mockito.when(volume.getPath()).thenReturn("vol-uuid");
+        Mockito.when(volumeDao.findByInstance(1L)).thenReturn(new 
ArrayList<>(List.of(volume)));
+        StoragePoolVO pool = mock(StoragePoolVO.class);
+        Mockito.when(pool.getId()).thenReturn(11L);
+        
Mockito.when(pool.getPoolType()).thenReturn(Storage.StoragePoolType.Linstor);
+        Mockito.when(storagePoolDao.findById(11L)).thenReturn(pool);
+        DataStore dataStore = mock(DataStore.class);
+        PrimaryDataStoreTO dataStoreTO = mock(PrimaryDataStoreTO.class);
+        Mockito.when(dataStore.getTO()).thenReturn(dataStoreTO);
+        Mockito.when(dataStoreMgr.getDataStore(11L, 
DataStoreRole.Primary)).thenReturn(dataStore);
+
+        BackupAnswer answer = mock(BackupAnswer.class);
+        Mockito.when(answer.getResult()).thenReturn(false);
+        ArgumentCaptor<TakeBackupCommand> commandCaptor = 
ArgumentCaptor.forClass(TakeBackupCommand.class);
+        Mockito.when(agentManager.send(anyLong(), 
commandCaptor.capture())).thenReturn(answer);
+        
Mockito.when(backupDao.persist(Mockito.any(BackupVO.class))).thenAnswer(invocation
 -> invocation.getArgument(0));
+
+        nasBackupProvider.takeBackup(vm, false);
+
+        TakeBackupCommand command = commandCaptor.getValue();
+        Assert.assertEquals("content", command.getMethod());
+        Assert.assertEquals(List.of(dataStoreTO), command.getVolumePools());
+        Assert.assertEquals(List.of("/dev/drbd/by-res/cs-vol-uuid/0"), 
command.getVolumePaths());
+    }
+
     @Test
     public void takeBackupFailureNeedingCleanupReturnsErrorBackup() throws 
AgentUnavailableException, OperationTimedoutException {
         VMInstanceVO vm = mockRunningVmForBackup(1L, 2L, 3L, 4L, 5L, 6L);
@@ -570,6 +603,7 @@ public class NASBackupProviderTest {
         NASBackupProvider.ChainDecision decision = 
nasBackupProvider.decideChain(vm);
         Assert.assertNotNull(decision);
         Assert.assertEquals(NASBackupChainKeys.TYPE_FULL, decision.mode);
+        Assert.assertEquals(NASBackupChainKeys.METHOD_QCOW2, decision.method);
         Assert.assertNull(decision.bitmapParent);
         Assert.assertEquals(0, decision.chainPosition);
     }
@@ -1165,4 +1199,196 @@ public class NASBackupProviderTest {
         Mockito.verify(backupDao, Mockito.never()).remove(51L);
         Mockito.verify(backupDao).remove(50L);
     }
+
+    // -- content-based (pull mode) chain decisions for raw block storage 
-----------------
+
+    /**
+     * With the incremental master switch off, a LINSTOR-backed VM keeps the 
legacy push-mode full
+     * like every other VM: enabling incrementals is the opt-in to the 
content-based path and its
+     * host-local scratch space. The storage is not even looked at.
+     */
+    @Test
+    public void 
decideChainReturnsLegacyFullForLinstorWhenIncrementalDisabled() {
+        Long vmId = 70L;
+        VMInstanceVO vm = mock(VMInstanceVO.class);
+        Mockito.lenient().when(vm.getId()).thenReturn(vmId);
+        Mockito.lenient().when(vm.getDataCenterId()).thenReturn(1L);
+
+        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.assertTrue(decision.isLegacyFull());
+        Assert.assertEquals(NASBackupChainKeys.METHOD_QCOW2, decision.method);
+        Assert.assertFalse(decision.isContentBased());
+        Assert.assertNull("legacy full anchors no chain", decision.chainId);
+        Mockito.verify(volumeDao, Mockito.never()).findByInstance(vmId);
+    }
+
+    /**
+     * Stopped VMs are imaged straight from the disk by qemu-img, with no 
point-in-time export to
+     * diff against a parent, so a LINSTOR VM that is stopped must take a 
content full.
+     */
+    @Test
+    public void decideChainReturnsContentFullForStoppedLinstorVm() {
+        Long vmId = 71L;
+        VMInstanceVO vm = mock(VMInstanceVO.class);
+        Mockito.when(vm.getId()).thenReturn(vmId);
+        Mockito.when(vm.getDataCenterId()).thenReturn(1L);
+        Mockito.when(vm.getState()).thenReturn(VMInstanceVO.State.Stopped);
+        stubAllVolumesOnLinstor(vmId, 1);
+        enableIncrementals();
+
+        NASBackupProvider.ChainDecision decision = 
nasBackupProvider.decideChain(vm);
+        Assert.assertEquals(NASBackupChainKeys.TYPE_FULL, decision.mode);
+        Assert.assertEquals(NASBackupChainKeys.METHOD_CONTENT, 
decision.method);
+    }
+
+    /**
+     * Running LINSTOR VM with a healthy parent inside the cadence => content 
incremental, carrying
+     * the parent's chain id, the next position, the parent uuid (resolved 
directly since there is
+     * no bitmap to look it up by) and the per-volume parent paths the script 
rebases onto.
+     */
+    @Test
+    public void 
decideChainReturnsContentIncrementalForLinstorWithValidParent() {
+        Long vmId = 72L;
+        VMInstanceVO vm = mock(VMInstanceVO.class);
+        Mockito.when(vm.getId()).thenReturn(vmId);
+        Mockito.when(vm.getDataCenterId()).thenReturn(1L);
+        Mockito.when(vm.getState()).thenReturn(VMInstanceVO.State.Running);
+        stubAllVolumesOnLinstor(vmId, 1);
+        enableIncrementals();
+        ReflectionTestUtils.setField(nasBackupProvider, "NASBackupFullEvery",
+                new 
org.apache.cloudstack.framework.config.ConfigKey<>("Advanced", Integer.class,
+                        "nas.backup.full.every", "3", "test override", true,
+                        
org.apache.cloudstack.framework.config.ConfigKey.Scope.Zone));
+
+        Backup parent = mock(Backup.class);
+        Mockito.when(parent.getId()).thenReturn(900L);
+        Mockito.when(parent.getStatus()).thenReturn(Backup.Status.BackedUp);
+        Mockito.lenient().when(parent.getDate()).thenReturn(new Date());
+        Mockito.when(parent.getUuid()).thenReturn("parent-uuid");
+        
Mockito.when(parent.getExternalId()).thenReturn("i-2-3-VM/2026.01.01.00.00.00");
+        Backup.VolumeInfo pv = mock(Backup.VolumeInfo.class);
+        Mockito.when(pv.getPath()).thenReturn("volpath1");
+        
Mockito.<List<Backup.VolumeInfo>>when(parent.getBackedUpVolumes()).thenReturn(List.of(pv));
+        Mockito.when(backupDao.listByVmId(null, 
vmId)).thenReturn(List.of(parent));
+
+        BackupDetailVO chainId = mock(BackupDetailVO.class);
+        Mockito.when(chainId.getValue()).thenReturn("chain-1");
+        BackupDetailVO chainPos = mock(BackupDetailVO.class);
+        Mockito.when(chainPos.getValue()).thenReturn("0");
+        Mockito.when(backupDetailsDao.findDetail(900L, 
NASBackupChainKeys.CHAIN_ID)).thenReturn(chainId);
+        Mockito.when(backupDetailsDao.findDetail(900L, 
NASBackupChainKeys.CHAIN_POSITION)).thenReturn(chainPos);
+
+        NASBackupProvider.ChainDecision decision = 
nasBackupProvider.decideChain(vm);
+        Assert.assertEquals(NASBackupChainKeys.TYPE_INCREMENTAL, 
decision.mode);
+        Assert.assertEquals(NASBackupChainKeys.METHOD_CONTENT, 
decision.method);
+        Assert.assertTrue(decision.isIncremental());
+        Assert.assertTrue(decision.isContentBased());
+        Assert.assertNull("a content incremental carries no bitmap", 
decision.bitmapNew);
+        Assert.assertNull(decision.bitmapParent);
+        Assert.assertEquals("chain-1", decision.chainId);
+        Assert.assertEquals(1, decision.chainPosition);
+        Assert.assertEquals("parent-uuid", decision.parentBackupUuid);
+        
Assert.assertEquals(List.of("i-2-3-VM/2026.01.01.00.00.00/root.volpath1.qcow2"),
+                decision.parentPaths);
+    }
+
+    /**
+     * Once the chain reaches nas.backup.full.every, the next LINSTOR backup 
must anchor a new
+     * chain with a content full rather than extending the existing one.
+     */
+    @Test
+    public void decideChainReturnsContentFullForLinstorAtChainEnd() {
+        Long vmId = 73L;
+        VMInstanceVO vm = mock(VMInstanceVO.class);
+        Mockito.when(vm.getId()).thenReturn(vmId);
+        Mockito.when(vm.getDataCenterId()).thenReturn(1L);
+        Mockito.when(vm.getState()).thenReturn(VMInstanceVO.State.Running);
+        stubAllVolumesOnLinstor(vmId, 1);
+        enableIncrementals();
+        ReflectionTestUtils.setField(nasBackupProvider, "NASBackupFullEvery",
+                new 
org.apache.cloudstack.framework.config.ConfigKey<>("Advanced", Integer.class,
+                        "nas.backup.full.every", "2", "test override", true,
+                        
org.apache.cloudstack.framework.config.ConfigKey.Scope.Zone));
+
+        Backup parent = mock(Backup.class);
+        Mockito.when(parent.getId()).thenReturn(901L);
+        Mockito.when(parent.getStatus()).thenReturn(Backup.Status.BackedUp);
+        Mockito.lenient().when(parent.getDate()).thenReturn(new Date());
+        Mockito.when(backupDao.listByVmId(null, 
vmId)).thenReturn(List.of(parent));
+        BackupDetailVO chainId = mock(BackupDetailVO.class);
+        Mockito.when(chainId.getValue()).thenReturn("chain-9");
+        BackupDetailVO chainPos = mock(BackupDetailVO.class);
+        Mockito.when(chainPos.getValue()).thenReturn("1"); // 1 + 1 >= 2 => 
new full
+        Mockito.when(backupDetailsDao.findDetail(901L, 
NASBackupChainKeys.CHAIN_ID)).thenReturn(chainId);
+        Mockito.when(backupDetailsDao.findDetail(901L, 
NASBackupChainKeys.CHAIN_POSITION)).thenReturn(chainPos);
+
+        NASBackupProvider.ChainDecision decision = 
nasBackupProvider.decideChain(vm);
+        Assert.assertEquals(NASBackupChainKeys.TYPE_FULL, decision.mode);
+        Assert.assertEquals(NASBackupChainKeys.METHOD_CONTENT, 
decision.method);
+    }
+
+    /**
+     * A VM straddling LINSTOR and file-based storage is not content-diff 
capable: one backup run
+     * uses a single mode, so it must not be routed down the content path.
+     */
+    @Test
+    public void allVolumesOnContentDiffCapableStorageFalseForMixedStorage() {
+        Long vmId = 74L;
+        VMInstanceVO vm = mock(VMInstanceVO.class);
+        Mockito.when(vm.getId()).thenReturn(vmId);
+
+        VolumeVO linVol = mock(VolumeVO.class);
+        Mockito.when(linVol.getPoolId()).thenReturn(1L);
+        VolumeVO nfsVol = mock(VolumeVO.class);
+        Mockito.when(nfsVol.getPoolId()).thenReturn(2L);
+        
Mockito.when(volumeDao.findByInstance(vmId)).thenReturn(List.of(linVol, 
nfsVol));
+
+        StoragePoolVO lin = mock(StoragePoolVO.class);
+        
Mockito.when(lin.getPoolType()).thenReturn(Storage.StoragePoolType.Linstor);
+        StoragePoolVO nfs = mock(StoragePoolVO.class);
+        
Mockito.when(nfs.getPoolType()).thenReturn(Storage.StoragePoolType.NetworkFilesystem);
+        Mockito.when(storagePoolDao.findById(1L)).thenReturn(lin);
+        Mockito.when(storagePoolDao.findById(2L)).thenReturn(nfs);
+
+        
Assert.assertFalse(nasBackupProvider.allVolumesOnContentDiffCapableStorage(vm));
+    }
+
+    /** A VM with no volumes is not content-diff capable (safe default). */
+    @Test
+    public void allVolumesOnContentDiffCapableStorageFalseForNoVolumes() {
+        Long vmId = 75L;
+        VMInstanceVO vm = mock(VMInstanceVO.class);
+        Mockito.when(vm.getId()).thenReturn(vmId);
+        Mockito.when(volumeDao.findByInstance(vmId)).thenReturn(List.of());
+        
Assert.assertFalse(nasBackupProvider.allVolumesOnContentDiffCapableStorage(vm));
+    }
+
+    private void enableIncrementals() {
+        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));
+    }
+
+    /** Point every volume of {@code vmId} at a LINSTOR pool. */
+    private void stubAllVolumesOnLinstor(Long vmId, int count) {
+        List<VolumeVO> vols = new java.util.ArrayList<>();
+        StoragePoolVO lin = mock(StoragePoolVO.class);
+        
Mockito.lenient().when(lin.getPoolType()).thenReturn(Storage.StoragePoolType.Linstor);
+        for (int i = 0; i < count; i++) {
+            VolumeVO v = mock(VolumeVO.class);
+            long poolId = 100L + i;
+            Mockito.when(v.getPoolId()).thenReturn(poolId);
+            Mockito.when(storagePoolDao.findById(poolId)).thenReturn(lin);
+            vols.add(v);
+        }
+        Mockito.when(volumeDao.findByInstance(vmId)).thenReturn(vols);
+    }
 }
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 d067a29d31a..dcd4cf8bcf3 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
@@ -63,7 +63,7 @@ public class LibvirtRestoreBackupCommandWrapper extends 
CommandWrapper<RestoreBa
     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) {
+    static String getVolumeUuidFromPath(String volumePath, PrimaryDataStoreTO 
volumePool) {
         if (Storage.StoragePoolType.Linstor.equals(volumePool.getPoolType())) {
             Path path = Paths.get(volumePath);
             String rscName = path.getParent().getFileName().toString();
@@ -330,15 +330,22 @@ public class LibvirtRestoreBackupCommandWrapper extends 
CommandWrapper<RestoreBa
         KVMStoragePool volumeStoragePool = 
storagePoolMgr.getStoragePool(volumePool.getPoolType(), volumePool.getUuid());
         QemuImg qemu;
         try {
-            qemu = new QemuImg(timeout, true, false);
             String volumeUuid = getVolumeUuidFromPath(volumePath, volumePool);
             KVMPhysicalDisk disk = null;
+            boolean targetExists = true;
+            // Let qemu-img skip the backup's zero regions (--target-is-zero) 
only on a volume that
+            // provably reads as zeroes. An in-place restore overwrites a 
volume that still holds its
+            // old data, and a new LINSTOR volume reads as zeroes only on thin 
or ZFS backed pools.
+            boolean targetReadsAsZero = false;
             if (createTargetVolume) {
                 if 
(Storage.StoragePoolType.Linstor.equals(volumePool.getPoolType())) {
                     if (size == null) {
                         throw new CloudRuntimeException("Restore volume size 
is required for Linstor pool when creating target volume");
                     }
                     disk = volumeStoragePool.createPhysicalDisk(volumeUuid, 
QemuImg.PhysicalDiskFormat.RAW, Storage.ProvisioningType.THIN, size, null);
+                    targetReadsAsZero = 
volumeStoragePool.newVolumeReadsAsZero(volumeUuid);
+                } else {
+                    targetExists = false;
                 }
             } else {
                 if 
(Storage.StoragePoolType.Linstor.equals(volumePool.getPoolType())) {
@@ -346,8 +353,9 @@ public class LibvirtRestoreBackupCommandWrapper extends 
CommandWrapper<RestoreBa
                 } else {
                     disk = volumeStoragePool.getPhysicalDisk(volumePath);
                 }
-                qemu.setSkipTargetVolumeCreation(true);
             }
+            qemu = new QemuImg(timeout, targetReadsAsZero, false);
+            qemu.setSkipTargetVolumeCreation(targetExists);
             if (disk != null) {
                 logger.debug("Restoring volume: {}", disk.toString());
             }
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 b7e60958765..92a4d3af8c7 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
@@ -21,6 +21,7 @@ package com.cloud.hypervisor.kvm.resource.wrapper;
 
 import com.cloud.agent.api.Answer;
 import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource;
+import com.cloud.hypervisor.kvm.resource.LibvirtVMDef.DiskDef;
 import com.cloud.hypervisor.kvm.storage.KVMPhysicalDisk;
 import com.cloud.hypervisor.kvm.storage.KVMStoragePool;
 import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager;
@@ -28,17 +29,25 @@ import com.cloud.resource.CommandWrapper;
 import com.cloud.resource.ResourceWrapper;
 import com.cloud.storage.Storage;
 import com.cloud.utils.Pair;
+import com.cloud.utils.exception.CloudRuntimeException;
 import com.cloud.utils.script.Script;
 import org.apache.cloudstack.backup.BackupAnswer;
 import org.apache.cloudstack.backup.TakeBackupCommand;
 import org.apache.cloudstack.storage.to.PrimaryDataStoreTO;
 import org.apache.commons.collections.CollectionUtils;
 import org.apache.commons.lang3.StringUtils;
+import org.libvirt.Connect;
+import org.libvirt.Domain;
+import org.libvirt.DomainInfo;
+import org.libvirt.LibvirtException;
 
+import java.nio.file.Files;
+import java.nio.file.Paths;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Objects;
+import java.util.stream.Collectors;
 
 @ResourceWrapper(handles = TakeBackupCommand.class)
 public class LibvirtTakeBackupCommandWrapper extends 
CommandWrapper<TakeBackupCommand, Answer, LibvirtComputingResource> {
@@ -53,13 +62,18 @@ public class LibvirtTakeBackupCommandWrapper extends 
CommandWrapper<TakeBackupCo
     // chain metadata. Matches nasbackup.sh's "legacy-full" mode 
(make_checkpoint=0).
     private static final String MODE_LEGACY_FULL = "legacy-full";
 
+    // Backup methods, orthogonal to the mode. qcow2 is the push-mode path 
with QEMU dirty bitmaps,
+    // also assumed when the management server sends none. content is libvirt 
pull mode, used for
+    // raw block-device storage such as LINSTOR/DRBD which cannot carry 
persistent dirty bitmaps:
+    // it needs no bitmap names, an incremental only its per-disk parent paths.
+    private static final String METHOD_QCOW2 = "qcow2";
+    private static final String METHOD_CONTENT = "content";
+    // Fixed part of the qcow2 metadata on a scratch device, with room to 
spare.
+    private static final long SCRATCH_EXTRA_BYTES = 4L * 1024 * 1024;
+
     @Override
     public Answer execute(TakeBackupCommand command, LibvirtComputingResource 
libvirtComputingResource) {
         final String vmName = command.getVmName();
-        final String backupPath = command.getBackupPath();
-        final String backupRepoType = command.getBackupRepoType();
-        final String backupRepoAddress = command.getBackupRepoAddress();
-        final String mountOptions = command.getMountOptions();
         List<PrimaryDataStoreTO> volumePools = command.getVolumePools();
         final List<String> volumePaths = command.getVolumePaths();
         KVMStoragePoolManager storagePoolMgr = 
libvirtComputingResource.getStoragePoolMgr();
@@ -87,12 +101,67 @@ public class LibvirtTakeBackupCommandWrapper extends 
CommandWrapper<TakeBackupCo
             }
         }
 
-        Pair<Integer, String> result = 
runBackupScript(libvirtComputingResource, command, vmName, backupRepoType, 
backupRepoAddress,
-                mountOptions, backupPath, diskPaths, command.getMode(),
-                command.getBitmapNew(), command.getBitmapParent(), 
command.getParentPaths(), timeout);
+        // A stopped VM is copied straight from its volumes, but a LINSTOR 
volume only has a device on
+        // the hosts that hold a resource of it, and once the VM is stopped 
its last host may hold none.
+        List<Pair<PrimaryDataStoreTO, String>> connectedVolumes;
+        try {
+            connectedVolumes = connectMissingLinstorVolumes(storagePoolMgr, 
volumePools, volumePaths);
+        } catch (CloudRuntimeException e) {
+            logger.warn("Failed to make the volumes of VM {} available for its 
backup", vmName, e);
+            return new BackupAnswer(command, false,
+                    String.format("Failed to make the volumes of VM %s 
available: %s", vmName, e.getMessage()));
+        }
+
+        KVMStoragePool scratchPool = null;
+        Pair<String, List<String>> scratch = null;
+        try {
+            // A pull-mode backup of a running VM needs a copy-before-write 
scratch device per disk, provided by
+            // the VM's primary storage. A stopped VM is copied straight from 
its disks and needs none.
+            if (METHOD_CONTENT.equals(command.getMethod())) {
+                try {
+                    List<Long> diskSizes = 
getRunningVmDiskSizes(libvirtComputingResource, vmName);
+                    if (diskSizes != null) {
+                        if (CollectionUtils.isEmpty(volumePools)) {
+                            return new BackupAnswer(command, false, "No 
volumes to back up for VM " + vmName);
+                        }
+                        PrimaryDataStoreTO scratchPoolTO = volumePools.get(0);
+                        scratchPool = 
storagePoolMgr.getStoragePool(scratchPoolTO.getPoolType(), 
scratchPoolTO.getUuid());
+                        scratch = scratchPool.createBackupScratch(vmName,
+                                
diskSizes.stream().map(LibvirtTakeBackupCommandWrapper::scratchSize).collect(Collectors.toList()));
+                    }
+                } catch (LibvirtException | CloudRuntimeException e) {
+                    logger.warn("Failed to create the backup scratch space for 
VM {}", vmName, e);
+                    return new BackupAnswer(command, false,
+                            String.format("Failed to create the backup scratch 
space for VM %s: %s", vmName, e.getMessage()));
+                }
+            }
+
+            return runBackup(libvirtComputingResource, command, diskPaths, 
scratch != null ? scratch.second() : null, timeout);
+        } finally {
+            if (scratch != null) {
+                try {
+                    scratchPool.deleteBackupScratch(scratch.first());
+                } catch (CloudRuntimeException e) {
+                    logger.warn("Failed to delete the backup scratch space {} 
of VM {}, delete it manually: {}",
+                            scratch.first(), vmName, e.getMessage());
+                }
+            }
+            disconnectVolumes(storagePoolMgr, connectedVolumes);
+        }
+    }
+
+    private Answer runBackup(LibvirtComputingResource 
libvirtComputingResource, TakeBackupCommand command,
+            List<String> diskPaths, List<String> scratchDevices, int timeout) {
+        final String vmName = command.getVmName();
+        Pair<Integer, String> result = 
runBackupScript(libvirtComputingResource, command, vmName, 
command.getBackupRepoType(),
+                command.getBackupRepoAddress(), command.getMountOptions(), 
command.getBackupPath(), diskPaths, command.getMode(),
+                command.getBitmapNew(), command.getBitmapParent(), 
command.getParentPaths(), scratchDevices, timeout);
 
         if (result.first() != 0) {
             logger.debug("Failed to take VM backup: " + result.second());
+            if (Script.ERR_TIMEOUT.equals(result.second()) && 
METHOD_CONTENT.equals(command.getMethod())) {
+                abortPullBackupJob(vmName);
+            }
             BackupAnswer answer = new BackupAnswer(command, false, 
StringUtils.trimToEmpty(result.second()));
             if (EXIT_CLEANUP_FAILED.equals(result.first())) {
                 logger.debug("Backup cleanup failed");
@@ -118,6 +187,104 @@ public class LibvirtTakeBackupCommandWrapper extends 
CommandWrapper<TakeBackupCo
         return answer;
     }
 
+    /**
+     * Makes each LINSTOR volume whose device does not exist on this host 
available here; connecting
+     * returns once the device exists. A volume of a running VM, or one this 
host holds a replica of,
+     * already has its device and is left alone. Returns the pool and uuid of 
every volume it connected,
+     * for {@link #disconnectVolumes}.
+     */
+    private List<Pair<PrimaryDataStoreTO, String>> 
connectMissingLinstorVolumes(KVMStoragePoolManager storagePoolMgr,
+            List<PrimaryDataStoreTO> volumePools, List<String> volumePaths) {
+        List<Pair<PrimaryDataStoreTO, String>> connected = new ArrayList<>();
+        if (volumePaths == null) {
+            return connected;
+        }
+        try {
+            for (int idx = 0; idx < volumePaths.size(); idx++) {
+                PrimaryDataStoreTO volumePool = volumePools.get(idx);
+                String volumePath = volumePaths.get(idx);
+                if (volumePool.getPoolType() != 
Storage.StoragePoolType.Linstor || deviceExists(volumePath)) {
+                    continue;
+                }
+                String volumeUuid = 
LibvirtRestoreBackupCommandWrapper.getVolumeUuidFromPath(volumePath, 
volumePool);
+                logger.debug("Making LINSTOR volume {} available for the 
backup", volumeUuid);
+                storagePoolMgr.connectPhysicalDisk(volumePool.getPoolType(), 
volumePool.getUuid(), volumeUuid, null);
+                connected.add(new Pair<>(volumePool, volumeUuid));
+            }
+        } catch (CloudRuntimeException e) {
+            disconnectVolumes(storagePoolMgr, connected);
+            throw e;
+        }
+        return connected;
+    }
+
+    private void disconnectVolumes(KVMStoragePoolManager storagePoolMgr, 
List<Pair<PrimaryDataStoreTO, String>> volumes) {
+        for (Pair<PrimaryDataStoreTO, String> volume : volumes) {
+            try {
+                
storagePoolMgr.disconnectPhysicalDisk(volume.first().getPoolType(), 
volume.first().getUuid(), volume.second());
+            } catch (CloudRuntimeException e) {
+                logger.warn("Failed to disconnect volume {} after the backup: 
{}", volume.second(), e.getMessage());
+            }
+        }
+    }
+
+    boolean deviceExists(String path) {
+        return Files.exists(Paths.get(path));
+    }
+
+    /**
+     * A pull-mode backup job never ends on its own: nasbackup.sh ends it once 
the data is read,
+     * and its EXIT trap does so on failure. A timed-out script is killed with 
SIGKILL, which
+     * no trap can catch, so end the job here. Otherwise it keeps the NBD 
server and the scratch
+     * devices busy and blocks every later backup or migration of the VM.
+     */
+    private void abortPullBackupJob(String vmName) {
+        logger.warn("Backup of VM {} timed out, aborting its pull-mode backup 
job", vmName);
+        int exitValue = 
Script.executeCommandForExitValue(Script.getExecutableAbsolutePath("virsh"),
+                "-c", "qemu:///system", "domjobabort", vmName);
+        if (exitValue != 0) {
+            logger.warn("Failed to abort the backup job of VM {} (exit code 
{})", vmName, exitValue);
+        }
+    }
+
+    /**
+     * The capacity of each disk of the VM in domain order, which is the order 
nasbackup.sh lists them in, or
+     * {@code null} if the VM is not running here. nasbackup.sh takes a 
stopped VM's backup without pull mode.
+     */
+    private List<Long> getRunningVmDiskSizes(LibvirtComputingResource 
libvirtComputingResource, String vmName)
+            throws LibvirtException {
+        Connect conn = 
libvirtComputingResource.getLibvirtUtilitiesHelper().getConnectionByVmName(vmName);
+        Domain dm;
+        try {
+            dm = libvirtComputingResource.getDomain(conn, vmName);
+        } catch (LibvirtException e) {
+            logger.debug("VM {} is not defined on this host: {}", vmName, 
e.getMessage());
+            return null;
+        }
+        try {
+            if (dm.getInfo().state != 
DomainInfo.DomainState.VIR_DOMAIN_RUNNING) {
+                return null;
+            }
+            List<Long> sizes = new ArrayList<>();
+            for (DiskDef disk : libvirtComputingResource.getDisks(conn, 
vmName)) {
+                if (disk.getDeviceType() == DiskDef.DeviceType.DISK) {
+                    sizes.add(dm.blockInfo(disk.getDiskLabel()).getCapacity());
+                }
+            }
+            return sizes;
+        } finally {
+            dm.free();
+        }
+    }
+
+    /**
+     * Size of a scratch device for a disk of {@code diskSize} bytes. libvirt 
formats it as a qcow2 that may
+     * end up holding every block of the disk, plus metadata: about 0.015% of 
the disk with 64 KiB clusters.
+     */
+    static long scratchSize(long diskSize) {
+        return diskSize + diskSize / 4096 + SCRATCH_EXTRA_BYTES;
+    }
+
     /** Remove nasbackup.sh's stdout signalling marker lines so they don't 
pollute size parsing. */
     private String stripMarkerLines(String stdout) {
         if (StringUtils.isBlank(stdout)) {
@@ -142,7 +309,7 @@ public class LibvirtTakeBackupCommandWrapper extends 
CommandWrapper<TakeBackupCo
     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) {
+            String bitmapNew, String bitmapParent, List<String> parentPaths, 
List<String> scratchDevices, int timeout) {
         boolean quiesce = command.getQuiesce() != null && command.getQuiesce();
         List<String> argv = new ArrayList<>(Arrays.asList(
                 libvirtComputingResource.getNasBackupPath(),
@@ -164,6 +331,10 @@ public class LibvirtTakeBackupCommandWrapper extends 
CommandWrapper<TakeBackupCo
             argv.add("-M");
             argv.add(mode);
         }
+        if (StringUtils.isNotBlank(command.getMethod())) {
+            argv.add("--method");
+            argv.add(command.getMethod());
+        }
         if (StringUtils.isNotBlank(bitmapNew)) {
             argv.add("--bitmap-new");
             argv.add(bitmapNew);
@@ -176,6 +347,10 @@ public class LibvirtTakeBackupCommandWrapper extends 
CommandWrapper<TakeBackupCo
             argv.add("--parent-paths");
             argv.add(String.join(",", parentPaths));
         }
+        if (CollectionUtils.isNotEmpty(scratchDevices)) {
+            argv.add("--scratch-devs");
+            argv.add(String.join(",", scratchDevices));
+        }
 
         List<String[]> commands = new ArrayList<>();
         commands.add(argv.toArray(new String[0]));
@@ -188,6 +363,13 @@ public class LibvirtTakeBackupCommandWrapper extends 
CommandWrapper<TakeBackupCo
      */
     private String validateBackupArgs(TakeBackupCommand command) {
         String mode = command.getMode();
+        String method = StringUtils.defaultIfBlank(command.getMethod(), 
METHOD_QCOW2);
+        if (METHOD_CONTENT.equals(method)) {
+            return validateContentBackupArgs(command);
+        }
+        if (!METHOD_QCOW2.equals(method)) {
+            return "Unknown backup method: " + method;
+        }
         if (StringUtils.isBlank(mode)) {
             return null; // legacy full-only, no extra args expected
         }
@@ -215,6 +397,21 @@ public class LibvirtTakeBackupCommandWrapper extends 
CommandWrapper<TakeBackupCo
         return "Unknown backup mode: " + mode;
     }
 
+    private String validateContentBackupArgs(TakeBackupCommand command) {
+        String mode = command.getMode();
+        if (MODE_FULL.equals(mode)) {
+            return null; // pull-mode full — no bitmap, no parent
+        }
+        if (MODE_INCREMENTAL.equals(mode)) {
+            // No bitmap is involved: the delta comes from comparing the disk 
against the parent.
+            if (CollectionUtils.isEmpty(command.getParentPaths())) {
+                return "content incremental mode requires parentPaths";
+            }
+            return null;
+        }
+        return "Unsupported mode for the content backup method: " + 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
diff --git 
a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStoragePool.java
 
b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStoragePool.java
index 3e35ed9476b..46e57d701a0 100644
--- 
a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStoragePool.java
+++ 
b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStoragePool.java
@@ -30,6 +30,8 @@ import com.cloud.agent.api.to.HostTO;
 import com.cloud.hypervisor.kvm.resource.KVMHABase.HAStoragePool;
 import com.cloud.storage.Storage;
 import com.cloud.storage.Storage.StoragePoolType;
+import com.cloud.utils.Pair;
+import com.cloud.utils.exception.CloudRuntimeException;
 
 public interface KVMStoragePool {
 
@@ -125,4 +127,35 @@ public interface KVMStoragePool {
 
     default void customizeLibvirtDiskDef(LibvirtVMDef.DiskDef disk) {
     }
+
+    /**
+     * Whether the volume {@code volumeUuid}, freshly created in this pool, 
already reads as zeroes,
+     * so a copy onto it may skip zero regions (qemu-img --target-is-zero). 
Says nothing about a
+     * volume that has been written to: that still holds its old data.
+     */
+    default boolean newVolumeReadsAsZero(String volumeUuid) {
+        return false;
+    }
+
+    /**
+     * Creates the scratch space for a pull-mode backup of the running VM 
{@code vmName} on this host: one
+     * block device per entry of {@code sizes}, at least that many bytes 
large. libvirt formats each device
+     * as the copy-before-write target of one disk, keeping the guest data the 
backup has not read yet.
+     *
+     * @return the name to pass to {@link #deleteBackupScratch(String)} and 
the device paths, in the order
+     *         of {@code sizes}
+     */
+    default Pair<String, List<String>> createBackupScratch(String vmName, 
List<Long> sizes) {
+        throw new CloudRuntimeException(String.format("Storage pool %s of type 
%s cannot provide backup scratch space",
+                getUuid(), getType()));
+    }
+
+    /**
+     * Removes scratch space created by {@link #createBackupScratch(String, 
List)}. Only valid once the
+     * backup job has ended and libvirt has released the devices.
+     */
+    default void deleteBackupScratch(String name) {
+        throw new CloudRuntimeException(String.format("Storage pool %s of type 
%s cannot provide backup scratch space",
+                getUuid(), getType()));
+    }
 }
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 138b57aef0a..56497c3c14f 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
@@ -22,6 +22,7 @@ import static org.mockito.ArgumentMatchers.anyList;
 import static org.mockito.ArgumentMatchers.anyLong;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.mockConstruction;
 import static org.mockito.Mockito.mockStatic;
 import static org.mockito.Mockito.when;
 
@@ -35,10 +36,13 @@ import java.util.List;
 import org.apache.cloudstack.backup.BackupAnswer;
 import org.apache.cloudstack.backup.RestoreBackupCommand;
 import org.apache.cloudstack.storage.to.PrimaryDataStoreTO;
+import org.apache.cloudstack.utils.qemu.QemuImg;
+import org.apache.cloudstack.utils.qemu.QemuImgFile;
 import org.junit.Assert;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
+import org.mockito.MockedConstruction;
 import org.mockito.MockedStatic;
 import org.mockito.Mockito;
 import org.mockito.junit.MockitoJUnitRunner;
@@ -871,4 +875,67 @@ public class LibvirtRestoreBackupCommandWrapperTest {
             }
         }
     }
+
+    /**
+     * Restores a backup onto a LINSTOR volume through 
replaceBlockDeviceWithBackup and returns
+     * the QemuImg instance used for the copy together with its constructor 
arguments.
+     */
+    private Pair<QemuImg, List<?>> restoreOntoLinstorVolume(boolean 
createTargetVolume, boolean newVolumeReadsAsZero)
+            throws Exception {
+        Method method = 
LibvirtRestoreBackupCommandWrapper.class.getDeclaredMethod("replaceBlockDeviceWithBackup",
+                KVMStoragePoolManager.class, PrimaryDataStoreTO.class, 
String.class, String.class, int.class,
+                boolean.class, Long.class);
+        method.setAccessible(true);
+
+        PrimaryDataStoreTO volumePool = Mockito.mock(PrimaryDataStoreTO.class);
+        
when(volumePool.getPoolType()).thenReturn(Storage.StoragePoolType.Linstor);
+        when(volumePool.getUuid()).thenReturn("pool-uuid");
+        KVMStoragePoolManager storagePoolMgr = 
Mockito.mock(KVMStoragePoolManager.class);
+        KVMStoragePool linstorPool = Mockito.mock(KVMStoragePool.class);
+        
lenient().when(linstorPool.newVolumeReadsAsZero("volume-123")).thenReturn(newVolumeReadsAsZero);
+        when(storagePoolMgr.getStoragePool(Storage.StoragePoolType.Linstor, 
"pool-uuid")).thenReturn(linstorPool);
+
+        final List<?>[] ctorArgs = new List<?>[1];
+        try (MockedConstruction<QemuImg> qemuMock = 
mockConstruction(QemuImg.class,
+                (mock, context) -> ctorArgs[0] = context.arguments())) {
+            boolean result = (boolean) method.invoke(wrapper, storagePoolMgr, 
volumePool,
+                    "/dev/drbd/by-res/cs-volume-123/0", 
"/mnt/backup/root.volume-123.qcow2", 60,
+                    createTargetVolume, 1024L);
+
+            Assert.assertTrue(result);
+            Assert.assertEquals(1, qemuMock.constructed().size());
+            QemuImg qemu = qemuMock.constructed().get(0);
+            Mockito.verify(qemu).convert(any(QemuImgFile.class), 
any(QemuImgFile.class));
+            if (!createTargetVolume) {
+                Mockito.verify(linstorPool, 
Mockito.never()).newVolumeReadsAsZero(anyString());
+            }
+            return new Pair<>(qemu, ctorArgs[0]);
+        }
+    }
+
+    @Test
+    public void testInPlaceLinstorRestoreDoesNotSkipZeroes() throws Exception {
+        // Even on a thin pool: the volume still holds its old data, so the 
backup's zero regions
+        // must be written.
+        Pair<QemuImg, List<?>> restore = restoreOntoLinstorVolume(false, true);
+
+        Assert.assertEquals(Boolean.FALSE, restore.second().get(1));
+        Mockito.verify(restore.first()).setSkipTargetVolumeCreation(true);
+    }
+
+    @Test
+    public void testLinstorRestoreToNewThickVolumeDoesNotSkipZeroes() throws 
Exception {
+        Pair<QemuImg, List<?>> restore = restoreOntoLinstorVolume(true, false);
+
+        Assert.assertEquals(Boolean.FALSE, restore.second().get(1));
+        Mockito.verify(restore.first()).setSkipTargetVolumeCreation(true);
+    }
+
+    @Test
+    public void testLinstorRestoreToNewThinVolumeSkipsZeroes() throws 
Exception {
+        Pair<QemuImg, List<?>> restore = restoreOntoLinstorVolume(true, true);
+
+        Assert.assertEquals(Boolean.TRUE, restore.second().get(1));
+        Mockito.verify(restore.first()).setSkipTargetVolumeCreation(true);
+    }
 }
diff --git 
a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtTakeBackupCommandWrapperTest.java
 
b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtTakeBackupCommandWrapperTest.java
index 7ef64fd53b5..2feaba13528 100644
--- 
a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtTakeBackupCommandWrapperTest.java
+++ 
b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtTakeBackupCommandWrapperTest.java
@@ -18,25 +18,41 @@ package com.cloud.hypervisor.kvm.resource.wrapper;
 
 import static org.mockito.ArgumentMatchers.anyList;
 import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.when;
 
+import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.List;
 
 import org.apache.cloudstack.backup.BackupAnswer;
 import org.apache.cloudstack.backup.TakeBackupCommand;
+import org.apache.cloudstack.storage.to.PrimaryDataStoreTO;
 import org.junit.Assert;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
+import org.libvirt.Connect;
+import org.libvirt.Domain;
+import org.libvirt.DomainBlockInfo;
+import org.libvirt.DomainInfo;
+import org.libvirt.LibvirtException;
 import org.mockito.ArgumentCaptor;
+import org.mockito.InOrder;
 import org.mockito.MockedStatic;
 import org.mockito.Mockito;
 import org.mockito.junit.MockitoJUnitRunner;
 
 import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource;
+import com.cloud.hypervisor.kvm.resource.LibvirtVMDef.DiskDef;
+import com.cloud.hypervisor.kvm.storage.KVMStoragePool;
+import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager;
+import com.cloud.storage.Storage;
 import com.cloud.utils.Pair;
+import com.cloud.utils.exception.CloudRuntimeException;
 import com.cloud.utils.script.Script;
 
 @RunWith(MockitoJUnitRunner.class)
@@ -99,4 +115,268 @@ public class LibvirtTakeBackupCommandWrapperTest {
 
         Assert.assertFalse(runAndCaptureArgv().contains("--quiesce-timeout"));
     }
+
+    private void runFailing(Pair<Integer, String> result, boolean 
expectJobAbort) {
+        try (MockedStatic<Script> scriptMock = mockStatic(Script.class)) {
+            scriptMock.when(() -> Script.executePipedCommands(anyList(), 
anyLong())).thenReturn(result);
+            scriptMock.when(() -> 
Script.getExecutableAbsolutePath("virsh")).thenReturn("/usr/bin/virsh");
+
+            BackupAnswer answer = (BackupAnswer) wrapper.execute(command, 
libvirtComputingResource);
+            Assert.assertFalse(answer.getResult());
+
+            if (expectJobAbort) {
+                scriptMock.verify(() -> 
Script.executeCommandForExitValue("/usr/bin/virsh", "-c", "qemu:///system",
+                        "domjobabort", "i-2-3-VM"));
+            } else {
+                scriptMock.verify(() -> 
Script.executeCommandForExitValue(anyString(), anyString(), anyString(),
+                        anyString(), anyString()), never());
+            }
+        }
+    }
+
+    private static final String LINSTOR_DEVICE = "/dev/drbd/by-res/cs-vol-1/0";
+
+    /** A stopped VM with one volume on a LINSTOR pool; its device exists here 
as given. */
+    private KVMStoragePoolManager mockStoppedVmOnLinstor(boolean deviceExists) 
{
+        PrimaryDataStoreTO pool = Mockito.mock(PrimaryDataStoreTO.class);
+        when(pool.getPoolType()).thenReturn(Storage.StoragePoolType.Linstor);
+        Mockito.lenient().when(pool.getUuid()).thenReturn("pool-uuid");
+        command.setVolumePools(Collections.singletonList(pool));
+        command.setVolumePaths(Collections.singletonList(LINSTOR_DEVICE));
+        KVMStoragePoolManager storagePoolMgr = 
Mockito.mock(KVMStoragePoolManager.class);
+        
when(libvirtComputingResource.getStoragePoolMgr()).thenReturn(storagePoolMgr);
+        wrapper = Mockito.spy(wrapper);
+        
Mockito.doReturn(deviceExists).when(wrapper).deviceExists(LINSTOR_DEVICE);
+        return storagePoolMgr;
+    }
+
+    @Test
+    public void executeConnectsLinstorVolumeMissingOnThisHost() {
+        KVMStoragePoolManager storagePoolMgr = mockStoppedVmOnLinstor(false);
+
+        List<String> argv = runAndCaptureArgv();
+
+        Assert.assertEquals(LINSTOR_DEVICE, argv.get(argv.indexOf("-d") + 1));
+        InOrder inOrder = Mockito.inOrder(storagePoolMgr);
+        
inOrder.verify(storagePoolMgr).connectPhysicalDisk(Storage.StoragePoolType.Linstor,
 "pool-uuid", "vol-1", null);
+        
inOrder.verify(storagePoolMgr).disconnectPhysicalDisk(Storage.StoragePoolType.Linstor,
 "pool-uuid", "vol-1");
+    }
+
+    @Test
+    public void executeLeavesLinstorVolumeWithDeviceOnThisHostAlone() {
+        KVMStoragePoolManager storagePoolMgr = mockStoppedVmOnLinstor(true);
+
+        runAndCaptureArgv();
+
+        Mockito.verify(storagePoolMgr, 
never()).connectPhysicalDisk(Mockito.any(Storage.StoragePoolType.class),
+                anyString(), anyString(), Mockito.any());
+        Mockito.verify(storagePoolMgr, 
never()).disconnectPhysicalDisk(Mockito.any(Storage.StoragePoolType.class),
+                anyString(), anyString());
+    }
+
+    @Test
+    public void executeDisconnectsConnectedVolumeWhenBackupFails() {
+        KVMStoragePoolManager storagePoolMgr = mockStoppedVmOnLinstor(false);
+
+        runFailing(new Pair<>(1, "qemu-img convert failed"), false);
+
+        
Mockito.verify(storagePoolMgr).disconnectPhysicalDisk(Storage.StoragePoolType.Linstor,
 "pool-uuid", "vol-1");
+    }
+
+    @Test
+    public void executeFailsWhenLinstorVolumeCannotBeConnected() {
+        KVMStoragePoolManager storagePoolMgr = mockStoppedVmOnLinstor(false);
+        
when(storagePoolMgr.connectPhysicalDisk(Storage.StoragePoolType.Linstor, 
"pool-uuid", "vol-1", null))
+                .thenThrow(new CloudRuntimeException("resource not found"));
+
+        try (MockedStatic<Script> scriptMock = mockStatic(Script.class)) {
+            BackupAnswer answer = (BackupAnswer) wrapper.execute(command, 
libvirtComputingResource);
+
+            Assert.assertFalse(answer.getResult());
+            Assert.assertTrue(answer.getDetails().contains("resource not 
found"));
+            scriptMock.verify(() -> Script.executePipedCommands(anyList(), 
anyLong()), never());
+        }
+        Mockito.verify(storagePoolMgr, 
never()).disconnectPhysicalDisk(Mockito.any(Storage.StoragePoolType.class),
+                anyString(), anyString());
+    }
+
+    /** The VM runs here with disks of the given sizes on a pool that hands 
out scratch devices. */
+    private KVMStoragePool mockRunningVmOnScratchPool(long... diskSizes) 
throws LibvirtException {
+        LibvirtUtilitiesHelper libvirtUtilitiesHelper = 
Mockito.mock(LibvirtUtilitiesHelper.class);
+        Connect conn = Mockito.mock(Connect.class);
+        Domain dm = Mockito.mock(Domain.class);
+        DomainInfo info = new DomainInfo();
+        info.state = DomainInfo.DomainState.VIR_DOMAIN_RUNNING;
+        
when(libvirtComputingResource.getLibvirtUtilitiesHelper()).thenReturn(libvirtUtilitiesHelper);
+        
when(libvirtUtilitiesHelper.getConnectionByVmName("i-2-3-VM")).thenReturn(conn);
+        when(libvirtComputingResource.getDomain(conn, 
"i-2-3-VM")).thenReturn(dm);
+        when(dm.getInfo()).thenReturn(info);
+
+        List<DiskDef> disks = new ArrayList<>();
+        List<PrimaryDataStoreTO> pools = new ArrayList<>();
+        List<String> volumePaths = new ArrayList<>();
+        List<String> scratchDevices = new ArrayList<>();
+        for (int i = 0; i < diskSizes.length; i++) {
+            String label = "vd" + (char) ('a' + i);
+            DiskDef disk = Mockito.mock(DiskDef.class);
+            when(disk.getDeviceType()).thenReturn(DiskDef.DeviceType.DISK);
+            when(disk.getDiskLabel()).thenReturn(label);
+            DomainBlockInfo blockInfo = Mockito.mock(DomainBlockInfo.class);
+            when(blockInfo.getCapacity()).thenReturn(diskSizes[i]);
+            when(dm.blockInfo(label)).thenReturn(blockInfo);
+            disks.add(disk);
+
+            PrimaryDataStoreTO pool = Mockito.mock(PrimaryDataStoreTO.class);
+            
when(pool.getPoolType()).thenReturn(Storage.StoragePoolType.Linstor);
+            pools.add(pool);
+            volumePaths.add("/dev/drbd/by-res/cs-vol-" + i + "/0");
+            scratchDevices.add("/dev/scratch/" + label);
+        }
+        DiskDef cdrom = Mockito.mock(DiskDef.class);
+        when(cdrom.getDeviceType()).thenReturn(DiskDef.DeviceType.CDROM);
+        disks.add(cdrom);
+        when(libvirtComputingResource.getDisks(conn, 
"i-2-3-VM")).thenReturn(disks);
+        when(pools.get(0).getUuid()).thenReturn("pool-uuid");
+        command.setVolumePools(pools);
+        command.setVolumePaths(volumePaths);
+        wrapper = Mockito.spy(wrapper);
+        Mockito.doReturn(true).when(wrapper).deviceExists(anyString());
+
+        KVMStoragePoolManager storagePoolMgr = 
Mockito.mock(KVMStoragePoolManager.class);
+        KVMStoragePool scratchPool = Mockito.mock(KVMStoragePool.class);
+        
when(libvirtComputingResource.getStoragePoolMgr()).thenReturn(storagePoolMgr);
+        when(storagePoolMgr.getStoragePool(Storage.StoragePoolType.Linstor, 
"pool-uuid")).thenReturn(scratchPool);
+        when(scratchPool.createBackupScratch(Mockito.eq("i-2-3-VM"), 
anyList()))
+                .thenReturn(new Pair<>("cs-bkscratch-1", scratchDevices));
+        return scratchPool;
+    }
+
+    @Test
+    public void executeAbortsPullBackupJobOnTimeout() throws LibvirtException {
+        command.setMode("full");
+        command.setMethod("content");
+        KVMStoragePool scratchPool = mockRunningVmOnScratchPool(1L << 30);
+
+        runFailing(new Pair<>(-1, Script.ERR_TIMEOUT), true);
+
+        Mockito.verify(scratchPool).deleteBackupScratch("cs-bkscratch-1");
+    }
+
+    @Test
+    public void executeLeavesPullBackupJobToScriptOnFailure() throws 
LibvirtException {
+        command.setMode("full");
+        command.setMethod("content");
+        KVMStoragePool scratchPool = mockRunningVmOnScratchPool(1L << 30);
+
+        runFailing(new Pair<>(1, "qemu-img convert failed"), false);
+
+        Mockito.verify(scratchPool).deleteBackupScratch("cs-bkscratch-1");
+    }
+
+    @Test
+    public void executeFailsWhenScratchCannotBeCreated() throws 
LibvirtException {
+        command.setMode("full");
+        command.setMethod("content");
+        KVMStoragePool scratchPool = mockRunningVmOnScratchPool(1L << 30);
+        when(scratchPool.createBackupScratch(Mockito.eq("i-2-3-VM"), 
anyList()))
+                .thenThrow(new CloudRuntimeException("no storage pool"));
+
+        try (MockedStatic<Script> scriptMock = mockStatic(Script.class)) {
+            BackupAnswer answer = (BackupAnswer) wrapper.execute(command, 
libvirtComputingResource);
+
+            Assert.assertFalse(answer.getResult());
+            Assert.assertTrue(answer.getDetails().contains("no storage pool"));
+            scriptMock.verify(() -> Script.executePipedCommands(anyList(), 
anyLong()), never());
+        }
+        Mockito.verify(scratchPool, never()).deleteBackupScratch(anyString());
+    }
+
+    @Test
+    public void executeKeepsBackupWhenScratchCannotBeDeleted() throws 
LibvirtException {
+        command.setMode("full");
+        command.setMethod("content");
+        KVMStoragePool scratchPool = mockRunningVmOnScratchPool(1L << 30);
+        Mockito.doThrow(new CloudRuntimeException("in 
use")).when(scratchPool).deleteBackupScratch("cs-bkscratch-1");
+
+        Assert.assertTrue(runAndCaptureArgv().contains("--scratch-devs"));
+    }
+
+    @Test
+    public void executeTakesStoppedVmBackupWithoutScratch() throws 
LibvirtException {
+        command.setMode("full");
+        command.setMethod("content");
+        LibvirtUtilitiesHelper libvirtUtilitiesHelper = 
Mockito.mock(LibvirtUtilitiesHelper.class);
+        Connect conn = Mockito.mock(Connect.class);
+        
when(libvirtComputingResource.getLibvirtUtilitiesHelper()).thenReturn(libvirtUtilitiesHelper);
+        
when(libvirtUtilitiesHelper.getConnectionByVmName("i-2-3-VM")).thenReturn(conn);
+        when(libvirtComputingResource.getDomain(conn, 
"i-2-3-VM")).thenThrow(Mockito.mock(LibvirtException.class));
+
+        Assert.assertFalse(runAndCaptureArgv().contains("--scratch-devs"));
+    }
+
+    @Test
+    public void scratchSizeHoldsAFullyAllocatedQcow2() {
+        // qemu-img measure -O qcow2 --size <n>, "fully allocated size"
+        
Assert.assertTrue(LibvirtTakeBackupCommandWrapper.scratchSize(540909568L) >= 
541327360L);
+        
Assert.assertTrue(LibvirtTakeBackupCommandWrapper.scratchSize(5371715584L) >= 
5372837888L);
+        
Assert.assertTrue(LibvirtTakeBackupCommandWrapper.scratchSize(1099511627776L) 
>= 1099679662080L);
+    }
+
+    @Test
+    public void executeDoesNotAbortPushBackupJobOnTimeout() {
+        command.setMode("legacy-full");
+
+        runFailing(new Pair<>(-1, Script.ERR_TIMEOUT), false);
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    public void executePassesContentMethodToScript() throws LibvirtException {
+        command.setMode("incremental");
+        command.setMethod("content");
+        
command.setParentPaths(Arrays.asList("i-2-3-VM/2026.10.03.00.00.00/root.vol-0.qcow2",
+                "i-2-3-VM/2026.10.03.00.00.00/datadisk.vol-1.qcow2"));
+        KVMStoragePool scratchPool = mockRunningVmOnScratchPool(1L << 30, 5L 
<< 30);
+
+        List<String> argv = runAndCaptureArgv();
+
+        Assert.assertEquals("incremental", argv.get(argv.indexOf("-M") + 1));
+        Assert.assertEquals("content", argv.get(argv.indexOf("--method") + 1));
+        Assert.assertFalse(argv.contains("--bitmap-new"));
+        // One scratch device per disk, in disk order, each large enough for 
the whole disk.
+        Assert.assertEquals("/dev/scratch/vda,/dev/scratch/vdb", 
argv.get(argv.indexOf("--scratch-devs") + 1));
+        ArgumentCaptor<List<Long>> sizes = ArgumentCaptor.forClass(List.class);
+        
Mockito.verify(scratchPool).createBackupScratch(Mockito.eq("i-2-3-VM"), 
sizes.capture());
+        
Assert.assertEquals(Arrays.asList(LibvirtTakeBackupCommandWrapper.scratchSize(1L
 << 30),
+                LibvirtTakeBackupCommandWrapper.scratchSize(5L << 30)), 
sizes.getValue());
+        Mockito.verify(scratchPool).deleteBackupScratch("cs-bkscratch-1");
+    }
+
+    @Test
+    public void executeOmitsScratchDevicesForPushMode() {
+        command.setMode("legacy-full");
+
+        Assert.assertFalse(runAndCaptureArgv().contains("--scratch-devs"));
+    }
+
+    @Test
+    public void executeRejectsContentIncrementalWithoutParentPaths() {
+        command.setMode("incremental");
+        command.setMethod("content");
+
+        BackupAnswer answer = (BackupAnswer) wrapper.execute(command, 
libvirtComputingResource);
+
+        Assert.assertFalse(answer.getResult());
+        Assert.assertEquals("content incremental mode requires parentPaths", 
answer.getDetails());
+    }
+
+    @Test
+    public void executeRejectsLegacyFullWithContentMethod() {
+        command.setMode("legacy-full");
+        command.setMethod("content");
+
+        BackupAnswer answer = (BackupAnswer) wrapper.execute(command, 
libvirtComputingResource);
+
+        Assert.assertFalse(answer.getResult());
+    }
 }
diff --git 
a/plugins/storage/volume/linstor/src/main/java/com/cloud/hypervisor/kvm/storage/LinstorStorageAdaptor.java
 
b/plugins/storage/volume/linstor/src/main/java/com/cloud/hypervisor/kvm/storage/LinstorStorageAdaptor.java
index 848bdc60d66..94878684a99 100644
--- 
a/plugins/storage/volume/linstor/src/main/java/com/cloud/hypervisor/kvm/storage/LinstorStorageAdaptor.java
+++ 
b/plugins/storage/volume/linstor/src/main/java/com/cloud/hypervisor/kvm/storage/LinstorStorageAdaptor.java
@@ -19,15 +19,20 @@ package com.cloud.hypervisor.kvm.storage;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.Comparator;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import java.util.Set;
 import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.stream.Collectors;
 
 import javax.annotation.Nonnull;
 
 import com.cloud.storage.Storage;
+import com.cloud.utils.Pair;
 import com.cloud.utils.exception.CloudRuntimeException;
 import com.cloud.utils.script.Script;
 import 
org.apache.cloudstack.storage.datastore.util.LinstorConfigurationManager;
@@ -46,18 +51,24 @@ import com.linbit.linstor.api.ApiException;
 import com.linbit.linstor.api.DevelopersApi;
 import com.linbit.linstor.api.model.ApiCallRc;
 import com.linbit.linstor.api.model.ApiCallRcList;
+import com.linbit.linstor.api.model.AutoPlaceRequest;
+import com.linbit.linstor.api.model.AutoSelectFilter;
+import com.linbit.linstor.api.model.LayerType;
 import com.linbit.linstor.api.model.Node;
 import com.linbit.linstor.api.model.Properties;
 import com.linbit.linstor.api.model.ProviderKind;
 import com.linbit.linstor.api.model.Resource;
 import com.linbit.linstor.api.model.ResourceConnectionModify;
 import com.linbit.linstor.api.model.ResourceDefinition;
+import com.linbit.linstor.api.model.ResourceDefinitionCreate;
 import com.linbit.linstor.api.model.ResourceDefinitionModify;
 import com.linbit.linstor.api.model.ResourceGroupSpawn;
 import com.linbit.linstor.api.model.ResourceMakeAvailable;
 import com.linbit.linstor.api.model.ResourceWithVolumes;
 import com.linbit.linstor.api.model.StoragePool;
+import com.linbit.linstor.api.model.Volume;
 import com.linbit.linstor.api.model.VolumeDefinition;
+import com.linbit.linstor.api.model.VolumeDefinitionCreate;
 
 import java.io.File;
 import java.io.FileInputStream;
@@ -69,9 +80,14 @@ import java.nio.file.Paths;
 public class LinstorStorageAdaptor implements StorageAdaptor {
     protected Logger logger = LogManager.getLogger(getClass());
     private static final Map<String, KVMStoragePool> 
MapStorageUuidToStoragePool = new HashMap<>();
+    static final String BACKUP_SCRATCH_PREFIX = "cs-bkscratch-";
+    static final String BACKUP_SCRATCH_VM_PROP = "Aux/cs-backup-vm";
+    private static final long DEVICE_WAIT_MS = 10000;
+    // Controllers this agent has already removed its leftover backup scratch 
from, see deleteLeftoverBackupScratch().
+    private static final Set<String> BackupScratchSweptControllers = 
ConcurrentHashMap.newKeySet();
     private final String localNodeName;
 
-    private DevelopersApi getLinstorAPI(KVMStoragePool pool) {
+    DevelopersApi getLinstorAPI(KVMStoragePool pool) {
         // Use a fresh client per pool so a self-signed/insecure pool can't 
weaken the TLS settings
         // of another pool sharing this agent.
         ApiClient client = new ApiClient();
@@ -186,6 +202,11 @@ public class LinstorStorageAdaptor implements 
StorageAdaptor {
                 : 
Boolean.parseBoolean(LinstorConfigurationManager.InsecureSsl.defaultValue());
         LinstorStoragePool storagePool = new LinstorStoragePool(name, host, 
port, userInfo, type, this, insecureSsl);
 
+        // Pools are registered again whenever the agent reconnects, but only 
the first registration of a
+        // controller precedes every backup that could be using its scratch 
space.
+        if (BackupScratchSweptControllers.add(host)) {
+            deleteLeftoverBackupScratch(storagePool);
+        }
         MapStorageUuidToStoragePool.put(name, storagePool);
 
         return storagePool;
@@ -208,6 +229,206 @@ public class LinstorStorageAdaptor implements 
StorageAdaptor {
         ApiCallRcList answers = api.resourceMakeAvailableOnNode(rscName, 
localNodeName, rma);
         handleLinstorApiAnswers(answers,
             String.format("Linstor: Unable to make resource %s available on 
node: %s", rscName, localNodeName));
+        waitForLocalDevice(api, rscName);
+    }
+
+    /**
+     * Waits until the device of resource {@code rscName} exists on this node. 
Making a resource available
+     * returns before udev has created its /dev/drbd/by-res link, so opening 
the device right away can fail
+     * with "No such file or directory".
+     */
+    private void waitForLocalDevice(DevelopersApi api, String rscName) throws 
ApiException {
+        List<ResourceWithVolumes> resources = 
api.viewResources(Collections.singletonList(localNodeName),
+                Collections.singletonList(rscName), Collections.emptyList(), 
null, null, null);
+        if (resources.isEmpty() || resources.get(0).getVolumes().isEmpty()) {
+            throw new CloudRuntimeException(String.format("Linstor: resource 
%s has no volumes on %s",
+                    rscName, localNodeName));
+        }
+        waitForDevice(LinstorUtil.getDevicePathFromResource(resources.get(0)));
+    }
+
+    void waitForDevice(String device) {
+        long deadline = System.currentTimeMillis() + DEVICE_WAIT_MS;
+        while (!Files.exists(Paths.get(device))) {
+            if (System.currentTimeMillis() > deadline) {
+                throw new CloudRuntimeException(String.format("Linstor: device 
%s did not appear within %d ms",
+                        device, DEVICE_WAIT_MS));
+            }
+            try {
+                Thread.sleep(100);
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                throw new CloudRuntimeException("Linstor: interrupted while 
waiting for device " + device, e);
+            }
+        }
+    }
+
+    /**
+     * Creates the scratch space of a pull-mode backup as one resource with a 
volume per size. The resource
+     * definition is created in the pool's resource group, so the group's 
properties and storage pool selection
+     * apply; spawning it from the group would limit the volumes to the 
group's volume groups.
+     *
+     * Copy-before-write runs inline with the guest's writes, so the scratch 
is placed on a storage pool of this
+     * node if there is one, as a plain STORAGE resource: it only lives for 
one backup and needs no replication.
+     * Otherwise, e.g. on a compute node without storage, it is a single DRBD 
replica on another node that this
+     * node attaches diskless.
+     *
+     * The agent deletes the scratch once the backup has ended. If it never 
gets there, e.g. because the agent
+     * crashed or the host went down, it deletes the leftover the next time it 
starts, see
+     * {@link #deleteLeftoverBackupScratch(KVMStoragePool)}; the {@value 
#BACKUP_SCRATCH_VM_PROP} property
+     * names the VM.
+     */
+    public Pair<String, List<String>> createBackupScratch(KVMStoragePool pool, 
String vmName, List<Long> sizes) {
+        final LinstorStoragePool lpool = (LinstorStoragePool) pool;
+        final DevelopersApi api = getLinstorAPI(pool);
+        // LINSTOR caps resource names at 48 characters.
+        final String rscName = BACKUP_SCRATCH_PREFIX + 
UUID.randomUUID().toString().replace("-", "");
+        logger.info("Linstor: creating backup scratch {} for VM {} with volume 
sizes {}", rscName, vmName, sizes);
+        try {
+            ResourceDefinition rd = new ResourceDefinition();
+            rd.setName(rscName);
+            rd.setResourceGroupName(lpool.getResourceGroup());
+            Properties props = new Properties();
+            props.put(BACKUP_SCRATCH_VM_PROP, vmName);
+            rd.setProps(props);
+            ResourceDefinitionCreate rdCreate = new ResourceDefinitionCreate();
+            rdCreate.setResourceDefinition(rd);
+            checkLinstorAnswersThrow(api.resourceDefinitionCreate(rdCreate));
+        } catch (ApiException apiEx) {
+            throw new CloudRuntimeException(apiEx.getBestMessage(), apiEx);
+        }
+
+        try {
+            for (int vlmNr = 0; vlmNr < sizes.size(); vlmNr++) {
+                VolumeDefinition vd = new VolumeDefinition();
+                vd.setVolumeNumber(vlmNr);
+                vd.setSizeKib((sizes.get(vlmNr) + 1023) / 1024);
+                VolumeDefinitionCreate vdCreate = new VolumeDefinitionCreate();
+                vdCreate.setVolumeDefinition(vd);
+                checkLinstorAnswersThrow(api.volumeDefinitionCreate(rscName, 
vdCreate));
+            }
+            placeBackupScratch(api, rscName);
+            return new Pair<>(rscName, getBackupScratchDevices(api, rscName));
+        } catch (ApiException | CloudRuntimeException ex) {
+            try {
+                deleteBackupScratch(pool, rscName);
+            } catch (CloudRuntimeException deleteEx) {
+                logger.warn("Linstor: unable to delete backup scratch {}, 
delete it manually: {}",
+                        rscName, deleteEx.getMessage());
+            }
+            if (ex instanceof ApiException) {
+                throw new CloudRuntimeException(((ApiException) 
ex).getBestMessage(), ex);
+            }
+            throw (CloudRuntimeException) ex;
+        }
+    }
+
+    private void placeBackupScratch(DevelopersApi api, String rscName) throws 
ApiException {
+        String localError;
+        try {
+            // A failed placement usually comes back as an HTTP error rather 
than as error answers.
+            ApiCallRcList answers = api.resourceAutoplace(rscName, 
backupScratchPlacement(
+                    Collections.singletonList(localNodeName), 
LayerType.STORAGE));
+            if (!answers.hasError()) {
+                logLinstorAnswers(answers);
+                return;
+            }
+            localError = LinstorUtil.getBestErrorMessage(answers);
+        } catch (ApiException apiEx) {
+            localError = apiEx.getBestMessage();
+        }
+        logger.info("Linstor: cannot place backup scratch {} on {} ({}), 
attaching a replica on another node diskless",
+                rscName, localNodeName, localError);
+        checkLinstorAnswersThrow(api.resourceAutoplace(rscName, 
backupScratchPlacement(
+                Collections.emptyList(), LayerType.DRBD, LayerType.STORAGE)));
+        makeResourceAvailable(api, rscName, false);
+    }
+
+    private static AutoPlaceRequest backupScratchPlacement(List<String> 
nodeNames, LayerType... layers) {
+        List<String> layerStack = 
Arrays.stream(layers).map(LayerType::toString).collect(Collectors.toList());
+        AutoSelectFilter filter = new AutoSelectFilter();
+        filter.setPlaceCount(1);
+        filter.setNodeNameList(nodeNames);
+        filter.setLayerStack(layerStack);
+        filter.setDisklessOnRemaining(false);
+        AutoPlaceRequest request = new AutoPlaceRequest();
+        request.setSelectFilter(filter);
+        request.setLayerList(Arrays.asList(layers));
+        request.setDisklessOnRemaining(false);
+        return request;
+    }
+
+    /**
+     * The device paths of the scratch volumes on this node, in volume number 
order, once udev has created them.
+     */
+    private List<String> getBackupScratchDevices(DevelopersApi api, String 
rscName) throws ApiException {
+        List<ResourceWithVolumes> resources = 
api.viewResources(Collections.singletonList(localNodeName),
+                Collections.singletonList(rscName), Collections.emptyList(), 
null, null, null);
+        if (resources.isEmpty()) {
+            throw new CloudRuntimeException(String.format("Linstor: backup 
scratch %s has no resource on %s",
+                    rscName, localNodeName));
+        }
+        ResourceWithVolumes rsc = resources.get(0);
+        boolean drbd = rsc.getLayerObject().getDrbd() != null;
+        List<String> devices = rsc.getVolumes().stream()
+                .sorted(Comparator.comparing(Volume::getVolumeNumber))
+                .map(vlm -> drbd ? String.format("/dev/drbd/by-res/%s/%d", 
rscName, vlm.getVolumeNumber())
+                        : vlm.getDevicePath())
+                .collect(Collectors.toList());
+        for (String device : devices) {
+            waitForDevice(device);
+        }
+        return devices;
+    }
+
+    /**
+     * Deletes the backup scratch resources an earlier run of this agent left 
on this node, e.g. because it
+     * crashed or was restarted during a backup. Called when the first pool of 
a controller is registered,
+     * before any backup can use it, so none of them can belong to a running 
backup. A scratch always has a
+     * resource on the node whose agent created it, diskless if its data lives 
on another node. Failures are
+     * logged only: a leftover must not keep the pool from being registered.
+     */
+    void deleteLeftoverBackupScratch(KVMStoragePool pool) {
+        final DevelopersApi api = getLinstorAPI(pool);
+        try {
+            Map<String, ResourceDefinition> scratchRds = 
LinstorUtil.getRDListStartingWith(api, BACKUP_SCRATCH_PREFIX)
+                    
.stream().collect(Collectors.toMap(ResourceDefinition::getName, rd -> rd));
+            if (scratchRds.isEmpty()) {
+                return;
+            }
+            List<String> leftovers = 
api.viewResources(Collections.singletonList(localNodeName),
+                    new ArrayList<>(scratchRds.keySet()), 
Collections.emptyList(), null, null, null)
+                    
.stream().map(Resource::getName).distinct().collect(Collectors.toList());
+            for (String rscName : leftovers) {
+                Properties props = scratchRds.get(rscName).getProps();
+                String vmName = props != null ? 
props.get(BACKUP_SCRATCH_VM_PROP) : null;
+                logger.warn("Linstor: deleting backup scratch {} of VM {} left 
behind on {}", rscName, vmName,
+                        localNodeName);
+                try {
+                    deleteBackupScratch(pool, rscName);
+                } catch (CloudRuntimeException e) {
+                    // Typically after a crash during a backup: the VM's 
pull-mode backup job outlived the agent
+                    // and still holds the scratch devices.
+                    logger.warn("Linstor: unable to delete leftover backup 
scratch {}: {}. If VM {} still has a backup "
+                            + "job, end it with 'virsh domjobabort {}', then 
delete the resource definition manually.",
+                            rscName, e.getMessage(), vmName, vmName);
+                }
+            }
+        } catch (ApiException apiEx) {
+            logger.warn("Linstor: unable to look for leftover backup scratch 
on {}: {}", localNodeName,
+                    apiEx.getBestMessage());
+        } catch (CloudRuntimeException e) {
+            logger.warn("Linstor: unable to look for leftover backup scratch 
on {}: {}", localNodeName, e.getMessage());
+        }
+    }
+
+    public void deleteBackupScratch(KVMStoragePool pool, String rscName) {
+        logger.info("Linstor: deleting backup scratch {}", rscName);
+        try {
+            
checkLinstorAnswersThrow(getLinstorAPI(pool).resourceDefinitionDelete(rscName));
+        } catch (ApiException apiEx) {
+            throw new CloudRuntimeException(apiEx.getBestMessage(), apiEx);
+        }
     }
 
     /**
@@ -340,7 +561,7 @@ public class LinstorStorageAdaptor implements 
StorageAdaptor {
             ResourceMakeAvailable rma = new ResourceMakeAvailable();
             ApiCallRcList answers = api.resourceMakeAvailableOnNode(rscName, 
localNodeName, rma);
             checkLinstorAnswersThrow(answers);
-
+            waitForLocalDevice(api, rscName);
         } catch (ApiException apiEx) {
             logger.error(apiEx);
             throw new CloudRuntimeException(apiEx.getBestMessage(), apiEx);
diff --git 
a/plugins/storage/volume/linstor/src/main/java/com/cloud/hypervisor/kvm/storage/LinstorStoragePool.java
 
b/plugins/storage/volume/linstor/src/main/java/com/cloud/hypervisor/kvm/storage/LinstorStoragePool.java
index 8a02560fd46..2e9a0bbe98b 100644
--- 
a/plugins/storage/volume/linstor/src/main/java/com/cloud/hypervisor/kvm/storage/LinstorStoragePool.java
+++ 
b/plugins/storage/volume/linstor/src/main/java/com/cloud/hypervisor/kvm/storage/LinstorStoragePool.java
@@ -24,6 +24,7 @@ import com.cloud.agent.properties.AgentProperties;
 import com.cloud.agent.properties.AgentPropertiesFileHandler;
 import com.cloud.hypervisor.kvm.resource.KVMHABase.HAStoragePool;
 import com.cloud.storage.Storage;
+import com.cloud.utils.Pair;
 import com.cloud.utils.exception.CloudRuntimeException;
 import com.cloud.utils.script.OutputInterpreter;
 import com.cloud.utils.script.Script;
@@ -33,6 +34,7 @@ import com.google.gson.JsonIOException;
 import com.google.gson.JsonObject;
 import com.google.gson.JsonParser;
 import com.google.gson.JsonSyntaxException;
+import org.apache.cloudstack.storage.datastore.util.LinstorUtil;
 import org.apache.cloudstack.utils.qemu.QemuImg;
 import org.apache.logging.log4j.LogManager;
 import org.apache.logging.log4j.Logger;
@@ -374,6 +376,25 @@ public class LinstorStoragePool implements KVMStoragePool {
         return false;
     }
 
+    /**
+     * Thin LVM and ZFS backed resources start out unallocated and read as 
zeroes, thick LVM hands out
+     * whatever was on the disk before.
+     */
+    @Override
+    public boolean newVolumeReadsAsZero(String volumeUuid) {
+        return LinstorUtil.resourceSupportZeroBlocks(this, 
LinstorUtil.RSC_PREFIX + volumeUuid);
+    }
+
+    @Override
+    public Pair<String, List<String>> createBackupScratch(String vmName, 
List<Long> sizes) {
+        return ((LinstorStorageAdaptor) 
_storageAdaptor).createBackupScratch(this, vmName, sizes);
+    }
+
+    @Override
+    public void deleteBackupScratch(String name) {
+        ((LinstorStorageAdaptor) _storageAdaptor).deleteBackupScratch(this, 
name);
+    }
+
     @Override
     public Boolean hasVmActivity(HAStoragePool pool, HostTO host, Duration 
activityScriptTimeout, String volumeUUIDListString, String vmActivityCheckPath, 
long duration) {
         LOGGER.trace(String.format("Linstor.vmActivityCheck: %s, %s", 
pool.getPoolIp(), host.getPrivateNetwork().getIp()));
diff --git 
a/plugins/storage/volume/linstor/src/test/java/com/cloud/hypervisor/kvm/storage/LinstorStorageAdaptorTest.java
 
b/plugins/storage/volume/linstor/src/test/java/com/cloud/hypervisor/kvm/storage/LinstorStorageAdaptorTest.java
new file mode 100644
index 00000000000..054d6b8eac1
--- /dev/null
+++ 
b/plugins/storage/volume/linstor/src/test/java/com/cloud/hypervisor/kvm/storage/LinstorStorageAdaptorTest.java
@@ -0,0 +1,344 @@
+// 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 com.cloud.hypervisor.kvm.storage;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.cloudstack.utils.qemu.QemuImg;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InOrder;
+import org.mockito.Mockito;
+import org.mockito.junit.MockitoJUnitRunner;
+
+import com.cloud.storage.Storage;
+import com.cloud.utils.Pair;
+import com.cloud.utils.exception.CloudRuntimeException;
+import com.linbit.linstor.api.ApiConsts;
+import com.linbit.linstor.api.ApiException;
+import com.linbit.linstor.api.DevelopersApi;
+import com.linbit.linstor.api.model.ApiCallRc;
+import com.linbit.linstor.api.model.ApiCallRcList;
+import com.linbit.linstor.api.model.AutoPlaceRequest;
+import com.linbit.linstor.api.model.DrbdResource;
+import com.linbit.linstor.api.model.LayerType;
+import com.linbit.linstor.api.model.Properties;
+import com.linbit.linstor.api.model.ResourceDefinition;
+import com.linbit.linstor.api.model.ResourceDefinitionCreate;
+import com.linbit.linstor.api.model.ResourceLayer;
+import com.linbit.linstor.api.model.ResourceMakeAvailable;
+import com.linbit.linstor.api.model.ResourceWithVolumes;
+import com.linbit.linstor.api.model.StorageResource;
+import com.linbit.linstor.api.model.Volume;
+import com.linbit.linstor.api.model.VolumeDefinitionCreate;
+
+@RunWith(MockitoJUnitRunner.class)
+public class LinstorStorageAdaptorTest {
+
+    private static final String DEVICE = "/dev/drbd/by-res/cs-vol-1/0";
+
+    private LinstorStorageAdaptor adaptor;
+    private LinstorStoragePool pool;
+    private DevelopersApi api;
+    private String localNode;
+
+    @Before
+    public void setUp() {
+        adaptor = spy(new LinstorStorageAdaptor());
+        pool = new LinstorStoragePool("pool-uuid", "http://controller:3370";, 
3370, "cloudstack",
+                Storage.StoragePoolType.Linstor, adaptor, false);
+        api = mock(DevelopersApi.class);
+        doReturn(api).when(adaptor).getLinstorAPI(any());
+        doNothing().when(adaptor).waitForDevice(anyString());
+        localNode = LinstorStoragePool.getHostname();
+    }
+
+    private static ApiCallRcList answers(long retCode, String message) {
+        ApiCallRc rc = new ApiCallRc();
+        rc.setRetCode(retCode);
+        rc.setMessage(message);
+        ApiCallRcList list = new ApiCallRcList();
+        list.add(rc);
+        return list;
+    }
+
+    private static ApiCallRcList success() {
+        return answers(ApiConsts.MASK_SUCCESS, "ok");
+    }
+
+    private static ApiCallRcList error(String message) {
+        return answers(ApiConsts.MASK_ERROR, message);
+    }
+
+    private void mockDefinitionsCreated() throws ApiException {
+        when(api.resourceDefinitionCreate(any())).thenReturn(success());
+        when(api.volumeDefinitionCreate(anyString(), 
any())).thenReturn(success());
+    }
+
+    private static ResourceWithVolumes drbdResource() {
+        ResourceLayer layer = new ResourceLayer();
+        layer.setDrbd(new DrbdResource());
+        Volume vlm = new Volume();
+        vlm.setVolumeNumber(0);
+        vlm.setDevicePath("/dev/drbd1000");
+        vlm.setAllocatedSizeKib(0L);
+        ResourceWithVolumes rsc = new ResourceWithVolumes();
+        rsc.setName("cs-vol-1");
+        rsc.setLayerObject(layer);
+        rsc.setVolumes(Collections.singletonList(vlm));
+        return rsc;
+    }
+
+    /** How the controller reports a failed request: an HTTP error carrying 
the answers in its body. */
+    private static ApiException httpError(String message) {
+        return new ApiException(500, Collections.emptyMap(),
+                String.format("[{\"ret_code\": %d, \"message\": \"%s\"}]", 
ApiConsts.MASK_ERROR, message));
+    }
+
+    private static Volume volume(int number, String devicePath) {
+        Volume vlm = new Volume();
+        vlm.setVolumeNumber(number);
+        vlm.setDevicePath(devicePath);
+        return vlm;
+    }
+
+    private void mockLocalResource(boolean drbd, Volume... volumes) throws 
ApiException {
+        ResourceLayer layer = new ResourceLayer();
+        if (drbd) {
+            layer.setDrbd(new DrbdResource());
+        } else {
+            layer.setStorage(new StorageResource());
+        }
+        ResourceWithVolumes rsc = new ResourceWithVolumes();
+        rsc.setLayerObject(layer);
+        rsc.setVolumes(Arrays.asList(volumes));
+        when(api.viewResources(eq(Collections.singletonList(localNode)), 
any(), any(), any(), any(), any()))
+                .thenReturn(Collections.singletonList(rsc));
+    }
+
+    @Test
+    public void createBackupScratchPlacesStorageOnlyResourceLocally() throws 
ApiException {
+        mockDefinitionsCreated();
+        when(api.resourceAutoplace(anyString(), any())).thenReturn(success());
+        mockLocalResource(false, volume(1, "/dev/storage/scratch_00001"), 
volume(0, "/dev/storage/scratch_00000"));
+
+        Pair<String, List<String>> scratch = adaptor.createBackupScratch(pool, 
"i-2-3-VM", Arrays.asList(1025L, 4096L));
+
+        
Assert.assertTrue(scratch.first().startsWith(LinstorStorageAdaptor.BACKUP_SCRATCH_PREFIX));
+        Assert.assertTrue(scratch.first().length() <= 48);
+        Assert.assertEquals(Arrays.asList("/dev/storage/scratch_00000", 
"/dev/storage/scratch_00001"), scratch.second());
+
+        ArgumentCaptor<ResourceDefinitionCreate> rdCreate = 
ArgumentCaptor.forClass(ResourceDefinitionCreate.class);
+        verify(api).resourceDefinitionCreate(rdCreate.capture());
+        ResourceDefinition rd = rdCreate.getValue().getResourceDefinition();
+        Assert.assertEquals(scratch.first(), rd.getName());
+        Assert.assertEquals("cloudstack", rd.getResourceGroupName());
+        Assert.assertEquals("i-2-3-VM", 
rd.getProps().get(LinstorStorageAdaptor.BACKUP_SCRATCH_VM_PROP));
+
+        // One volume per disk, sizes rounded up to KiB.
+        ArgumentCaptor<VolumeDefinitionCreate> vdCreate = 
ArgumentCaptor.forClass(VolumeDefinitionCreate.class);
+        verify(api, times(2)).volumeDefinitionCreate(eq(scratch.first()), 
vdCreate.capture());
+        Assert.assertEquals(Integer.valueOf(0), 
vdCreate.getAllValues().get(0).getVolumeDefinition().getVolumeNumber());
+        Assert.assertEquals(Long.valueOf(2L), 
vdCreate.getAllValues().get(0).getVolumeDefinition().getSizeKib());
+        Assert.assertEquals(Integer.valueOf(1), 
vdCreate.getAllValues().get(1).getVolumeDefinition().getVolumeNumber());
+        Assert.assertEquals(Long.valueOf(4L), 
vdCreate.getAllValues().get(1).getVolumeDefinition().getSizeKib());
+
+        ArgumentCaptor<AutoPlaceRequest> placement = 
ArgumentCaptor.forClass(AutoPlaceRequest.class);
+        verify(api).resourceAutoplace(eq(scratch.first()), 
placement.capture());
+        Assert.assertEquals(Collections.singletonList(localNode), 
placement.getValue().getSelectFilter().getNodeNameList());
+        Assert.assertEquals(Collections.singletonList(LayerType.STORAGE), 
placement.getValue().getLayerList());
+        Assert.assertEquals(Integer.valueOf(1), 
placement.getValue().getSelectFilter().getPlaceCount());
+        verify(api, never()).resourceMakeAvailableOnNode(anyString(), 
anyString(), any());
+    }
+
+    @Test
+    public void 
createBackupScratchAttachesRemoteReplicaDisklessWithoutLocalStoragePool() 
throws ApiException {
+        mockDefinitionsCreated();
+        when(api.resourceAutoplace(anyString(), any()))
+                .thenThrow(httpError("Not enough available nodes"))
+                .thenReturn(success());
+        when(api.resourceMakeAvailableOnNode(anyString(), eq(localNode), 
any())).thenReturn(success());
+        mockLocalResource(true, volume(0, "/dev/drbd1000"), volume(1, 
"/dev/drbd1001"));
+
+        Pair<String, List<String>> scratch = adaptor.createBackupScratch(pool, 
"i-2-3-VM", Arrays.asList(1024L, 1024L));
+
+        String rscName = scratch.first();
+        Assert.assertEquals(Arrays.asList("/dev/drbd/by-res/" + rscName + 
"/0", "/dev/drbd/by-res/" + rscName + "/1"),
+                scratch.second());
+
+        ArgumentCaptor<AutoPlaceRequest> placement = 
ArgumentCaptor.forClass(AutoPlaceRequest.class);
+        verify(api, times(2)).resourceAutoplace(eq(rscName), 
placement.capture());
+        AutoPlaceRequest remote = placement.getAllValues().get(1);
+        Assert.assertEquals(Arrays.asList(LayerType.DRBD, LayerType.STORAGE), 
remote.getLayerList());
+        
Assert.assertTrue(remote.getSelectFilter().getNodeNameList().isEmpty());
+        Assert.assertEquals(Integer.valueOf(1), 
remote.getSelectFilter().getPlaceCount());
+
+        ArgumentCaptor<ResourceMakeAvailable> available = 
ArgumentCaptor.forClass(ResourceMakeAvailable.class);
+        verify(api).resourceMakeAvailableOnNode(eq(rscName), eq(localNode), 
available.capture());
+        Assert.assertFalse(available.getValue().isDiskful());
+        verify(api, never()).resourceDefinitionDelete(anyString());
+    }
+
+    @Test
+    public void 
createBackupScratchDeletesResourceDefinitionWhenItCannotBePlaced() throws 
ApiException {
+        mockDefinitionsCreated();
+        when(api.resourceAutoplace(anyString(), any()))
+                .thenReturn(error("Not enough available nodes"))
+                .thenThrow(httpError("No storage pool with enough space"));
+        when(api.resourceDefinitionDelete(anyString())).thenReturn(success());
+
+        try {
+            adaptor.createBackupScratch(pool, "i-2-3-VM", 
Collections.singletonList(1024L));
+            Assert.fail("expected CloudRuntimeException");
+        } catch (CloudRuntimeException e) {
+            Assert.assertTrue(e.getMessage().contains("No storage pool with 
enough space"));
+        }
+
+        ArgumentCaptor<ResourceDefinitionCreate> rdCreate = 
ArgumentCaptor.forClass(ResourceDefinitionCreate.class);
+        verify(api).resourceDefinitionCreate(rdCreate.capture());
+        
verify(api).resourceDefinitionDelete(rdCreate.getValue().getResourceDefinition().getName());
+    }
+
+    @Test(expected = CloudRuntimeException.class)
+    public void deleteBackupScratchFailsWhenStillInUse() throws ApiException {
+        
when(api.resourceDefinitionDelete("cs-bkscratch-1")).thenReturn(error("Resource 
is still in use"));
+
+        adaptor.deleteBackupScratch(pool, "cs-bkscratch-1");
+    }
+
+    @Test
+    public void connectPhysicalDiskWaitsForTheLocalDevice() throws 
ApiException {
+        when(api.resourceMakeAvailableOnNode(eq("cs-vol-1"), eq(localNode), 
any())).thenReturn(success());
+        when(api.viewResources(Collections.singletonList(localNode), 
Collections.singletonList("cs-vol-1"),
+                Collections.emptyList(), null, null, 
null)).thenReturn(Collections.singletonList(drbdResource()));
+
+        Assert.assertTrue(adaptor.connectPhysicalDisk("vol-1", pool, null, 
false));
+
+        InOrder inOrder = Mockito.inOrder(api, adaptor);
+        inOrder.verify(api).resourceMakeAvailableOnNode(eq("cs-vol-1"), 
eq(localNode), any());
+        inOrder.verify(adaptor).waitForDevice(DEVICE);
+    }
+
+    @Test
+    public void createPhysicalDiskWaitsForTheLocalDevice() throws ApiException 
{
+        when(api.resourceDefinitionList(null, false, null, null, 
null)).thenReturn(Collections.emptyList());
+        when(api.resourceGroupSpawn(eq("cloudstack"), 
any())).thenReturn(success());
+        when(api.viewResources(any(), 
eq(Collections.singletonList("cs-vol-1")), any(), any(), any(), any()))
+                .thenReturn(Collections.singletonList(drbdResource()));
+        when(api.resourceMakeAvailableOnNode(eq("cs-vol-1"), eq(localNode), 
any())).thenReturn(success());
+
+        KVMPhysicalDisk disk = adaptor.createPhysicalDisk("vol-1", pool, 
QemuImg.PhysicalDiskFormat.RAW,
+                Storage.ProvisioningType.THIN, 1024L * 1024, null);
+
+        Assert.assertEquals(DEVICE, disk.getPath());
+        InOrder inOrder = Mockito.inOrder(api, adaptor);
+        inOrder.verify(api).resourceMakeAvailableOnNode(eq("cs-vol-1"), 
eq(localNode), any());
+        inOrder.verify(adaptor).waitForDevice(DEVICE);
+    }
+
+    @Test(expected = CloudRuntimeException.class)
+    public void connectPhysicalDiskFailsWithoutLocalVolume() throws 
ApiException {
+        when(api.resourceMakeAvailableOnNode(eq("cs-vol-1"), eq(localNode), 
any())).thenReturn(success());
+        when(api.viewResources(Collections.singletonList(localNode), 
Collections.singletonList("cs-vol-1"),
+                Collections.emptyList(), null, null, 
null)).thenReturn(Collections.emptyList());
+
+        adaptor.connectPhysicalDisk("vol-1", pool, null, false);
+    }
+
+    @Test
+    public void waitForDeviceReturnsForAnExistingDevice() {
+        new LinstorStorageAdaptor().waitForDevice("/dev/null");
+    }
+
+    private static ResourceDefinition scratchRd(String name, String vmName) {
+        ResourceDefinition rd = new ResourceDefinition();
+        rd.setName(name);
+        Properties props = new Properties();
+        props.put(LinstorStorageAdaptor.BACKUP_SCRATCH_VM_PROP, vmName);
+        rd.setProps(props);
+        return rd;
+    }
+
+    private static ResourceWithVolumes resourceOf(String name) {
+        ResourceWithVolumes rsc = new ResourceWithVolumes();
+        rsc.setName(name);
+        return rsc;
+    }
+
+    // The controllers this agent has swept are remembered per process, so 
every test uses its own one.
+    private KVMStoragePool registerPool(String controller) {
+        return adaptor.createStoragePool("pool-" + controller, controller, 
3370, "cloudstack", null,
+                Storage.StoragePoolType.Linstor, null, true);
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    public void createStoragePoolDeletesLeftoverScratchOfThisNode() throws 
ApiException {
+        ResourceDefinition volume = new ResourceDefinition();
+        volume.setName("cs-vol-1");
+        when(api.resourceDefinitionList(null, false, null, null, 
null)).thenReturn(Arrays.asList(
+                scratchRd("cs-bkscratch-here", "i-2-3-VM"), 
scratchRd("cs-bkscratch-elsewhere", "i-2-4-VM"), volume));
+        ArgumentCaptor<List<String>> rscNames = 
ArgumentCaptor.forClass(List.class);
+        when(api.viewResources(eq(Collections.singletonList(localNode)), 
rscNames.capture(), any(), any(), any(), any()))
+                
.thenReturn(Collections.singletonList(resourceOf("cs-bkscratch-here")));
+        
when(api.resourceDefinitionDelete("cs-bkscratch-here")).thenReturn(success());
+
+        Assert.assertNotNull(registerPool("http://sweep-1:3370";));
+
+        Assert.assertEquals(2, rscNames.getValue().size());
+        Assert.assertFalse(rscNames.getValue().contains("cs-vol-1"));
+        verify(api).resourceDefinitionDelete("cs-bkscratch-here");
+        verify(api, 
never()).resourceDefinitionDelete("cs-bkscratch-elsewhere");
+    }
+
+    @Test
+    public void createStoragePoolSweepsEachControllerOnlyOnce() throws 
ApiException {
+        when(api.resourceDefinitionList(null, false, null, null, 
null)).thenReturn(Collections.emptyList());
+
+        registerPool("http://sweep-2:3370";);
+        registerPool("http://sweep-2:3370";);
+
+        verify(api, times(1)).resourceDefinitionList(null, false, null, null, 
null);
+    }
+
+    @Test
+    public void createStoragePoolRegistersPoolWhenControllerIsUnreachable() 
throws ApiException {
+        when(api.resourceDefinitionList(null, false, null, null, 
null)).thenThrow(new ApiException("connection refused"));
+
+        KVMStoragePool registered = registerPool("http://sweep-3:3370";);
+
+        Assert.assertSame(registered, 
adaptor.getStoragePool("pool-http://sweep-3:3370";));
+        verify(api, never()).resourceDefinitionDelete(anyString());
+    }
+}
diff --git a/scripts/vm/hypervisor/kvm/nasbackup.sh 
b/scripts/vm/hypervisor/kvm/nasbackup.sh
index 04cc7cd86f2..8999ede1c01 100755
--- a/scripts/vm/hypervisor/kvm/nasbackup.sh
+++ b/scripts/vm/hypervisor/kvm/nasbackup.sh
@@ -35,6 +35,8 @@ QUIESCE=""
 QUIESCE_TIMEOUT=""    # Seconds to wait for the guest agent freeze/thaw; empty 
=> libvirt default
 # Incremental backup parameters (all optional; legacy callers omit them)
 MODE=""               # "full" or "incremental"; empty => legacy full-only 
behavior (no checkpoint created)
+METHOD="qcow2"        # How the backup is taken: "qcow2" (push mode, 
incrementals from dirty bitmaps) or
+                      # "content" (pull mode, incrementals by content 
comparison; raw block storage)
 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,
@@ -42,10 +44,15 @@ PARENT_PATHS=""       # For incremental: comma-separated 
list of parent backup f
                       # 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.
+SCRATCH_DEVS=""       # Pull mode: comma-separated list of copy-before-write 
scratch block devices, one
+                      # per VM volume in the same order as DISK_PATHS, created 
by the agent on the
+                      # VM's primary storage and at least as large as the disk 
plus qcow2 metadata.
+PULL_WORK_PARENT="/var/tmp"   # Parent dir for the pull-mode work dir (NBD 
socket + backup XML)
 logFile="/var/log/cloudstack/agent/agent.log"
 
 EXIT_CLEANUP_FAILED=20
 BACKUP_JOB_ACTIVE=0
+PULL_WORK_DIR=""      # Work dir of a running pull-mode backup, removed by 
on_pull_backup_exit()
 
 log() {
   [[ "$verb" -eq 1 ]] && builtin echo "$@"
@@ -183,6 +190,236 @@ thaw_guest() {
   return 1
 }
 
+# ---------------------------------------------------------------------------
+# Content-based backup path (libvirt pull mode).
+#
+# Used for raw block-device disks such as LINSTOR/DRBD. Those cannot carry QEMU
+# persistent dirty bitmaps -- persistence is a qcow2-only feature -- so libvirt
+# checkpoints, and with them the push-mode incremental path, are unavailable.
+#
+# Instead libvirt is asked for a pull-mode backup: it starts an NBD server 
exposing a
+# point-in-time view of each disk, held consistent by copy-before-write into a 
scratch
+# block device per disk, which the agent creates on the VM's primary storage 
and libvirt
+# formats as qcow2. The backup is then derived from that export:
+#
+#   full         qemu-img convert -> sparse standalone qcow2. Zero detection 
happens
+#                while streaming, so the fully allocated push-mode output and 
its
+#                separate re-convert pass are both avoided.
+#   incremental  a qcow2 overlay backed by the NBD export, then a SAFE rebase 
onto the
+#                parent backup. That merges exactly the clusters which differ 
between
+#                export and parent into the overlay, yielding a delta qcow2 
backed by the
+#                parent -- the same chain shape the push-mode incremental 
produces, so
+#                restore/delete are unchanged.
+#
+# Unlike a storage-snapshot based diff there is no trailing DRBD metadata to 
clip: the
+# DRBD device, and therefore the NBD export, is already net-sized.
+#
+# Assumes mount_operation() already ran, so $dest and $mount_point are set.
+# ---------------------------------------------------------------------------
+
+# EXIT trap of the pull-mode path, armed once the work dir exists and cleared 
once the
+# backup is complete. Any exit in between -- an explicit failure, an 
unexpected error under
+# "set -e", or SIGINT/SIGTERM/SIGHUP -- has to end the pull job: unlike a push 
job it never
+# finishes on its own, so it would keep the NBD server and the scratch devices 
busy and
+# block every later backup or migration of the VM. cleanup() aborts the job 
(via
+# BACKUP_JOB_ACTIVE) before removing the partial backup. SIGKILL cannot be 
trapped, so the
+# agent wrapper aborts the job itself when the script times out.
+on_pull_backup_exit() {
+  local rc=$?
+  trap - EXIT INT TERM HUP
+  [[ $rc -ne 0 ]] || rc=1
+  # The NBD socket belongs to the job: only remove it once the job is gone.
+  if abort_backup_job; then
+    rm -rf "$PULL_WORK_DIR"
+  fi
+  cleanup
+  exit $rc
+}
+
+backup_running_vm_pull() {
+  local effective_mode="$1"
+
+  # Work dir for the NBD socket and the backup XML. 711 lets the qemu process 
traverse in
+  # to the socket.
+  local work_dir
+  work_dir=$(mktemp -d "$PULL_WORK_PARENT/csbackup-pull.XXXXXX") || {
+    echo "Failed to create work directory under $PULL_WORK_PARENT"; cleanup; 
exit 1;
+  }
+  chmod 711 "$work_dir"
+  PULL_WORK_DIR="$work_dir"
+  trap on_pull_backup_exit EXIT
+  trap 'exit 129' HUP
+  trap 'exit 130' INT
+  trap 'exit 143' TERM
+  local nbd_sock="$work_dir/nbd.sock"
+  local backupxml="$work_dir/backup.xml"
+
+  # Snapshot the disk list once so the backup XML and the read-back loop stay 
in sync.
+  local -a disks
+  mapfile -t disks < <(
+    virsh -c qemu:///system domblklist "$VM" --details 2>/dev/null | awk 
'$2=="disk"{print $3, $4}'
+  )
+  if [[ ${#disks[@]} -eq 0 ]]; then
+    echo "No disks found for vm $VM"
+    exit 1
+  fi
+
+  # Copy-before-write saves each guest block overwritten during the backup 
into the disk's
+  # scratch device, at most once per block. The agent sizes each device for 
the whole disk,
+  # so it cannot fill up and pause the VM.
+  local -a scratch_devs=()
+  IFS=',' read -ra scratch_devs <<< "$SCRATCH_DEVS"
+  if [[ ${#scratch_devs[@]} -ne ${#disks[@]} ]]; then
+    echo "Pull-mode backup of vm $VM needs one scratch device per disk:" \
+      "got ${#scratch_devs[@]} for ${#disks[@]} disks"
+    exit 1
+  fi
+  local scratch_dev
+  for scratch_dev in "${scratch_devs[@]}"; do
+    if [[ ! -b "$scratch_dev" ]]; then
+      echo "Scratch device $scratch_dev for the backup of vm $VM is not a 
block device"
+      exit 1
+    fi
+  done
+
+  # Resolve each disk's parent backup up front. PARENT_PATHS is comma 
separated, one entry
+  # per volume in the same order as the disk list. Anything missing degrades 
this run to a
+  # full backup rather than failing it, mirroring the push-mode fallback.
+  local -a parent_paths_arr=()
+  if [[ "$effective_mode" == "incremental" ]]; then
+    if [[ -z "$PARENT_PATHS" ]]; then
+      log -e "content incremental: no parent paths supplied -- falling back to 
full"
+      echo "INCREMENTAL_FALLBACK=true"
+      effective_mode="full"
+    else
+      IFS=',' read -ra parent_paths_arr <<< "$PARENT_PATHS"
+      if [[ ${#parent_paths_arr[@]} -lt ${#disks[@]} ]]; then
+        log -e "content incremental: parent path list shorter than disk list 
-- falling back to full"
+        echo "INCREMENTAL_FALLBACK=true"
+        effective_mode="full"
+      else
+        local pp
+        for pp in "${parent_paths_arr[@]}"; do
+          if [[ ! -f "$mount_point/$pp" ]]; then
+            log -e "content incremental: parent $mount_point/$pp missing on 
NAS -- falling back to full"
+            echo "INCREMENTAL_FALLBACK=true"
+            effective_mode="full"
+            break
+          fi
+        done
+      fi
+    fi
+  fi
+
+  # Pull-mode backup XML: one NBD export and one scratch device per disk.
+  {
+    echo "<domainbackup mode='pull'>"
+    echo "<server transport='unix' socket='$nbd_sock'/>"
+    echo "<disks>"
+    local entry idx=0
+    for entry in "${disks[@]}"; do
+      echo "<disk name='${entry%% *}' backup='yes' type='block' 
backupmode='full'><scratch dev='${scratch_devs[$idx]}'/></disk>"
+      idx=$((idx + 1))
+    done
+    echo "</disks></domainbackup>"
+  } > "$backupxml"
+
+  local thaw=0
+  local freeze_ok=0
+  if [[ ${QUIESCE} == "true" ]]; then
+    # Always thaw after a freeze attempt, see backup_running_vm().
+    thaw=1
+    if freeze_err=$(qemu_agent_command '{"execute":"guest-fsfreeze-freeze"}' 
2>&1 > /dev/null); then
+      freeze_ok=1
+    else
+      log -e "Failed to freeze the filesystem for vm $VM, continuing without 
quiescing: $freeze_err"
+    fi
+  fi
+
+  # backup-begin establishes the point-in-time and starts the NBD server.
+  local backup_begin=0
+  local backup_out
+  if backup_out=$(virsh -c qemu:///system backup-begin --domain "$VM" 
--backupxml "$backupxml" 2>&1); then
+    backup_begin=1
+    BACKUP_JOB_ACTIVE=1
+  fi
+
+  if [[ $thaw -eq 1 ]] && ! thaw_guest "$freeze_ok"; then
+    exit 1
+  fi
+
+  if [[ $backup_begin -ne 1 ]]; then
+    echo "Failed to start pull-mode backup for $VM: $backup_out"
+    exit 1
+  fi
+
+  # Backup domain information
+  virsh -c qemu:///system dumpxml $VM > $dest/domain-config.xml 2>/dev/null
+  virsh -c qemu:///system dominfo $VM > $dest/dominfo.xml 2>/dev/null
+  virsh -c qemu:///system domiflist $VM > $dest/domiflist.xml 2>/dev/null
+  virsh -c qemu:///system domblklist $VM > $dest/domblklist.xml 2>/dev/null
+
+  local name="root"
+  local disk_idx=0
+  local entry disk fullpath volUuid output export_uri parent_abs parent_rel
+  for entry in "${disks[@]}"; do
+    disk="${entry%% *}"
+    fullpath="${entry#* }"
+    if [[ "$fullpath" == /dev/drbd/by-res/* ]]; then
+      volUuid=$(get_linstor_uuid_from_path "$fullpath")
+    elif [[ "$fullpath" == /dev/drbd[0-9]* ]]; then
+      if ! volUuid=$(get_linstor_uuid_from_device "$fullpath"); then
+        echo "Failed to resolve LINSTOR volume UUID for $fullpath"
+        exit 1
+      fi
+    else
+      volUuid="${fullpath##*/}"
+    fi
+    output="$dest/$name.$volUuid.qcow2"
+    # The export name defaults to the disk target (e.g. vda) and presents raw 
bytes.
+    export_uri="nbd+unix:///$disk?socket=$nbd_sock"
+
+    if [[ "$effective_mode" == "incremental" ]]; then
+      parent_abs="$mount_point/${parent_paths_arr[$disk_idx]}"
+      parent_rel=$(realpath --relative-to="$dest" "$parent_abs")
+      # Overlay over the export, safe-rebase onto the parent to merge the 
differing
+      # clusters in, then a metadata-only rebase to store the parent as a 
relative path
+      # so the chain survives being mounted at a different mount point.
+      if ! qemu-img create -f qcow2 -b "$export_uri" -F raw "$output" >> 
"$logFile" 2> >(cat >&2) \
+         || ! qemu-img rebase -b "$parent_abs" -F qcow2 "$output" >> 
"$logFile" 2> >(cat >&2) \
+         || ! qemu-img rebase -u -b "$parent_rel" -F qcow2 "$output" >> 
"$logFile" 2> >(cat >&2); then
+        echo "qemu-img incremental delta failed for $disk -> $output"
+        exit 1
+      fi
+    else
+      # Zero detection while streaming gives a sparse qcow2 in a single pass.
+      if ! qemu-img convert -f raw -O qcow2 "$export_uri" "$output" >> 
"$logFile" 2> >(cat >&2); then
+        echo "qemu-img convert failed for pull export $disk -> $output"
+        exit 1
+      fi
+    fi
+    name="datadisk"
+    disk_idx=$((disk_idx + 1))
+  done
+
+  # End the pull job. Aborting the active job is how a pull backup finishes 
(there is no
+  # backup-end command): libvirt tears down the NBD server and releases the 
scratch devices,
+  # which the agent then deletes. The data already pulled is a complete 
backup, so from here
+  # on a failure must no longer discard it.
+  trap - EXIT INT TERM HUP
+  if ! abort_backup_job; then
+    log -e "warning: failed to end (domjobabort) backup job for $VM"
+  fi
+  rm -rf "$work_dir"
+  sync
+
+  # Statistics -- the size on the last stdout line is parsed by the Java 
wrapper.
+  du -sb $dest | cut -f1
+
+  umount $mount_point
+  rmdir $mount_point
+}
+
 backup_running_vm() {
   mount_operation
   mkdir -p "$dest" || { echo "Failed to create backup directory $dest"; exit 
1; }
@@ -193,6 +430,30 @@ backup_running_vm() {
   # invoking the script; the case below is a defensive fallback for direct 
invocations.
   local effective_mode="${MODE:-legacy-full}"
   local make_checkpoint=0
+  case "$METHOD" in
+    qcow2)
+      ;;
+    content)
+      # Raw block-device storage (LINSTOR/DRBD) cannot carry persistent dirty 
bitmaps,
+      # so checkpoints are unavailable. Use the content-based pull-mode path 
instead.
+      case "$effective_mode" in
+        full|incremental)
+          backup_running_vm_pull "$effective_mode"
+          return
+          ;;
+        *)
+          echo "Mode $effective_mode is not supported by the content method"
+          cleanup
+          exit 1
+          ;;
+      esac
+      ;;
+    *)
+      echo "Unknown method: $METHOD"
+      cleanup
+      exit 1
+      ;;
+  esac
   case "$effective_mode" in
     incremental|full)
       make_checkpoint=1
@@ -513,9 +774,10 @@ backup_stopped_vm() {
       volUuid="${disk##*/}"
     fi
     output="$dest/$name.$volUuid.qcow2"
-    if ! qemu-img convert -O qcow2 "$disk" "$output" > "$logFile" 2> >(cat 
>&2); then
+    if ! qemu-img convert -O qcow2 "$disk" "$output" >> "$logFile" 2> >(cat 
>&2); then
       echo "qemu-img convert failed for $disk $output"
       cleanup
+      exit 1
     fi
 
     # Pre-seed a persistent bitmap on the source disk so the NEXT backup (taken
@@ -616,7 +878,7 @@ 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 "         [--quiesce-timeout <seconds>]"
-  echo "         [-M|--mode <full|incremental>] [--bitmap-new <name>] 
[--bitmap-parent <name>] [--parent-paths <p1,p2,...>]"
+  echo "         [-M|--mode <full|incremental>] [--method <qcow2|content>] 
[--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."
@@ -624,6 +886,15 @@ function usage {
   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 "  --method qcow2                Default. Push-mode backup; 
incrementals come from QEMU dirty bitmaps."
+  echo "  --method content              Libvirt pull-mode backup, for raw 
block-device storage (LINSTOR/DRBD) which"
+  echo "                                cannot carry the persistent dirty 
bitmaps checkpoints need. A full is written"
+  echo "                                as a sparse qcow2; an incremental is a 
qcow2 overlay on the NBD export,"
+  echo "                                safe-rebased onto the parent, keeping 
only the clusters that differ."
+  echo "                                Takes no bitmap names; an incremental 
requires --parent-paths."
+  echo "  --scratch-devs <d1,d2,...>    Copy-before-write scratch block 
devices for --method content on a running"
+  echo "                                VM, one per disk in the same order as 
-d|--disks, each large enough for"
+  echo "                                the whole disk plus qcow2 metadata. 
libvirt formats them."
   echo "  Without -M, behaves as legacy full-only backup with no checkpoint 
creation."
   echo ""
   exit 1
@@ -681,6 +952,11 @@ while [[ $# -gt 0 ]]; do
       shift
       shift
       ;;
+    --method)
+      METHOD="$2"
+      shift
+      shift
+      ;;
     --bitmap-new)
       BITMAP_NEW="$2"
       shift
@@ -696,6 +972,11 @@ while [[ $# -gt 0 ]]; do
       shift
       shift
       ;;
+    --scratch-devs)
+      SCRATCH_DEVS="$2"
+      shift
+      shift
+      ;;
     -h|--help)
       usage
       shift

Reply via email to