Download Volume Snapshots (#8878)

Co-authored-by: Rodrigo D. Lopez <19981369+RodrigoDLopez@users.noreply.github.com>
diff --git a/api/src/main/java/com/cloud/event/EventTypes.java b/api/src/main/java/com/cloud/event/EventTypes.java
index d4235cb..71de350 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 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 @@
         // 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 59d203a..4e696e8 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 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 0893f33..67afd6a 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 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;
@@ -107,6 +108,16 @@
     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
      * @return Archived Snapshot object
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 ef759aa..a4d5238 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 @@
 
     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 5db6800..7861c1e 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 @@
             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 0000000..3f0f82e
--- /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 ce6ba5e..0fa0679 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 @@
             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 1146f80..9445aba 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.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 @@
         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 a1dc05fc..7a466c1 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 @@
     @Column(name = "install_path")
     private String installPath;
 
+    @Column(name = "download_url", length = 2048)
+    private String extractUrl;
+
+    @Column(name = "download_url_created")
+    @Temporal(value = TemporalType.TIMESTAMP)
+    private Date extractUrlCreated = null;
+
     @Column(name = "update_count", updatable = true, nullable = false)
     protected long updatedCount;
 
@@ -310,6 +317,22 @@
         this.volumeId = volumeId;
     }
 
+    public String getExtractUrl() {
+        return extractUrl;
+    }
+
+    public void setExtractUrl(String extractUrl) {
+        this.extractUrl = extractUrl;
+    }
+
+    public Date getExtractUrlCreated() {
+        return extractUrlCreated;
+    }
+
+    public void setExtractUrlCreated(Date extractUrlCreated) {
+        this.extractUrlCreated = extractUrlCreated;
+    }
+
     public void setCreated(Date created) {
         this.created = created;
     }
diff --git a/engine/schema/src/main/resources/META-INF/db/schema-41910to42000.sql b/engine/schema/src/main/resources/META-INF/db/schema-41910to42000.sql
index c522b45..2bcf41a 100644
--- a/engine/schema/src/main/resources/META-INF/db/schema-41910to42000.sql
+++ b/engine/schema/src/main/resources/META-INF/db/schema-41910to42000.sql
@@ -93,6 +93,10 @@
 -- Add `is_implicit` column to `host_tags` table
 CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.host_tags', 'is_implicit', 'int(1) UNSIGNED NOT NULL DEFAULT 0 COMMENT "If host tag is implicit or explicit" ');
 
+-- Fields related to Snapshot Extraction
+CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.snapshot_store_ref', 'download_url', 'varchar(2048) DEFAULT NULL');
+CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.snapshot_store_ref', 'download_url_created', 'datetime DEFAULT NULL');
+
 -- Webhooks feature
 DROP TABLE IF EXISTS `cloud`.`webhook`;
 CREATE TABLE `cloud`.`webhook` (
diff --git a/server/src/main/java/com/cloud/api/ApiResponseHelper.java b/server/src/main/java/com/cloud/api/ApiResponseHelper.java
index cd1d653..7e40588 100644
--- a/server/src/main/java/com/cloud/api/ApiResponseHelper.java
+++ b/server/src/main/java/com/cloud/api/ApiResponseHelper.java
@@ -372,7 +372,6 @@
 import com.cloud.storage.SnapshotVO;
 import com.cloud.storage.StoragePool;
 import com.cloud.storage.Upload;
-import com.cloud.storage.UploadVO;
 import com.cloud.storage.VMTemplateVO;
 import com.cloud.storage.Volume;
 import com.cloud.storage.VolumeVO;
@@ -1914,58 +1913,49 @@
         return listSgs.get(0);
     }
 
