This is an automated email from the ASF dual-hosted git repository.
DaanHoogland pushed a commit to branch 4.20
in repository https://gitbox.apache.org/repos/asf/cloudstack.git
The following commit(s) were added to refs/heads/4.20 by this push:
new e742aadfc97 Reject creating a snapshot with a duplicate name for the
same volume (#14089)
e742aadfc97 is described below
commit e742aadfc97e84b65f4b24052f57f5ee0bd2c849
Author: The Kernel Nomad <[email protected]>
AuthorDate: Thu Oct 22 03:48:52 2026 -0400
Reject creating a snapshot with a duplicate name for the same volume
(#14089)
---
.../java/com/cloud/storage/dao/SnapshotDao.java | 2 +
.../com/cloud/storage/dao/SnapshotDaoImpl.java | 16 +++++
.../com/cloud/storage/dao/SnapshotDaoImplTest.java | 80 ++++++++++++++++++++++
.../storage/snapshot/SnapshotManagerImpl.java | 5 ++
.../storage/snapshot/SnapshotManagerImplTest.java | 78 +++++++++++++++++++++
5 files changed, 181 insertions(+)
diff --git a/engine/schema/src/main/java/com/cloud/storage/dao/SnapshotDao.java
b/engine/schema/src/main/java/com/cloud/storage/dao/SnapshotDao.java
index 737e4a5a53c..2659f41d562 100755
--- a/engine/schema/src/main/java/com/cloud/storage/dao/SnapshotDao.java
+++ b/engine/schema/src/main/java/com/cloud/storage/dao/SnapshotDao.java
@@ -53,6 +53,8 @@ public interface SnapshotDao extends GenericDao<SnapshotVO,
Long>, StateDao<Snap
List<SnapshotVO> listByStatusNotIn(long volumeId, Snapshot.State...
status);
+ SnapshotVO findByVolumeIdAndNameNotInStatus(long volumeId, String name,
Snapshot.State... status);
+
/**
* Retrieves a list of snapshots filtered by ids.
* @param ids Snapshot ids.
diff --git
a/engine/schema/src/main/java/com/cloud/storage/dao/SnapshotDaoImpl.java
b/engine/schema/src/main/java/com/cloud/storage/dao/SnapshotDaoImpl.java
index 238ae54e07f..0f6576e996a 100755
--- a/engine/schema/src/main/java/com/cloud/storage/dao/SnapshotDaoImpl.java
+++ b/engine/schema/src/main/java/com/cloud/storage/dao/SnapshotDaoImpl.java
@@ -65,6 +65,7 @@ public class SnapshotDaoImpl extends
GenericDaoBase<SnapshotVO, Long> implements
private SearchBuilder<SnapshotVO> InstanceIdSearch;
private SearchBuilder<SnapshotVO> StatusSearch;
private SearchBuilder<SnapshotVO> notInStatusSearch;
+ private SearchBuilder<SnapshotVO> volumeIdNameNotInStatusSearch;
private GenericSearchBuilder<SnapshotVO, Long> CountSnapshotsByAccount;
@Inject
ResourceTagDao _tagsDao;
@@ -159,6 +160,12 @@ public class SnapshotDaoImpl extends
GenericDaoBase<SnapshotVO, Long> implements
notInStatusSearch.and("status", notInStatusSearch.entity().getState(),
SearchCriteria.Op.NOTIN);
notInStatusSearch.done();
+ volumeIdNameNotInStatusSearch = createSearchBuilder();
+ volumeIdNameNotInStatusSearch.and("volumeId",
volumeIdNameNotInStatusSearch.entity().getVolumeId(), SearchCriteria.Op.EQ);
+ volumeIdNameNotInStatusSearch.and("name",
volumeIdNameNotInStatusSearch.entity().getName(), SearchCriteria.Op.EQ);
+ volumeIdNameNotInStatusSearch.and("status",
volumeIdNameNotInStatusSearch.entity().getState(), SearchCriteria.Op.NOTIN);
+ volumeIdNameNotInStatusSearch.done();
+
CountSnapshotsByAccount = createSearchBuilder(Long.class);
CountSnapshotsByAccount.select(null, Func.COUNT, null);
CountSnapshotsByAccount.and("account",
CountSnapshotsByAccount.entity().getAccountId(), SearchCriteria.Op.EQ);
@@ -295,6 +302,15 @@ public class SnapshotDaoImpl extends
GenericDaoBase<SnapshotVO, Long> implements
return listBy(sc, null);
}
+ @Override
+ public SnapshotVO findByVolumeIdAndNameNotInStatus(long volumeId, String
name, Snapshot.State... status) {
+ SearchCriteria<SnapshotVO> sc = volumeIdNameNotInStatusSearch.create();
+ sc.setParameters("volumeId", volumeId);
+ sc.setParameters("name", name);
+ sc.setParameters("status", (Object[]) status);
+ return findOneBy(sc);
+ }
+
@Override
public List<SnapshotVO> searchByVolumes(List<Long> volumeIds) {
if (CollectionUtils.isEmpty(volumeIds)) {
diff --git
a/engine/schema/src/test/java/com/cloud/storage/dao/SnapshotDaoImplTest.java
b/engine/schema/src/test/java/com/cloud/storage/dao/SnapshotDaoImplTest.java
new file mode 100644
index 00000000000..f4609bcc340
--- /dev/null
+++ b/engine/schema/src/test/java/com/cloud/storage/dao/SnapshotDaoImplTest.java
@@ -0,0 +1,80 @@
+// 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.storage.dao;
+
+import java.lang.reflect.Field;
+
+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 com.cloud.storage.Snapshot;
+import com.cloud.storage.SnapshotVO;
+import com.cloud.utils.db.SearchBuilder;
+import com.cloud.utils.db.SearchCriteria;
+
+@RunWith(MockitoJUnitRunner.class)
+public class SnapshotDaoImplTest {
+
+ @Mock
+ SearchBuilder<SnapshotVO> volumeIdNameNotInStatusSearchMock;
+
+ @Mock
+ SearchCriteria<SnapshotVO> searchCriteriaMock;
+
+ @Spy
+ SnapshotDaoImpl snapshotDao = new SnapshotDaoImpl();
+
+ @Before
+ public void setUp() throws Exception {
+ // init() wires up several search builders that join other DAOs, which
are not available in a
+ // unit test; inject only the search builder this method uses and stub
it to return a criteria.
+ Field searchField =
SnapshotDaoImpl.class.getDeclaredField("volumeIdNameNotInStatusSearch");
+ searchField.setAccessible(true);
+ searchField.set(snapshotDao, volumeIdNameNotInStatusSearchMock);
+
Mockito.lenient().doReturn(searchCriteriaMock).when(volumeIdNameNotInStatusSearchMock).create();
+ }
+
+ @Test
+ public void testFindByVolumeIdAndNameNotInStatusReturnsTheMatch() {
+ SnapshotVO expected = Mockito.mock(SnapshotVO.class);
+
Mockito.doReturn(expected).when(snapshotDao).findOneBy(Mockito.any(SearchCriteria.class));
+
+ SnapshotVO result = snapshotDao.findByVolumeIdAndNameNotInStatus(5L,
"snap-1", Snapshot.State.Destroyed);
+
+ Assert.assertSame(expected, result);
+ Mockito.verify(searchCriteriaMock).setParameters("volumeId", 5L);
+ Mockito.verify(searchCriteriaMock).setParameters("name", "snap-1");
+ Mockito.verify(snapshotDao).findOneBy(searchCriteriaMock);
+ }
+
+ @Test
+ public void testFindByVolumeIdAndNameNotInStatusReturnsNullWhenNoMatch() {
+
Mockito.doReturn(null).when(snapshotDao).findOneBy(Mockito.any(SearchCriteria.class));
+
+ SnapshotVO result = snapshotDao.findByVolumeIdAndNameNotInStatus(5L,
"snap-1");
+
+ Assert.assertNull(result);
+ Mockito.verify(searchCriteriaMock).setParameters("volumeId", 5L);
+ Mockito.verify(snapshotDao).findOneBy(searchCriteriaMock);
+ }
+}
diff --git
a/server/src/main/java/com/cloud/storage/snapshot/SnapshotManagerImpl.java
b/server/src/main/java/com/cloud/storage/snapshot/SnapshotManagerImpl.java
index 0d6e9de509f..ce3cee8480a 100755
--- a/server/src/main/java/com/cloud/storage/snapshot/SnapshotManagerImpl.java
+++ b/server/src/main/java/com/cloud/storage/snapshot/SnapshotManagerImpl.java
@@ -1718,9 +1718,14 @@ public class SnapshotManagerImpl extends
MutualExclusiveIdsManagerBase implement
if (vmInstance != null) {
vmDisplayName = vmInstance.getHostName();
}
+ boolean nameProvided = snapshotName != null;
if (snapshotName == null)
snapshotName = vmDisplayName + "_" + volume.getName() + "_" +
timeString;
+ if (nameProvided &&
_snapshotDao.findByVolumeIdAndNameNotInStatus(volumeId, snapshotName,
Snapshot.State.Destroyed, Snapshot.State.Error) != null) {
+ throw new InvalidParameterValueException(String.format("A snapshot
with name [%s] already exists for volume %s.", snapshotName, volume));
+ }
+
HypervisorType hypervisorType = HypervisorType.None;
StoragePoolVO storagePool =
_storagePoolDao.findById(volume.getDataStore().getId());
if (storagePool.getScope() == ScopeType.ZONE) {
diff --git
a/server/src/test/java/com/cloud/storage/snapshot/SnapshotManagerImplTest.java
b/server/src/test/java/com/cloud/storage/snapshot/SnapshotManagerImplTest.java
index 32b103f39d3..d99c67e2cf6 100644
---
a/server/src/test/java/com/cloud/storage/snapshot/SnapshotManagerImplTest.java
+++
b/server/src/test/java/com/cloud/storage/snapshot/SnapshotManagerImplTest.java
@@ -28,8 +28,13 @@ import
org.apache.cloudstack.engine.subsystem.api.storage.SnapshotInfo;
import org.apache.cloudstack.engine.subsystem.api.storage.SnapshotResult;
import org.apache.cloudstack.engine.subsystem.api.storage.SnapshotService;
import org.apache.cloudstack.framework.async.AsyncCallFuture;
+import org.apache.cloudstack.context.CallContext;
+import org.apache.cloudstack.engine.subsystem.api.storage.VolumeDataFactory;
+import org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo;
+import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao;
import org.apache.cloudstack.storage.datastore.db.SnapshotDataStoreDao;
import org.apache.cloudstack.storage.datastore.db.SnapshotDataStoreVO;
+import org.apache.cloudstack.storage.datastore.db.StoragePoolVO;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
@@ -45,10 +50,12 @@ import com.cloud.dc.DataCenterVO;
import com.cloud.dc.dao.DataCenterDao;
import com.cloud.event.ActionEventUtils;
import com.cloud.exception.InvalidParameterValueException;
+import com.cloud.hypervisor.Hypervisor.HypervisorType;
import com.cloud.exception.PermissionDeniedException;
import com.cloud.exception.ResourceUnavailableException;
import com.cloud.org.Grouping;
import com.cloud.storage.DataStoreRole;
+import com.cloud.storage.ScopeType;
import com.cloud.storage.Snapshot;
import com.cloud.storage.SnapshotVO;
import com.cloud.storage.VolumeVO;
@@ -59,8 +66,10 @@ import com.cloud.user.Account;
import com.cloud.user.AccountManager;
import com.cloud.user.AccountVO;
import com.cloud.user.ResourceLimitService;
+import com.cloud.user.User;
import com.cloud.user.dao.AccountDao;
import com.cloud.utils.Pair;
+import com.cloud.vm.dao.UserVmDao;
@RunWith(MockitoJUnitRunner.class)
public class SnapshotManagerImplTest {
@@ -86,9 +95,78 @@ public class SnapshotManagerImplTest {
SnapshotZoneDao snapshotZoneDao;
@Mock
VolumeDao volumeDao;
+ @Mock
+ PrimaryDataStoreDao primaryDataStoreDao;
+ @Mock
+ VolumeDataFactory volFactory;
+ @Mock
+ UserVmDao userVmDao;
@InjectMocks
SnapshotManagerImpl snapshotManager = new SnapshotManagerImpl();
+ @Test
+ public void testAllocSnapshotRejectsDuplicateNameForVolume() {
+ long volumeId = 1L;
+ CallContext.register(Mockito.mock(User.class),
Mockito.mock(Account.class));
+ try {
+ VolumeInfo volume = Mockito.mock(VolumeInfo.class);
+ Mockito.when(volFactory.getVolume(volumeId)).thenReturn(volume);
+ DataStore dataStore = Mockito.mock(DataStore.class);
+ Mockito.when(volume.getDataStore()).thenReturn(dataStore);
+ Mockito.when(dataStore.getId()).thenReturn(10L);
+ StoragePoolVO pool = Mockito.mock(StoragePoolVO.class);
+ Mockito.when(primaryDataStoreDao.findById(10L)).thenReturn(pool);
+ Mockito.when(pool.getScope()).thenReturn(ScopeType.ZONE);
+ Mockito.when(pool.getHypervisor()).thenReturn(HypervisorType.None);
+ Mockito.when(volume.getInstanceId()).thenReturn(null);
+ Mockito.when(volume.getAccountId()).thenReturn(2L);
+
+
Mockito.when(snapshotDao.findByVolumeIdAndNameNotInStatus(volumeId, "dup",
Snapshot.State.Destroyed, Snapshot.State.Error))
+ .thenReturn(Mockito.mock(SnapshotVO.class));
+
+ Assert.assertThrows(InvalidParameterValueException.class, () ->
+ snapshotManager.allocSnapshot(volumeId,
Snapshot.MANUAL_POLICY_ID, "dup", null));
+ } finally {
+ CallContext.unregister();
+ }
+ }
+
+ @Test
+ public void testAllocSnapshotDoesNotCheckDuplicateForAutoGeneratedName() {
+ long volumeId = 1L;
+ CallContext.register(Mockito.mock(User.class),
Mockito.mock(Account.class));
+ try {
+ VolumeInfo volume = Mockito.mock(VolumeInfo.class);
+ Mockito.when(volFactory.getVolume(volumeId)).thenReturn(volume);
+ DataStore dataStore = Mockito.mock(DataStore.class);
+ Mockito.when(volume.getDataStore()).thenReturn(dataStore);
+ Mockito.lenient().when(dataStore.getId()).thenReturn(10L);
+ StoragePoolVO pool = Mockito.mock(StoragePoolVO.class);
+
Mockito.lenient().when(primaryDataStoreDao.findById(10L)).thenReturn(pool);
+ Mockito.lenient().when(pool.getScope()).thenReturn(ScopeType.ZONE);
+
Mockito.lenient().when(pool.getHypervisor()).thenReturn(HypervisorType.None);
+ Mockito.when(volume.getInstanceId()).thenReturn(null);
+ Mockito.when(volume.getAccountId()).thenReturn(2L);
+
+ // A recurring snapshot carries no caller-supplied name, so the
auto-generated name must not be checked.
+
Mockito.lenient().when(snapshotDao.findByVolumeIdAndNameNotInStatus(Mockito.eq(volumeId),
Mockito.anyString(),
+ Mockito.eq(Snapshot.State.Destroyed),
Mockito.eq(Snapshot.State.Error)))
+ .thenReturn(Mockito.mock(SnapshotVO.class));
+
+ try {
+ snapshotManager.allocSnapshot(volumeId,
Snapshot.MANUAL_POLICY_ID, null, null);
+ } catch (Exception e) {
+ // The rest of allocSnapshot is not stubbed here; this test
only asserts the duplicate-name guard is
+ // not applied to an auto-generated name.
+ }
+
+ Mockito.verify(snapshotDao,
Mockito.never()).findByVolumeIdAndNameNotInStatus(Mockito.eq(volumeId),
+ Mockito.anyString(), Mockito.eq(Snapshot.State.Destroyed),
Mockito.eq(Snapshot.State.Error));
+ } finally {
+ CallContext.unregister();
+ }
+ }
+
@Test
public void testGetSnapshotZoneImageStoreValid() {
final long snapshotId = 1L;