Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ public interface SnapshotDataStoreDao extends GenericDao<SnapshotDataStoreVO, Lo

SnapshotDataStoreVO findParent(DataStoreRole role, Long storeId, Long zoneId, Long volumeId, boolean kvmIncrementalSnapshot, Hypervisor.HypervisorType hypervisorType);

/**
* Whether snapshots of this volume are chained on secondary storage through a content diff against
* the parent snapshot file (Linstor) instead of qemu checkpoints. Such chains live purely on the
* image store, so checkpoint-oriented parent handling must not be applied to them.
*/
boolean usesContentBasedChain(long volumeId);

SnapshotDataStoreVO findBySnapshotIdAndDataStoreRoleAndState(long snapshotId, DataStoreRole role, ObjectInDataStoreStateMachine.State state);

List<SnapshotDataStoreVO> listBySnapshotIdAndDataStoreRoleAndStateIn(long snapshotId, DataStoreRole role, ObjectInDataStoreStateMachine.State... state);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,11 @@
import com.cloud.hypervisor.Hypervisor;
import com.cloud.storage.DataStoreRole;
import com.cloud.storage.SnapshotVO;
import com.cloud.storage.Storage;
import com.cloud.storage.VMTemplateStorageResourceAssoc;
import com.cloud.storage.VolumeVO;
import com.cloud.storage.dao.SnapshotDao;
import com.cloud.storage.dao.VolumeDao;
import com.cloud.utils.db.DB;
import com.cloud.utils.db.Filter;
import com.cloud.utils.db.GenericDaoBase;
Expand Down Expand Up @@ -88,6 +91,12 @@ public class SnapshotDataStoreDaoImpl extends GenericDaoBase<SnapshotDataStoreVO
@Inject
protected ImageStoreDao imageStoreDao;

@Inject
protected VolumeDao volumeDao;

@Inject
protected PrimaryDataStoreDao storagePoolDao;

private static final String FIND_OLDEST_OR_LATEST_SNAPSHOT = "select store_id, store_role, snapshot_id from cloud.snapshot_store_ref where " +
" store_role = ? and volume_id = ? and state = 'Ready'" +
" order by created %s " +
Expand Down Expand Up @@ -352,8 +361,15 @@ public SnapshotDataStoreVO findParent(DataStoreRole role, Long storeId, Long zon
return null;
}

boolean contentBasedChain = kvmIncrementalSnapshot && Hypervisor.HypervisorType.KVM.equals(hypervisorType) && usesContentBasedChain(volumeId);
if (contentBasedChain && (role == null || !role.isImageStore())) {
logger.trace("Content-based snapshot chains only exist on the image store. Returning null as parent for volume [{}] and role [{}].", volumeId, role);
return null;
}
boolean checkpointBasedChain = kvmIncrementalSnapshot && Hypervisor.HypervisorType.KVM.equals(hypervisorType) && !contentBasedChain;
Comment on lines +364 to +369