-    //TODO: we need to deprecate uploadVO, since extract is done in a synchronous fashion
-    @Override
-    public ExtractResponse createExtractResponse(Long id, Long zoneId, Long accountId, String mode, String url) {
-
+    private ExtractResponse createExtractResponse (Long zoneId, Long accountId, String url) {
         ExtractResponse response = new ExtractResponse();
-        response.setObjectName("template");
-        VMTemplateVO template = ApiDBUtils.findTemplateById(id);
-        response.setId(template.getUuid());
-        response.setName(template.getName());
         if (zoneId != null) {
             DataCenter zone = ApiDBUtils.findZoneById(zoneId);
             response.setZoneId(zone.getUuid());
             response.setZoneName(zone.getName());
         }
-        response.setMode(mode);
         response.setUrl(url);
         response.setState(Upload.Status.DOWNLOAD_URL_CREATED.toString());
         Account account = ApiDBUtils.findAccountById(accountId);
         response.setAccountId(account.getUuid());
-
         return response;
     }
 
     @Override
-    public ExtractResponse createExtractResponse(Long uploadId, Long id, Long zoneId, Long accountId, String mode, String url) {
+    public ExtractResponse createVolumeExtractResponse(Long id, Long zoneId, Long accountId, String mode, String url) {
+        ExtractResponse response = createExtractResponse(zoneId, accountId, url);
+        response.setObjectName("volume");
+        response.setMode(mode);
+        Volume volume = ApiDBUtils.findVolumeById(id);
+        response.setId(volume.getUuid());
+        response.setName(volume.getName());
+        return response;
+    }
 
-        ExtractResponse response = new ExtractResponse();
-        response.setObjectName("template");
+    @Override
+    public ExtractResponse createSnapshotExtractResponse(Long id, Long zoneId, Long accountId, String url) {
+        ExtractResponse response = createExtractResponse(zoneId, accountId, url);
+        response.setObjectName("snapshot");
+        Snapshot snapshot = ApiDBUtils.findSnapshotById(id);
+        response.setId(snapshot.getUuid());
+        response.setName(snapshot.getName());
+        return response;
+    }
+
+    @Override
+    public ExtractResponse createImageExtractResponse(Long id, Long zoneId, Long accountId, String mode, String url) {
+        ExtractResponse response = createExtractResponse(zoneId, accountId, url);
+        response.setMode(mode);
         VMTemplateVO template = ApiDBUtils.findTemplateById(id);
         response.setId(template.getUuid());
         response.setName(template.getName());
-        if (zoneId != null) {
-            DataCenter zone = ApiDBUtils.findZoneById(zoneId);
-            response.setZoneId(zone.getUuid());
-            response.setZoneName(zone.getName());
-        }
-        response.setMode(mode);
-        if (uploadId == null) {
-            // region-wide image store
-            response.setUrl(url);
-            response.setState(Upload.Status.DOWNLOAD_URL_CREATED.toString());
-        } else {
-            UploadVO uploadInfo = ApiDBUtils.findUploadById(uploadId);
-            response.setUploadId(uploadInfo.getUuid());
-            response.setState(uploadInfo.getUploadState().toString());
-            response.setUrl(uploadInfo.getUploadUrl());
-        }
-        Account account = ApiDBUtils.findAccountById(accountId);
-        response.setAccountId(account.getUuid());
-
         return response;
-
     }
 
     @Override
diff --git a/server/src/main/java/com/cloud/configuration/Config.java b/server/src/main/java/com/cloud/configuration/Config.java
index 6d9e91d..ce3ac76 100644
--- a/server/src/main/java/com/cloud/configuration/Config.java
+++ b/server/src/main/java/com/cloud/configuration/Config.java
@@ -557,7 +557,7 @@
             Boolean.class,
             "disable.extraction",
             "false",
-            "Flag for disabling extraction of template, isos and volumes",
+            "Flag for disabling extraction of templates, isos, snapshots and volumes",
             null),
     ExtractURLExpirationInterval(
             "Advanced",
diff --git a/server/src/main/java/com/cloud/server/ManagementServerImpl.java b/server/src/main/java/com/cloud/server/ManagementServerImpl.java
index a485cfe..68a3371 100644
--- a/server/src/main/java/com/cloud/server/ManagementServerImpl.java
+++ b/server/src/main/java/com/cloud/server/ManagementServerImpl.java
@@ -495,6 +495,7 @@
 import org.apache.cloudstack.api.command.user.snapshot.CreateSnapshotPolicyCmd;
 import org.apache.cloudstack.api.command.user.snapshot.DeleteSnapshotCmd;
 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.RevertSnapshotCmd;
@@ -3755,6 +3756,7 @@
         cmdList.add(CreateSnapshotFromVMSnapshotCmd.class);
         cmdList.add(CopySnapshotCmd.class);
         cmdList.add(DeleteSnapshotCmd.class);
+        cmdList.add(ExtractSnapshotCmd.class);
         cmdList.add(ArchiveSnapshotCmd.class);
         cmdList.add(CreateSnapshotPolicyCmd.class);
         cmdList.add(UpdateSnapshotPolicyCmd.class);
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 56981cf..51634ad 100755
--- a/server/src/main/java/com/cloud/storage/snapshot/SnapshotManagerImpl.java
+++ b/server/src/main/java/com/cloud/storage/snapshot/SnapshotManagerImpl.java
@@ -33,6 +33,7 @@
 import javax.naming.ConfigurationException;
 
 import org.apache.cloudstack.acl.SecurityChecker;
+import com.cloud.api.ApiDBUtils;
 import org.apache.cloudstack.annotation.AnnotationService;
 import org.apache.cloudstack.annotation.dao.AnnotationDao;
 import org.apache.cloudstack.api.ApiCommandResourceType;
@@ -40,6 +41,7 @@
 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;
@@ -72,10 +74,12 @@
 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.apache.cloudstack.utils.reflectiontostringbuilderutils.ReflectionToStringBuilderUtils;
 import org.apache.commons.collections.CollectionUtils;
 import org.apache.commons.collections.MapUtils;
 import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.ObjectUtils;
 import org.apache.commons.lang3.builder.ReflectionToStringBuilder;
 import org.apache.commons.lang3.builder.ToStringStyle;
 import org.springframework.stereotype.Component;
@@ -467,6 +471,74 @@
     }
 
     @Override
+    @ActionEvent(eventType = EventTypes.EVENT_SNAPSHOT_EXTRACT, eventDescription = "extracting snapshot", async = true)
+    public String extractSnapshot(ExtractSnapshotCmd cmd) {
+        Account caller = CallContext.current().getCallingAccount();
+        Long snapshotId = cmd.getId();
+        Long zoneId = cmd.getZoneId();
+
+        if (!_accountMgr.isRootAdmin(caller.getId()) && ApiDBUtils.isExtractionDisabled()) {
+            logger.error("Extraction is disabled through [{}].", Config.DisableExtraction);
+            throw new PermissionDeniedException("Extraction could not be completed.");
+        }
+
+        SnapshotVO snapshot = _snapshotDao.findById(snapshotId);
+        if (snapshot == null || snapshot.getRemoved() != null) {
+            logger.error("Unable to find active [{}].", snapshot);
+            throw new InvalidParameterValueException("Unable to find active snapshot.");
+        }
+
+        if (zoneId != null && dataCenterDao.findById(zoneId) == null) {
+            logger.error("Invalid zone id [{}].", zoneId);
+            throw new IllegalArgumentException("Please specify a valid zone.");
+        }
+
+        _accountMgr.checkAccess(caller, null, true, snapshot);
+
+        List<DataStore> 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 11254af..4d095d0 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 @@
     @Inject
     private HypervisorGuruManager _hvGuruMgr;
 
-    private boolean _disableExtraction = false;
     private List<TemplateAdapter> _adapters;
 
     ExecutorService _preloadExecutor;
@@ -539,7 +538,7 @@
         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 @@
 
     @Override
     public boolean configure(String name, Map<String, Object> 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 74b3128..28903c7 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 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.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 @@
     @Mock
     DataCenterDao dataCenterDao;
 
+    MockedStatic<ApiDBUtils> apiDBUtilsMock;
+    @Mock
+    ExtractSnapshotCmd extractSnapshotCmdMock;
+    @Mock
+    DataCenterVO dataCenterVOMock;
+    @Mock
+    ImageStoreEntity imageStoreEntityMock;
+    @Mock
+    DataStoreManager dataStoreManagerMock;
+
     SnapshotPolicyVO snapshotPolicyVoInstance;
 
     List<DateUtil.IntervalType> listIntervalTypes = Arrays.asList(DateUtil.IntervalType.values());
@@ -191,6 +209,11 @@
     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 @@
 
         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 @@
         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<DataStore> 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 cda4e34..3e02e28 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 82d527a..79333c1 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 4c76ebf..b4debc8 100644
--- a/ui/src/config/section/storage.js
+++ b/ui/src/config/section/storage.js
@@ -395,6 +395,26 @@
           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 <a href="${result.snapshot.url}" target="_blank">${result.snapshot.url}</a> to download.` }
+        },
+        {
           api: 'deleteSnapshot',
           icon: 'delete-outlined',
           label: 'label.action.delete.snapshot',