From 9b22cd590d20145a44dcd7c40bb542ea4aa4bc0b Mon Sep 17 00:00:00 2001 From: Gabriel Pordeus Santos Date: Wed, 21 Aug 2024 04:38:56 -0300 Subject: [PATCH] Download Volume Snapshots (#8878) Co-authored-by: Rodrigo D. Lopez <19981369+RodrigoDLopez@users.noreply.github.com> --- .../main/java/com/cloud/event/EventTypes.java | 2 + .../main/java/com/cloud/storage/Upload.java | 2 +- .../storage/snapshot/SnapshotApiService.java | 11 ++ .../cloudstack/api/ResponseGenerator.java | 6 +- .../api/command/user/iso/ExtractIsoCmd.java | 2 +- .../user/snapshot/ExtractSnapshotCmd.java | 115 ++++++++++++++++ .../user/template/ExtractTemplateCmd.java | 3 +- .../command/user/volume/ExtractVolumeCmd.java | 16 +-- .../datastore/db/SnapshotDataStoreVO.java | 23 ++++ .../META-INF/db/schema-41910to42000.sql | 4 + .../java/com/cloud/api/ApiResponseHelper.java | 58 ++++---- .../java/com/cloud/configuration/Config.java | 2 +- .../cloud/server/ManagementServerImpl.java | 2 + .../storage/snapshot/SnapshotManagerImpl.java | 72 ++++++++++ .../cloud/template/TemplateManagerImpl.java | 7 +- .../storage/snapshot/SnapshotManagerTest.java | 130 ++++++++++++++++++ ui/public/locales/en.json | 2 + ui/public/locales/pt_BR.json | 2 + ui/src/config/section/storage.js | 20 +++ 19 files changed, 418 insertions(+), 61 deletions(-) create mode 100644 api/src/main/java/org/apache/cloudstack/api/command/user/snapshot/ExtractSnapshotCmd.java diff --git a/api/src/main/java/com/cloud/event/EventTypes.java b/api/src/main/java/com/cloud/event/EventTypes.java index d4235cbc9bc..71de3505e0d 100644 --- a/api/src/main/java/com/cloud/event/EventTypes.java +++ b/api/src/main/java/com/cloud/event/EventTypes.java @@ -333,6 +333,7 @@ public class EventTypes { public static final String EVENT_SNAPSHOT_OFF_PRIMARY = "SNAPSHOT.OFF_PRIMARY"; public static final String EVENT_SNAPSHOT_DELETE = "SNAPSHOT.DELETE"; public static final String EVENT_SNAPSHOT_REVERT = "SNAPSHOT.REVERT"; + public static final String EVENT_SNAPSHOT_EXTRACT = "SNAPSHOT.EXTRACT"; public static final String EVENT_SNAPSHOT_POLICY_CREATE = "SNAPSHOTPOLICY.CREATE"; public static final String EVENT_SNAPSHOT_POLICY_UPDATE = "SNAPSHOTPOLICY.UPDATE"; public static final String EVENT_SNAPSHOT_POLICY_DELETE = "SNAPSHOTPOLICY.DELETE"; @@ -897,6 +898,7 @@ public class EventTypes { // Snapshots entityEventDetails.put(EVENT_SNAPSHOT_CREATE, Snapshot.class); entityEventDetails.put(EVENT_SNAPSHOT_DELETE, Snapshot.class); + entityEventDetails.put(EVENT_SNAPSHOT_EXTRACT, Snapshot.class); entityEventDetails.put(EVENT_SNAPSHOT_ON_PRIMARY, Snapshot.class); entityEventDetails.put(EVENT_SNAPSHOT_OFF_PRIMARY, Snapshot.class); entityEventDetails.put(EVENT_SNAPSHOT_POLICY_CREATE, SnapshotPolicy.class); diff --git a/api/src/main/java/com/cloud/storage/Upload.java b/api/src/main/java/com/cloud/storage/Upload.java index 59d203ac73a..4e696e877cc 100644 --- a/api/src/main/java/com/cloud/storage/Upload.java +++ b/api/src/main/java/com/cloud/storage/Upload.java @@ -40,7 +40,7 @@ public interface Upload extends InternalIdentity, Identity { } public static enum Type { - VOLUME, TEMPLATE, ISO + VOLUME, SNAPSHOT, TEMPLATE, ISO } public static enum Mode { diff --git a/api/src/main/java/com/cloud/storage/snapshot/SnapshotApiService.java b/api/src/main/java/com/cloud/storage/snapshot/SnapshotApiService.java index 0893f337ce2..67afd6aa4e2 100644 --- a/api/src/main/java/com/cloud/storage/snapshot/SnapshotApiService.java +++ b/api/src/main/java/com/cloud/storage/snapshot/SnapshotApiService.java @@ -21,6 +21,7 @@ import java.util.List; import org.apache.cloudstack.api.command.user.snapshot.CopySnapshotCmd; import org.apache.cloudstack.api.command.user.snapshot.CreateSnapshotPolicyCmd; import org.apache.cloudstack.api.command.user.snapshot.DeleteSnapshotPoliciesCmd; +import org.apache.cloudstack.api.command.user.snapshot.ExtractSnapshotCmd; import org.apache.cloudstack.api.command.user.snapshot.ListSnapshotPoliciesCmd; import org.apache.cloudstack.api.command.user.snapshot.ListSnapshotsCmd; import org.apache.cloudstack.api.command.user.snapshot.UpdateSnapshotPolicyCmd; @@ -106,6 +107,16 @@ public interface SnapshotApiService { */ Snapshot createSnapshot(Long volumeId, Long policyId, Long snapshotId, Account snapshotOwner); + /** + * Extracts the snapshot to a particular location. + * + * @param cmd + * the command specifying url (where the snapshot needs to be extracted to), zoneId (zone where the snapshot exists) and + * id (the id of the snapshot) + * + */ + String extractSnapshot(ExtractSnapshotCmd cmd); + /** * Archives a snapshot from primary storage to secondary storage. * @param id Snapshot ID diff --git a/api/src/main/java/org/apache/cloudstack/api/ResponseGenerator.java b/api/src/main/java/org/apache/cloudstack/api/ResponseGenerator.java index ef759aaf9c3..a4d52384df3 100644 --- a/api/src/main/java/org/apache/cloudstack/api/ResponseGenerator.java +++ b/api/src/main/java/org/apache/cloudstack/api/ResponseGenerator.java @@ -345,9 +345,11 @@ public interface ResponseGenerator { SecurityGroupResponse createSecurityGroupResponse(SecurityGroup group); - ExtractResponse createExtractResponse(Long uploadId, Long id, Long zoneId, Long accountId, String mode, String url); + ExtractResponse createImageExtractResponse(Long id, Long zoneId, Long accountId, String mode, String url); - ExtractResponse createExtractResponse(Long id, Long zoneId, Long accountId, String mode, String url); + ExtractResponse createVolumeExtractResponse(Long id, Long zoneId, Long accountId, String mode, String url); + + ExtractResponse createSnapshotExtractResponse(Long id, Long zoneId, Long accountId, String url); String toSerializedString(CreateCmdResponse response, String responseType); diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/iso/ExtractIsoCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/iso/ExtractIsoCmd.java index 5db680066a6..7861c1e5d41 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/iso/ExtractIsoCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/iso/ExtractIsoCmd.java @@ -120,7 +120,7 @@ public class ExtractIsoCmd extends BaseAsyncCmd { CallContext.current().setEventDetails(getEventDescription()); String uploadUrl = _templateService.extract(this); if (uploadUrl != null) { - ExtractResponse response = _responseGenerator.createExtractResponse(id, zoneId, getEntityOwnerId(), mode, uploadUrl); + ExtractResponse response = _responseGenerator.createImageExtractResponse(id, zoneId, getEntityOwnerId(), mode, uploadUrl); response.setResponseName(getCommandName()); response.setObjectName("iso"); this.setResponseObject(response); diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/snapshot/ExtractSnapshotCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/snapshot/ExtractSnapshotCmd.java new file mode 100644 index 00000000000..3f0f82ea4e3 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/snapshot/ExtractSnapshotCmd.java @@ -0,0 +1,115 @@ +// 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.api.command.user.snapshot; + +import com.cloud.event.EventTypes; +import com.cloud.storage.Snapshot; +import com.cloud.user.Account; +import org.apache.cloudstack.acl.SecurityChecker.AccessType; +import org.apache.cloudstack.api.ACL; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.BaseAsyncCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.ExtractResponse; +import org.apache.cloudstack.api.response.SnapshotResponse; +import org.apache.cloudstack.api.response.ZoneResponse; +import org.apache.cloudstack.context.CallContext; + +@APICommand(name = "extractSnapshot", description = "Returns a download URL for extracting a snapshot. It must be in the Backed Up state.", since = "4.20.0", + responseObject = ExtractResponse.class, entityType = {Snapshot.class}, requestHasSensitiveInfo = false, responseHasSensitiveInfo = false) +public class ExtractSnapshotCmd extends BaseAsyncCmd { + + + ///////////////////////////////////////////////////// + //////////////// API parameters ///////////////////// + ///////////////////////////////////////////////////// + + @ACL(accessType = AccessType.OperateEntry) + @Parameter(name=ApiConstants.ID, type=CommandType.UUID, entityType=SnapshotResponse.class, required=true, since="4.20.0", description="the ID of the snapshot") + private Long id; + + @Parameter(name = ApiConstants.ZONE_ID, type = CommandType.UUID, entityType = ZoneResponse.class, required = true, since="4.20.0", + description = "the ID of the zone where the snapshot is located") + private Long zoneId; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + + public Long getId() { + return id; + } + + public Long getZoneId() { + return zoneId; + } + + ///////////////////////////////////////////////////// + /////////////// API Implementation/////////////////// + ///////////////////////////////////////////////////// + + @Override + public ApiCommandResourceType getApiResourceType() { + return ApiCommandResourceType.Snapshot; + } + + @Override + public Long getApiResourceId() { + return getId(); + } + + /** + * @return ID of the snapshot to extract, if any. Otherwise returns the ACCOUNT_ID_SYSTEM, so ERROR events will be traceable. + */ + @Override + public long getEntityOwnerId() { + Snapshot snapshot = _entityMgr.findById(Snapshot.class, getId()); + if (snapshot != null) { + return snapshot.getAccountId(); + } + + return Account.ACCOUNT_ID_SYSTEM; + } + + @Override + public String getEventType() { + return EventTypes.EVENT_SNAPSHOT_EXTRACT; + } + + @Override + public String getEventDescription() { + return "Snapshot extraction job"; + } + + @Override + public void execute() { + CallContext.current().setEventDetails("Snapshot ID: " + this._uuidMgr.getUuid(Snapshot.class, getId())); + String uploadUrl = _snapshotService.extractSnapshot(this); + logger.info("Extract URL [{}] of snapshot [{}].", uploadUrl, id); + if (uploadUrl != null) { + ExtractResponse response = _responseGenerator.createSnapshotExtractResponse(id, zoneId, getEntityOwnerId(), uploadUrl); + response.setResponseName(getCommandName()); + this.setResponseObject(response); + } else { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to extract snapshot"); + } + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/template/ExtractTemplateCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/template/ExtractTemplateCmd.java index ce6ba5e300c..0fa0679bfd9 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/template/ExtractTemplateCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/template/ExtractTemplateCmd.java @@ -120,8 +120,9 @@ public class ExtractTemplateCmd extends BaseAsyncCmd { CallContext.current().setEventDetails(getEventDescription()); String uploadUrl = _templateService.extract(this); if (uploadUrl != null) { - ExtractResponse response = _responseGenerator.createExtractResponse(id, zoneId, getEntityOwnerId(), mode, uploadUrl); + ExtractResponse response = _responseGenerator.createImageExtractResponse(id, zoneId, getEntityOwnerId(), mode, uploadUrl); response.setResponseName(getCommandName()); + response.setObjectName("template"); this.setResponseObject(response); } else { throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to extract template"); diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/volume/ExtractVolumeCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/volume/ExtractVolumeCmd.java index 1146f80f0e2..9445aba23c0 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/volume/ExtractVolumeCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/volume/ExtractVolumeCmd.java @@ -31,9 +31,7 @@ import org.apache.cloudstack.api.response.VolumeResponse; import org.apache.cloudstack.api.response.ZoneResponse; import org.apache.cloudstack.context.CallContext; -import com.cloud.dc.DataCenter; import com.cloud.event.EventTypes; -import com.cloud.storage.Upload; import com.cloud.storage.Volume; import com.cloud.user.Account; @@ -124,20 +122,8 @@ public class ExtractVolumeCmd extends BaseAsyncCmd { CallContext.current().setEventDetails("Volume Id: " + this._uuidMgr.getUuid(Volume.class, getId())); String uploadUrl = _volumeService.extractVolume(this); if (uploadUrl != null) { - ExtractResponse response = new ExtractResponse(); + ExtractResponse response = _responseGenerator.createVolumeExtractResponse(id, zoneId, getEntityOwnerId(), mode, uploadUrl); response.setResponseName(getCommandName()); - response.setObjectName("volume"); - Volume vol = _entityMgr.findById(Volume.class, id); - response.setId(vol.getUuid()); - response.setName(vol.getName()); - DataCenter zone = _entityMgr.findById(DataCenter.class, zoneId); - response.setZoneId(zone.getUuid()); - response.setZoneName(zone.getName()); - response.setMode(mode); - response.setState(Upload.Status.DOWNLOAD_URL_CREATED.toString()); - Account account = _entityMgr.findById(Account.class, getEntityOwnerId()); - response.setAccountId(account.getUuid()); - response.setUrl(uploadUrl); setResponseObject(response); } else { throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to extract volume"); diff --git a/engine/schema/src/main/java/org/apache/cloudstack/storage/datastore/db/SnapshotDataStoreVO.java b/engine/schema/src/main/java/org/apache/cloudstack/storage/datastore/db/SnapshotDataStoreVO.java index a1dc05fce58..7a466c1f505 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/storage/datastore/db/SnapshotDataStoreVO.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/storage/datastore/db/SnapshotDataStoreVO.java @@ -86,6 +86,13 @@ public class SnapshotDataStoreVO implements StateObject imageStores = dataStoreMgr.getImageStoresByScope(new ZoneScope(zoneId)); + + if (CollectionUtils.isEmpty(imageStores)) { + logger.error("Could not find any zone storages."); + throw new InvalidParameterValueException("Extraction could not be completed"); + } + + SnapshotDataStoreVO snapshotDataStoreReference = null; + ImageStoreEntity chosenStore = null; + + for (DataStore store : imageStores) { + snapshotDataStoreReference = _snapshotStoreDao.findByStoreSnapshot(DataStoreRole.Image, store.getId(), snapshotId); + if (snapshotDataStoreReference == null) { + logger.trace("Snapshot [{}] not in store [{}].", snapshotId, store.getId()); + continue; + } + String existingExtractUrl = snapshotDataStoreReference.getExtractUrl(); + if (existingExtractUrl != null) { + logger.debug("Extract URL already exists: [{}].", existingExtractUrl); + return existingExtractUrl; + } + chosenStore = (ImageStoreEntity) store; + logger.debug("Snapshot [{}] found in store [{}].", snapshotId, chosenStore.getId()); + break; + } + + if (ObjectUtils.anyNull(chosenStore, snapshotDataStoreReference)) { + logger.error("Snapshot [{}] not found in any secondary storage.", snapshotId); + throw new InvalidParameterValueException("Snapshot not found."); + } + + snapshotSrv.syncVolumeSnapshotsToRegionStore(snapshot.getVolumeId(), chosenStore); + + SnapshotInfo snapshotObject = snapshotFactory.getSnapshot(snapshotId, chosenStore); + String extractUrl = chosenStore.createEntityExtractUrl(snapshotObject.getPath(), snapshotObject.getBaseVolume().getFormat(), snapshotObject); + logger.debug("Extract URL [{}] created for snapshot [{}].", extractUrl, snapshot); + snapshotDataStoreReference.setExtractUrl(extractUrl); + snapshotDataStoreReference.setExtractUrlCreated(DateUtil.now()); + _snapshotStoreDao.update(snapshotDataStoreReference.getId(), snapshotDataStoreReference); + + return extractUrl; + } + @Override public Snapshot archiveSnapshot(Long snapshotId) { SnapshotInfo snapshotOnPrimary = snapshotFactory.getSnapshotOnPrimaryStore(snapshotId); diff --git a/server/src/main/java/com/cloud/template/TemplateManagerImpl.java b/server/src/main/java/com/cloud/template/TemplateManagerImpl.java index 11254afbaad..4d095d09cc6 100755 --- a/server/src/main/java/com/cloud/template/TemplateManagerImpl.java +++ b/server/src/main/java/com/cloud/template/TemplateManagerImpl.java @@ -298,7 +298,6 @@ public class TemplateManagerImpl extends ManagerBase implements TemplateManager, @Inject private HypervisorGuruManager _hvGuruMgr; - private boolean _disableExtraction = false; private List _adapters; ExecutorService _preloadExecutor; @@ -539,7 +538,7 @@ public class TemplateManagerImpl extends ManagerBase implements TemplateManager, if (isISO) { desc = Upload.Type.ISO.toString(); } - if (!_accountMgr.isRootAdmin(caller.getId()) && _disableExtraction) { + if (!_accountMgr.isRootAdmin(caller.getId()) && ApiDBUtils.isExtractionDisabled()) { throw new PermissionDeniedException("Extraction has been disabled by admin"); } @@ -1112,10 +1111,6 @@ public class TemplateManagerImpl extends ManagerBase implements TemplateManager, @Override public boolean configure(String name, Map params) throws ConfigurationException { - - String disableExtraction = _configDao.getValue(Config.DisableExtraction.toString()); - _disableExtraction = (disableExtraction == null) ? false : Boolean.parseBoolean(disableExtraction); - _preloadExecutor = Executors.newFixedThreadPool(TemplatePreloaderPoolSize.value(), new NamedThreadFactory("Template-Preloader")); return true; diff --git a/server/src/test/java/com/cloud/storage/snapshot/SnapshotManagerTest.java b/server/src/test/java/com/cloud/storage/snapshot/SnapshotManagerTest.java index 74b31283d9d..28903c72cc3 100755 --- a/server/src/test/java/com/cloud/storage/snapshot/SnapshotManagerTest.java +++ b/server/src/test/java/com/cloud/storage/snapshot/SnapshotManagerTest.java @@ -27,13 +27,20 @@ import static org.mockito.Mockito.when; import java.lang.reflect.Field; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; +import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; +import com.cloud.api.ApiDBUtils; +import com.cloud.exception.PermissionDeniedException; +import com.cloud.storage.Storage; +import org.apache.cloudstack.api.command.user.snapshot.ExtractSnapshotCmd; import org.apache.cloudstack.context.CallContext; import org.apache.cloudstack.engine.subsystem.api.storage.DataStore; +import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreManager; import org.apache.cloudstack.engine.subsystem.api.storage.ObjectInDataStoreStateMachine; import org.apache.cloudstack.engine.subsystem.api.storage.SnapshotDataFactory; import org.apache.cloudstack.engine.subsystem.api.storage.SnapshotInfo; @@ -49,6 +56,7 @@ 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.apache.cloudstack.storage.image.datastore.ImageStoreEntity; import org.junit.After; import org.junit.Assert; import org.junit.Before; @@ -176,6 +184,16 @@ public class SnapshotManagerTest { @Mock DataCenterDao dataCenterDao; + MockedStatic apiDBUtilsMock; + @Mock + ExtractSnapshotCmd extractSnapshotCmdMock; + @Mock + DataCenterVO dataCenterVOMock; + @Mock + ImageStoreEntity imageStoreEntityMock; + @Mock + DataStoreManager dataStoreManagerMock; + SnapshotPolicyVO snapshotPolicyVoInstance; List listIntervalTypes = Arrays.asList(DateUtil.IntervalType.values()); @@ -191,6 +209,11 @@ public class SnapshotManagerTest { private static final int TEST_SNAPSHOT_POLICY_MAX_SNAPS = 1; private static final boolean TEST_SNAPSHOT_POLICY_DISPLAY = true; private static final boolean TEST_SNAPSHOT_POLICY_ACTIVE = true; + private static final long TEST_ZONE_ID = 7L; + private static final long TEST_SNAPSHOTDATASTORE_ID = 7L; + private static final String TEST_EXTRACT_URL = "extractUrl"; + private static final String TEST_SNAPSHOT_PATH = "path"; + private static final Storage.ImageFormat TEST_VOLUME_FORMAT = Storage.ImageFormat.RAW; @Before public void setup() throws ResourceAllocationException { @@ -228,10 +251,13 @@ public class SnapshotManagerTest { snapshotPolicyVoInstance = new SnapshotPolicyVO(TEST_VOLUME_ID, TEST_SNAPSHOT_POLICY_SCHEDULE, TEST_SNAPSHOT_POLICY_TIMEZONE, TEST_SNAPSHOT_POLICY_INTERVAL, TEST_SNAPSHOT_POLICY_MAX_SNAPS, TEST_SNAPSHOT_POLICY_DISPLAY); + + apiDBUtilsMock = Mockito.mockStatic(ApiDBUtils.class); } @After public void tearDown() throws Exception { + apiDBUtilsMock.close(); CallContext.unregister(); } @@ -533,4 +559,108 @@ public class SnapshotManagerTest { mockForBackupSnapshotToSecondaryZoneTest(true, DataCenter.Type.Edge); Assert.assertFalse(_snapshotMgr.isBackupSnapshotToSecondaryForZone(1L)); } + + private void mockForExtractSnapshotTests() { + Mockito.doReturn(TEST_SNAPSHOT_ID).when(extractSnapshotCmdMock).getId(); + Mockito.doReturn(TEST_ZONE_ID).when(extractSnapshotCmdMock).getZoneId(); + Mockito.doReturn(false).when(_accountMgr).isRootAdmin(Mockito.anyLong()); + Mockito.when(ApiDBUtils.isExtractionDisabled()).thenReturn(false); + + Mockito.doReturn(dataCenterVOMock).when(dataCenterDao).findById(TEST_ZONE_ID); + + List dataStores = new ArrayList<>(); + dataStores.add(imageStoreEntityMock); + Mockito.doReturn(dataStores).when(dataStoreManagerMock).getImageStoresByScope(Mockito.any()); + Mockito.doReturn(TEST_STORAGE_POOL_ID).when(imageStoreEntityMock).getId(); + + Mockito.doReturn(snapshotStoreMock).when(snapshotStoreDao).findByStoreSnapshot(DataStoreRole.Image, TEST_STORAGE_POOL_ID, TEST_SNAPSHOT_ID); + + Mockito.doReturn(snapshotInfoMock).when(snapshotFactory).getSnapshot(TEST_SNAPSHOT_ID, imageStoreEntityMock); + Mockito.doReturn(TEST_SNAPSHOT_PATH).when(snapshotInfoMock).getPath(); + Mockito.doReturn(volumeInfoMock).when(snapshotInfoMock).getBaseVolume(); + Mockito.doReturn(TEST_VOLUME_FORMAT).when(volumeInfoMock).getFormat(); + + Mockito.doReturn(TEST_SNAPSHOTDATASTORE_ID).when(snapshotStoreMock).getId(); + Mockito.doReturn(TEST_EXTRACT_URL).when(imageStoreEntityMock).createEntityExtractUrl(TEST_SNAPSHOT_PATH, TEST_VOLUME_FORMAT, snapshotInfoMock); + } + + @Test(expected = PermissionDeniedException.class) + public void extractSnapshotTestNotRootAdminDisabledExtractionReturnException() { + mockForExtractSnapshotTests(); + Mockito.when(ApiDBUtils.isExtractionDisabled()).thenReturn(true); + + _snapshotMgr.extractSnapshot(extractSnapshotCmdMock); + } + + @Test(expected = InvalidParameterValueException.class) + public void extractSnapshotTestNullSnapshotReturnException() { + mockForExtractSnapshotTests(); + Mockito.doReturn(null).when(_snapshotDao).findById(TEST_SNAPSHOT_ID); + + _snapshotMgr.extractSnapshot(extractSnapshotCmdMock); + } + + @Test(expected = InvalidParameterValueException.class) + public void extractSnapshotTestRemovedSnapshotReturnException() { + mockForExtractSnapshotTests(); + Mockito.doReturn(Mockito.mock(Date.class)).when(snapshotMock).getRemoved(); + Mockito.doReturn(snapshotMock).when(_snapshotDao).findById(TEST_SNAPSHOT_ID); + + _snapshotMgr.extractSnapshot(extractSnapshotCmdMock); + } + + @Test(expected = IllegalArgumentException.class) + public void extractSnapshotTestNullDataCenterReturnException() { + mockForExtractSnapshotTests(); + Mockito.doReturn(null).when(dataCenterDao).findById(TEST_ZONE_ID); + + _snapshotMgr.extractSnapshot(extractSnapshotCmdMock); + } + + @Test(expected = InvalidParameterValueException.class) + public void extractSnapshotTestNoZoneStoragesReturnException() { + mockForExtractSnapshotTests(); + Mockito.doReturn(Collections.emptyList()).when(dataStoreManagerMock).getImageStoresByScope(Mockito.any()); + + _snapshotMgr.extractSnapshot(extractSnapshotCmdMock); + } + + @Test() + public void extractSnapshotTestExistingExtractUrlReturnUrl() { + mockForExtractSnapshotTests(); + String extractUrl = "extractUrl"; + Mockito.doReturn(extractUrl).when(snapshotStoreMock).getExtractUrl(); + + Assert.assertEquals(extractUrl, _snapshotMgr.extractSnapshot(extractSnapshotCmdMock)); + Mockito.verify(snapshotSrv, Mockito.never()).syncVolumeSnapshotsToRegionStore(Mockito.anyLong(), Mockito.any()); + Mockito.verify(snapshotStoreDao, Mockito.never()).update(Mockito.anyLong(), Mockito.any()); + } + + @Test(expected = InvalidParameterValueException.class) + public void extractSnapshotTestNullSnapshotStoreReturnException() { + mockForExtractSnapshotTests(); + Mockito.doReturn(null).when(snapshotStoreDao).findByStoreSnapshot(DataStoreRole.Image, TEST_STORAGE_POOL_ID, TEST_SNAPSHOT_ID); + + _snapshotMgr.extractSnapshot(extractSnapshotCmdMock); + } + + @Test() + public void extractSnapshotTestCreateExtractUrlReturnUrl() { + mockForExtractSnapshotTests(); + + Assert.assertEquals(TEST_EXTRACT_URL, _snapshotMgr.extractSnapshot(extractSnapshotCmdMock)); + Mockito.verify(snapshotSrv).syncVolumeSnapshotsToRegionStore(TEST_VOLUME_ID, imageStoreEntityMock); + Mockito.verify(snapshotStoreDao).update(TEST_SNAPSHOTDATASTORE_ID, snapshotStoreMock); + } + + @Test() + public void extractSnapshotTestRootAdminDisabledExtractionCreateExtractUrlReturnUrl() { + mockForExtractSnapshotTests(); + Mockito.doReturn(true).when(_accountMgr).isRootAdmin(Mockito.anyLong()); + Mockito.when(ApiDBUtils.isExtractionDisabled()).thenReturn(true); + + Assert.assertEquals(TEST_EXTRACT_URL, _snapshotMgr.extractSnapshot(extractSnapshotCmdMock)); + Mockito.verify(snapshotSrv).syncVolumeSnapshotsToRegionStore(TEST_VOLUME_ID, imageStoreEntityMock); + Mockito.verify(snapshotStoreDao).update(TEST_SNAPSHOTDATASTORE_ID, snapshotStoreMock); + } } diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index cda4e34618a..3e02e281042 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -115,6 +115,7 @@ "label.action.disable.user": "Disable User", "label.action.disable.zone": "Disable zone", "label.action.download.iso": "Download ISO", +"label.action.download.snapshot": "Download Snapshot", "label.action.download.template": "Download Template", "label.action.download.volume": "Download volume", "label.action.edit.account": "Edit Account", @@ -2575,6 +2576,7 @@ "message.action.disable.static.nat": "Please confirm that you want to disable static NAT.", "message.action.disable.zone": "Please confirm that you want to disable this zone.", "message.action.download.iso": "Please confirm that you want to download this ISO.", +"message.action.download.snapshot": "Please confirm that you want to download this Snapshot.", "message.action.download.template": "Please confirm that you want to download this Template.", "message.action.edit.nfs.mount.options": "Changes to NFS mount options will only take affect on cancelling maintenance mode which will cause the storage pool to be remounted on all KVM hosts with the new mount options.", "message.action.enable.cluster": "Please confirm that you want to enable this cluster.", diff --git a/ui/public/locales/pt_BR.json b/ui/public/locales/pt_BR.json index 82d527ae4c1..79333c100d3 100644 --- a/ui/public/locales/pt_BR.json +++ b/ui/public/locales/pt_BR.json @@ -95,6 +95,7 @@ "label.action.disable.user": "Desativar usu\u00e1rio", "label.action.disable.zone": "Desativar zona", "label.action.download.iso": "Baixar ISO", +"label.action.download.snapshot": "Baixar snapshot", "label.action.download.template": "Baixar template", "label.action.download.volume": "Baixar disco", "label.action.edit.account": "Editar conta", @@ -1856,6 +1857,7 @@ "message.action.disable.static.nat": "Confirme que voc\u00ea deseja desativar o NAT est\u00e1tico.", "message.action.disable.zone": "Confirma a desativa\u00e7\u00e3o da zona.", "message.action.download.iso": "Por favor confirme que voc\u00ea deseja baixar esta ISO.", +"message.action.download.snapshot": "Por favor confirme que voc\u00ea deseja baixar esta snapshot.", "message.action.download.template": "Por favor confirme que voc\u00ea deseja baixar este template.", "message.action.enable.cluster": "Confirma a ativa\u00e7\u00e3o do cluster.", "message.action.enable.physical.network": "Por favor confirme que voc\u00ea deseja habilitar esta rede f\u00edsica.", diff --git a/ui/src/config/section/storage.js b/ui/src/config/section/storage.js index 4c76ebf8db3..b4debc83a85 100644 --- a/ui/src/config/section/storage.js +++ b/ui/src/config/section/storage.js @@ -394,6 +394,26 @@ export default { dataView: true, show: (record) => { return record.state === 'BackedUp' && record.revertable } }, + { + api: 'extractSnapshot', + icon: 'cloud-download-outlined', + label: 'label.action.download.snapshot', + message: 'message.action.download.snapshot', + dataView: true, + show: (record, store) => { + return (['Admin'].includes(store.userInfo.roletype) || // If admin or owner or belongs to current project + ((record.domainid === store.userInfo.domainid && record.account === store.userInfo.account) || + (record.domainid === store.userInfo.domainid && record.projectid && store.project && store.project.id && record.projectid === store.project.id))) && + record.state === 'BackedUp' + }, + args: ['zoneid'], + mapping: { + zoneid: { + value: (record) => { return record.zoneid } + } + }, + response: (result) => { return `Please click ${result.snapshot.url} to download.` } + }, { api: 'deleteSnapshot', icon: 'delete-outlined',