SearchCriteria<SnapshotDataStoreVO> sc;
if (kvmIncrementalSnapshot && Hypervisor.HypervisorType.KVM.equals(hypervisorType)) {
if (checkpointBasedChain) {
sc = searchFilteringStoreIdInVolumeIdEqStoreRoleEqStateEqKVMCheckpointNotNull.create();
} else {
sc = searchFilteringStoreIdInVolumeIdEqStoreRoleEqStateEq.create();
Expand All @@ -379,13 +395,29 @@ public SnapshotDataStoreVO findParent(DataStoreRole role, Long storeId, Long zon

SnapshotDataStoreVO parent = snapshotList.get(0);

if (kvmIncrementalSnapshot && parent.getKvmCheckpointPath() == null && Hypervisor.HypervisorType.KVM.equals(hypervisorType)) {
if (checkpointBasedChain && parent.getKvmCheckpointPath() == null) {
return null;
}

return parent;
}

/**
* Volumes on Linstor primary storage chain incremental snapshots on secondary storage through a
* content diff (qemu-img rebase) against the parent snapshot file instead of qemu checkpoints, so
* parent selection must not require a checkpoint path. Encrypted volumes are excluded as they are
* always backed up as full copies (a rebase would need the LUKS secret for delta and backing file).
*/
@Override
public boolean usesContentBasedChain(long volumeId) {
VolumeVO volume = volumeDao.findByIdIncludingRemoved(volumeId);
if (volume == null || volume.getPoolId() == null || volume.getPassphraseId() != null) {
return false;
}
StoragePoolVO pool = storagePoolDao.findById(volume.getPoolId());
return pool != null && Storage.StoragePoolType.Linstor.equals(pool.getPoolType());
}
Comment on lines +411 to +419

@Override
public SnapshotDataStoreVO findBySnapshotIdAndDataStoreRoleAndState(long snapshotId, DataStoreRole role, State state) {
SearchCriteria<SnapshotDataStoreVO> sc = createSearchCriteriaBySnapshotIdAndStoreRole(snapshotId, role);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,18 @@
import java.util.List;

import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.Spy;
import org.mockito.junit.MockitoJUnitRunner;
import org.mockito.stubbing.Answer;

import com.cloud.storage.Storage;
import com.cloud.storage.VolumeVO;
import com.cloud.storage.dao.VolumeDao;
import com.cloud.utils.db.SearchBuilder;
import com.cloud.utils.db.SearchCriteria;

Expand All @@ -36,6 +41,18 @@ public class SnapshotDataStoreDaoImplTest {
@Spy
SnapshotDataStoreDaoImpl snapshotDataStoreDaoImplSpy;

@Mock
VolumeDao volumeDaoMock;

@Mock
PrimaryDataStoreDao storagePoolDaoMock;

@Before
public void setUp() {
snapshotDataStoreDaoImplSpy.volumeDao = volumeDaoMock;
snapshotDataStoreDaoImplSpy.storagePoolDao = storagePoolDaoMock;
}

@Test
public void testExpungeByVmListNoVms() {
Assert.assertEquals(0, snapshotDataStoreDaoImplSpy.expungeBySnapshotList(
Expand Down Expand Up @@ -64,4 +81,48 @@ public void testExpungeByVmList() {
Mockito.verify(snapshotDataStoreDaoImplSpy, Mockito.times(1))
.batchExpunge(sc, batchSize);
}

private VolumeVO mockVolume(Long poolId, Long passphraseId) {
VolumeVO volume = Mockito.mock(VolumeVO.class);
Mockito.when(volume.getPoolId()).thenReturn(poolId);
Mockito.lenient().when(volume.getPassphraseId()).thenReturn(passphraseId);
Mockito.when(volumeDaoMock.findByIdIncludingRemoved(1L)).thenReturn(volume);
return volume;
}

@Test
public void testUsesContentBasedChainVolumeNotFound() {
Mockito.when(volumeDaoMock.findByIdIncludingRemoved(1L)).thenReturn(null);
Assert.assertFalse(snapshotDataStoreDaoImplSpy.usesContentBasedChain(1L));
}

@Test
public void testUsesContentBasedChainNoPool() {
mockVolume(null, null);
Assert.assertFalse(snapshotDataStoreDaoImplSpy.usesContentBasedChain(1L));
}

@Test
public void testUsesContentBasedChainEncryptedVolume() {
mockVolume(3L, 7L);
Assert.assertFalse(snapshotDataStoreDaoImplSpy.usesContentBasedChain(1L));
}

@Test
public void testUsesContentBasedChainLinstorPool() {
mockVolume(3L, null);
StoragePoolVO pool = Mockito.mock(StoragePoolVO.class);
Mockito.when(pool.getPoolType()).thenReturn(Storage.StoragePoolType.Linstor);
Mockito.when(storagePoolDaoMock.findById(3L)).thenReturn(pool);
Assert.assertTrue(snapshotDataStoreDaoImplSpy.usesContentBasedChain(1L));
}

@Test
public void testUsesContentBasedChainNonLinstorPool() {
mockVolume(3L, null);
StoragePoolVO pool = Mockito.mock(StoragePoolVO.class);
Mockito.when(pool.getPoolType()).thenReturn(Storage.StoragePoolType.NetworkFilesystem);
Mockito.when(storagePoolDaoMock.findById(3L)).thenReturn(pool);
Assert.assertFalse(snapshotDataStoreDaoImplSpy.usesContentBasedChain(1L));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,9 @@ public SnapshotInfo getParent() {
}

/**
* Returns the snapshotInfo of the passed snapshot parentId. Will search for the snapshot reference which has a checkpoint path. If none is found, throws an exception.
* Returns the snapshotInfo of the passed snapshot parentId. Will search for the snapshot reference which has a checkpoint path.
* If none is found, returns the plain parent on this snapshot's store: KVM snapshots may also be chained without checkpoints,
* e.g. Linstor chains deltas through a content diff against the parent snapshot file on secondary storage.
* */
protected SnapshotInfo getCorrectIncrementalParent(long parentId) {
List<SnapshotDataStoreVO> parentSnapshotDatastoreVos = snapshotStoreDao.findBySnapshotId(parentId);
Expand All @@ -141,8 +143,11 @@ protected SnapshotInfo getCorrectIncrementalParent(long parentId) {
logger.debug("Found parent snapshot references {}, will filter to just one.", parentSnapshotDatastoreVos);

SnapshotDataStoreVO parent = parentSnapshotDatastoreVos.stream().filter(snapshotDataStoreVO -> snapshotDataStoreVO.getKvmCheckpointPath() != null)
.findFirst().
orElseThrow(() -> new CloudRuntimeException(String.format("Could not find snapshot parent with id [%s]. None of the records have a checkpoint path.", parentId)));
.findFirst().orElse(null);

if (parent == null) {
return snapshotFactory.getSnapshot(parentId, store);
}

SnapshotInfo snapshotInfo = snapshotFactory.getSnapshot(parentId, parent.getDataStoreId(), parent.getRole());
snapshotInfo.setKvmIncrementalSnapshot(parent.getKvmCheckpointPath() != null);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package org.apache.cloudstack.storage.snapshot;

import java.util.Collections;
import java.util.List;

import org.apache.cloudstack.engine.subsystem.api.storage.DataStore;
import org.apache.cloudstack.engine.subsystem.api.storage.SnapshotDataFactory;
import org.apache.cloudstack.engine.subsystem.api.storage.SnapshotInfo;
import org.apache.cloudstack.storage.datastore.db.SnapshotDataStoreDao;
import org.apache.cloudstack.storage.datastore.db.SnapshotDataStoreVO;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.MockitoJUnitRunner;

import com.cloud.storage.DataStoreRole;
import com.cloud.storage.SnapshotVO;

@RunWith(MockitoJUnitRunner.class)
public class SnapshotObjectTest {

private static final long PARENT_SNAPSHOT_ID = 2L;

@Mock
SnapshotDataStoreDao snapshotStoreDao;

@Mock
SnapshotDataFactory snapshotFactory;

@Mock
DataStore store;

@Mock
SnapshotVO snapshotVO;

SnapshotObject snapshotObject;

@Before
public void setUp() {
snapshotObject = new SnapshotObject();
snapshotObject.configure(snapshotVO, store);
snapshotObject.snapshotStoreDao = snapshotStoreDao;
snapshotObject.snapshotFactory = snapshotFactory;
}

@Test
public void testGetCorrectIncrementalParentNoRefsReturnsNull() {
Mockito.when(snapshotStoreDao.findBySnapshotId(PARENT_SNAPSHOT_ID)).thenReturn(Collections.emptyList());

Assert.assertNull(snapshotObject.getCorrectIncrementalParent(PARENT_SNAPSHOT_ID));
}

@Test
public void testGetCorrectIncrementalParentPrefersCheckpointBearingRef() {
SnapshotDataStoreVO refWithoutCheckpoint = Mockito.mock(SnapshotDataStoreVO.class);
Mockito.when(refWithoutCheckpoint.getKvmCheckpointPath()).thenReturn(null);
SnapshotDataStoreVO refWithCheckpoint = Mockito.mock(SnapshotDataStoreVO.class);
Mockito.when(refWithCheckpoint.getKvmCheckpointPath()).thenReturn("checkpoints/2/5/uuid");
Mockito.when(refWithCheckpoint.getDataStoreId()).thenReturn(5L);
Mockito.when(refWithCheckpoint.getRole()).thenReturn(DataStoreRole.Image);
Mockito.when(snapshotStoreDao.findBySnapshotId(PARENT_SNAPSHOT_ID)).thenReturn(List.of(refWithoutCheckpoint, refWithCheckpoint));

SnapshotInfo parentInfo = Mockito.mock(SnapshotInfo.class);
Mockito.when(snapshotFactory.getSnapshot(PARENT_SNAPSHOT_ID, 5L, DataStoreRole.Image)).thenReturn(parentInfo);

Assert.assertEquals(parentInfo, snapshotObject.getCorrectIncrementalParent(PARENT_SNAPSHOT_ID));
Mockito.verify(parentInfo).setKvmIncrementalSnapshot(true);
}

@Test
public void testGetCorrectIncrementalParentFallsBackToPlainParentWithoutCheckpointRefs() {
SnapshotDataStoreVO refWithoutCheckpoint = Mockito.mock(SnapshotDataStoreVO.class);
Mockito.when(refWithoutCheckpoint.getKvmCheckpointPath()).thenReturn(null);
Mockito.when(snapshotStoreDao.findBySnapshotId(PARENT_SNAPSHOT_ID)).thenReturn(List.of(refWithoutCheckpoint));

SnapshotInfo parentInfo = Mockito.mock(SnapshotInfo.class);
Mockito.when(snapshotFactory.getSnapshot(PARENT_SNAPSHOT_ID, store)).thenReturn(parentInfo);

Assert.assertEquals(parentInfo, snapshotObject.getCorrectIncrementalParent(PARENT_SNAPSHOT_ID));
Mockito.verify(parentInfo, Mockito.never()).setKvmIncrementalSnapshot(Mockito.anyBoolean());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,10 @@ public DataObject create(DataObject obj, DataStore dataStore) {
private SnapshotDataStoreVO findParent(DataStore dataStore, Long clusterId, SnapshotInfo snapshotInfo) {
boolean kvmIncrementalSnapshot = SnapshotManager.kvmIncrementalSnapshot.valueIn(clusterId);
SnapshotDataStoreVO snapshotDataStoreVO;
if (Hypervisor.HypervisorType.KVM.equals(snapshotInfo.getHypervisorType()) && kvmIncrementalSnapshot) {
// content-based chains (Linstor) live purely on the image store; the cross-role checkpoint
// handling below (with its end-of-chain marking) must not be applied to their parents
if (Hypervisor.HypervisorType.KVM.equals(snapshotInfo.getHypervisorType()) && kvmIncrementalSnapshot
&& !snapshotDataStoreDao.usesContentBasedChain(snapshotInfo.getVolumeId())) {
snapshotDataStoreVO = snapshotDataStoreDao.findParent(null, null, null, snapshotInfo.getVolumeId(),
kvmIncrementalSnapshot, snapshotInfo.getHypervisorType());
snapshotDataStoreVO = returnNullIfNotOnSameTypeOfStoreRole(snapshotInfo, snapshotDataStoreVO);
Expand Down
6 changes: 6 additions & 0 deletions plugins/storage/volume/linstor/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ All notable changes to Linstor CloudStack plugin will be documented in this file
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2026-07-30]

### Added

- Support for incremental snapshots on secondary storage backuped snapshots

## [2026-06-24]

### Fixed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@

public class LinstorBackupSnapshotCommand extends CopyCommand
{
/**
* Option holding the secondary storage install path of the parent snapshot qcow2. When set (and
* fullSnapshot=false), the agent writes an incremental backup: a qcow2 containing only the blocks
* that differ from the parent, with the parent as its backing file.
*/
public static final String OPTION_PARENT_PATH = "parentPath";

public LinstorBackupSnapshotCommand(DataTO srcData, DataTO destData, int timeout, boolean executeInSequence)
{
super(srcData, destData, timeout, executeInSequence);
Expand Down
Loading
Loading