diff --git a/core/src/main/java/org/apache/cloudstack/backup/RestoreBackupCommand.java b/core/src/main/java/org/apache/cloudstack/backup/RestoreBackupCommand.java index 972c2eaf7bb4..4d40f5d2de58 100644 --- a/core/src/main/java/org/apache/cloudstack/backup/RestoreBackupCommand.java +++ b/core/src/main/java/org/apache/cloudstack/backup/RestoreBackupCommand.java @@ -143,6 +143,9 @@ public void setVmState(VirtualMachine.State vmState) { @LogLevel(LogLevel.Log4jLevel.Off) private String mountOptions; + /** LUKS passphrase for backups taken with nas.backup.encryption.enabled; null for plain backups. Never logged. */ + @LogLevel(LogLevel.Log4jLevel.Off) + private String encryptionPassphrase; @Override public boolean executeInSequence() { @@ -153,6 +156,14 @@ public List getBackupVolumesUUIDs() { return backupVolumesUUIDs; } + public String getEncryptionPassphrase() { + return encryptionPassphrase; + } + + public void setEncryptionPassphrase(String encryptionPassphrase) { + this.encryptionPassphrase = encryptionPassphrase; + } + public void setBackupVolumesUUIDs(List backupVolumesUUIDs) { this.backupVolumesUUIDs = backupVolumesUUIDs; } diff --git a/core/src/main/java/org/apache/cloudstack/backup/TakeBackupCommand.java b/core/src/main/java/org/apache/cloudstack/backup/TakeBackupCommand.java index 5402b6b24760..1d531c455a82 100644 --- a/core/src/main/java/org/apache/cloudstack/backup/TakeBackupCommand.java +++ b/core/src/main/java/org/apache/cloudstack/backup/TakeBackupCommand.java @@ -23,9 +23,20 @@ import com.cloud.agent.api.LogLevel; import org.apache.cloudstack.storage.to.PrimaryDataStoreTO; +import java.util.HashMap; import java.util.List; +import java.util.Map; public class TakeBackupCommand extends Command { + // Detail-map keys shared between the management server (NASBackupProvider) and + // the KVM agent wrapper. Defining them once here avoids drift between producer + // and consumer when a key is renamed. + public static final String DETAIL_COMPRESSION = "compression"; + public static final String DETAIL_ENCRYPTION = "encryption"; + public static final String DETAIL_ENCRYPTION_PASSPHRASE = "encryption_passphrase"; + public static final String DETAIL_BANDWIDTH_LIMIT = "bandwidth_limit"; + public static final String DETAIL_INTEGRITY_CHECK = "integrity_check"; + private String vmName; private String backupPath; private String backupRepoType; @@ -35,6 +46,8 @@ public class TakeBackupCommand extends Command { private Boolean quiesce; @LogLevel(LogLevel.Log4jLevel.Off) private String mountOptions; + @LogLevel(LogLevel.Log4jLevel.Off) + private Map details = new HashMap<>(); public TakeBackupCommand(String vmName, String backupPath) { super(); @@ -106,6 +119,18 @@ public void setQuiesce(Boolean quiesce) { this.quiesce = quiesce; } + public Map getDetails() { + return details; + } + + public void setDetails(Map details) { + this.details = details != null ? details : new HashMap<>(); + } + + public void addDetail(String key, String value) { + this.details.put(key, value); + } + @Override public boolean executeInSequence() { return true; diff --git a/plugins/backup/nas/src/main/java/org/apache/cloudstack/backup/NASBackupProvider.java b/plugins/backup/nas/src/main/java/org/apache/cloudstack/backup/NASBackupProvider.java index fb1b78eb963b..8d4f6ba228bb 100644 --- a/plugins/backup/nas/src/main/java/org/apache/cloudstack/backup/NASBackupProvider.java +++ b/plugins/backup/nas/src/main/java/org/apache/cloudstack/backup/NASBackupProvider.java @@ -85,6 +85,46 @@ public class NASBackupProvider extends AdapterBase implements BackupProvider, Co true, BackupFrameworkEnabled.key()); + ConfigKey NASBackupCompressionEnabled = new ConfigKey<>("Advanced", Boolean.class, + "nas.backup.compression.enabled", + "false", + "Enable qcow2 compression for NAS backup files.", + true, + ConfigKey.Scope.Zone, + BackupFrameworkEnabled.key()); + + ConfigKey NASBackupEncryptionEnabled = new ConfigKey<>("Advanced", Boolean.class, + "nas.backup.encryption.enabled", + "false", + "Enable LUKS encryption for NAS backup files.", + true, + ConfigKey.Scope.Zone, + BackupFrameworkEnabled.key()); + + ConfigKey NASBackupEncryptionPassphrase = new ConfigKey<>("Secure", String.class, + "nas.backup.encryption.passphrase", + "", + "Passphrase for LUKS encryption of NAS backup files. Required when encryption is enabled.", + true, + ConfigKey.Scope.Zone, + BackupFrameworkEnabled.key()); + + ConfigKey NASBackupBandwidthLimitMbps = new ConfigKey<>("Advanced", Integer.class, + "nas.backup.bandwidth.limit.mbps", + "0", + "Bandwidth limit in MiB/s for backup operations (0 = unlimited).", + true, + ConfigKey.Scope.Zone, + BackupFrameworkEnabled.key()); + + ConfigKey NASBackupIntegrityCheckEnabled = new ConfigKey<>("Advanced", Boolean.class, + "nas.backup.integrity.check", + "false", + "Run qemu-img check on backup files after creation to verify integrity.", + true, + ConfigKey.Scope.Zone, + BackupFrameworkEnabled.key()); + @Inject private BackupDao backupDao; @@ -206,6 +246,9 @@ public Pair takeBackup(final VirtualMachine vm, Boolean quiesce command.setMountOptions(backupRepository.getMountOptions()); command.setQuiesce(quiesceVM); + // Pass optional backup enhancement settings from zone-scoped configs + applyBackupEnhancementDetails(command, vm.getDataCenterId()); + if (VirtualMachine.State.Stopped.equals(vm.getState())) { List vmVolumes = volumeDao.findByInstance(vm.getId()); vmVolumes.sort(Comparator.comparing(Volume::getDeviceId)); @@ -254,6 +297,32 @@ public Pair takeBackup(final VirtualMachine vm, Boolean quiesce } } + /** + * Translates the zone-scoped backup-enhancement settings (compression, encryption, + * bandwidth limit, integrity check) into details on the {@link TakeBackupCommand}. + * Fails fast if encryption is enabled without a configured passphrase. + */ + protected void applyBackupEnhancementDetails(TakeBackupCommand command, Long zoneId) { + if (Boolean.TRUE.equals(NASBackupCompressionEnabled.valueIn(zoneId))) { + command.addDetail(TakeBackupCommand.DETAIL_COMPRESSION, "true"); + } + if (Boolean.TRUE.equals(NASBackupEncryptionEnabled.valueIn(zoneId))) { + String passphrase = NASBackupEncryptionPassphrase.valueIn(zoneId); + if (passphrase == null || passphrase.isEmpty()) { + throw new CloudRuntimeException("NAS backup encryption is enabled but no passphrase is configured (nas.backup.encryption.passphrase)"); + } + command.addDetail(TakeBackupCommand.DETAIL_ENCRYPTION, "true"); + command.addDetail(TakeBackupCommand.DETAIL_ENCRYPTION_PASSPHRASE, passphrase); + } + Integer bandwidthLimit = NASBackupBandwidthLimitMbps.valueIn(zoneId); + if (bandwidthLimit != null && bandwidthLimit > 0) { + command.addDetail(TakeBackupCommand.DETAIL_BANDWIDTH_LIMIT, String.valueOf(bandwidthLimit)); + } + if (Boolean.TRUE.equals(NASBackupIntegrityCheckEnabled.valueIn(zoneId))) { + command.addDetail(TakeBackupCommand.DETAIL_INTEGRITY_CHECK, "true"); + } + } + private BackupVO createBackupObject(VirtualMachine vm, String backupPath) { BackupVO backup = new BackupVO(); backup.setVmId(vm.getId()); @@ -279,6 +348,19 @@ private BackupVO createBackupObject(VirtualMachine vm, String backupPath) { return backupDao.persist(backup); } + /** + * Restore-side counterpart of {@link #applyBackupEnhancementDetails}: hands the zone's LUKS passphrase + * to the host so encrypted backup files can be checked and converted back. It is sent whenever a + * passphrase is configured, not only while encryption is switched on, so backups taken before + * encryption was disabled stay restorable; the host ignores it for plain backups. + */ + protected void applyRestoreEncryptionDetails(RestoreBackupCommand command, Long zoneId) { + String passphrase = NASBackupEncryptionPassphrase.valueIn(zoneId); + if (passphrase != null && !passphrase.isEmpty()) { + command.setEncryptionPassphrase(passphrase); + } + } + @Override public Pair restoreBackupToVM(VirtualMachine vm, Backup backup, String hostIp, String dataStoreUuid) { return restoreVMBackup(vm, backup); @@ -318,6 +400,7 @@ private Pair restoreVMBackup(VirtualMachine vm, Backup backup) restoreCommand.setVmExists(vm.getRemoved() == null); restoreCommand.setVmState(vm.getState()); restoreCommand.setMountTimeout(NASBackupRestoreMountTimeout.value()); + applyRestoreEncryptionDetails(restoreCommand, vm.getDataCenterId()); BackupAnswer answer; try { @@ -435,6 +518,7 @@ public Pair restoreBackedUpVolume(Backup backup, Backup.VolumeI restoreCommand.setVmState(vmNameAndState.second()); restoreCommand.setMountTimeout(NASBackupRestoreMountTimeout.value()); restoreCommand.setBackupFiles(Collections.singletonList(matchingVolume.getPath())); + applyRestoreEncryptionDetails(restoreCommand, backup.getZoneId()); BackupAnswer answer; try { @@ -618,7 +702,12 @@ public Boolean crossZoneInstanceCreationEnabled(BackupOffering backupOffering) { @Override public ConfigKey[] getConfigKeys() { return new ConfigKey[]{ - NASBackupRestoreMountTimeout + NASBackupRestoreMountTimeout, + NASBackupCompressionEnabled, + NASBackupEncryptionEnabled, + NASBackupEncryptionPassphrase, + NASBackupBandwidthLimitMbps, + NASBackupIntegrityCheckEnabled }; } diff --git a/plugins/backup/nas/src/test/java/org/apache/cloudstack/backup/NASBackupProviderTest.java b/plugins/backup/nas/src/test/java/org/apache/cloudstack/backup/NASBackupProviderTest.java index 7540cabbbf52..ccfa9039ed29 100644 --- a/plugins/backup/nas/src/test/java/org/apache/cloudstack/backup/NASBackupProviderTest.java +++ b/plugins/backup/nas/src/test/java/org/apache/cloudstack/backup/NASBackupProviderTest.java @@ -19,14 +19,18 @@ import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.mock; +import java.lang.reflect.Field; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; +import org.apache.cloudstack.framework.config.ConfigKey; import org.junit.Assert; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; @@ -47,6 +51,7 @@ import com.cloud.storage.VolumeVO; import com.cloud.storage.dao.VolumeDao; import com.cloud.utils.Pair; +import com.cloud.utils.exception.CloudRuntimeException; import com.cloud.vm.VMInstanceVO; import com.cloud.vm.dao.VMInstanceDao; @@ -349,4 +354,225 @@ public void testGetVMHypervisorHostFallbackToZoneWideKVMHost() { Mockito.verify(hostDao).findHypervisorHostInCluster(clusterId); Mockito.verify(resourceManager).findOneRandomRunningHostByHypervisor(Hypervisor.HypervisorType.KVM, zoneId); } + + private void overrideConfigValue(final ConfigKey configKey, final Object value) { + try { + // Use reflection to invoke protected valueOf() + java.lang.reflect.Method valueOfMethod = ConfigKey.class.getDeclaredMethod("valueOf", String.class); + valueOfMethod.setAccessible(true); + Object typedValue = value != null ? valueOfMethod.invoke(configKey, String.valueOf(value)) : null; + + // Set _value for value() calls + Field f = ConfigKey.class.getDeclaredField("_value"); + f.setAccessible(true); + f.set(configKey, typedValue); + + // Also set _defaultValue via Spring's ReflectionTestUtils (handles final fields) + ReflectionTestUtils.setField(configKey, "_defaultValue", String.valueOf(value)); + } catch (Exception e) { + Assert.fail(e.getMessage()); + } + } + + private VMInstanceVO setupVmForTakeBackup(Long vmId, Long hostId, Long backupOfferingId, + Long accountId, Long domainId, Long zoneId) { + VMInstanceVO vm = mock(VMInstanceVO.class); + Mockito.when(vm.getId()).thenReturn(vmId); + Mockito.when(vm.getHostId()).thenReturn(hostId); + Mockito.when(vm.getInstanceName()).thenReturn("test-vm"); + Mockito.when(vm.getBackupOfferingId()).thenReturn(backupOfferingId); + Mockito.when(vm.getAccountId()).thenReturn(accountId); + Mockito.when(vm.getDomainId()).thenReturn(domainId); + Mockito.when(vm.getDataCenterId()).thenReturn(zoneId); + Mockito.when(vm.getState()).thenReturn(VMInstanceVO.State.Running); + return vm; + } + + private void setupHostAndRepo(Long hostId, Long backupOfferingId) { + BackupRepository backupRepository = mock(BackupRepository.class); + Mockito.when(backupRepository.getType()).thenReturn("nfs"); + Mockito.when(backupRepository.getAddress()).thenReturn("address"); + Mockito.when(backupRepository.getMountOptions()).thenReturn("sync"); + Mockito.when(backupRepositoryDao.findByBackupOfferingId(backupOfferingId)).thenReturn(backupRepository); + + HostVO host = mock(HostVO.class); + Mockito.when(host.getId()).thenReturn(hostId); + Mockito.when(host.getStatus()).thenReturn(Status.Up); + Mockito.when(host.getHypervisorType()).thenReturn(Hypervisor.HypervisorType.KVM); + Mockito.when(hostDao.findById(hostId)).thenReturn(host); + } + + @Test + public void testTakeBackupDetailsCompressionEnabled() throws AgentUnavailableException, OperationTimedoutException { + Long vmId = 1L; Long hostId = 2L; Long backupOfferingId = 3L; + Long accountId = 4L; Long domainId = 5L; Long zoneId = 6L; + + VMInstanceVO vm = setupVmForTakeBackup(vmId, hostId, backupOfferingId, accountId, domainId, zoneId); + setupHostAndRepo(hostId, backupOfferingId); + + VolumeVO volume = mock(VolumeVO.class); + Mockito.when(volume.getState()).thenReturn(Volume.State.Ready); + Mockito.when(volume.getSize()).thenReturn(100L); + Mockito.when(volumeDao.findByInstance(vmId)).thenReturn(List.of(volume)); + + overrideConfigValue(nasBackupProvider.NASBackupCompressionEnabled, "true"); + + BackupAnswer answer = mock(BackupAnswer.class); + Mockito.when(answer.getResult()).thenReturn(true); + Mockito.when(answer.getSize()).thenReturn(100L); + + ArgumentCaptor cmdCaptor = ArgumentCaptor.forClass(TakeBackupCommand.class); + Mockito.when(agentManager.send(anyLong(), cmdCaptor.capture())).thenReturn(answer); + Mockito.when(backupDao.persist(Mockito.any(BackupVO.class))).thenAnswer(invocation -> invocation.getArgument(0)); + Mockito.when(backupDao.update(Mockito.anyLong(), Mockito.any(BackupVO.class))).thenReturn(true); + + nasBackupProvider.takeBackup(vm, false); + + TakeBackupCommand capturedCmd = cmdCaptor.getValue(); + Map details = capturedCmd.getDetails(); + Assert.assertEquals("true", details.get(TakeBackupCommand.DETAIL_COMPRESSION)); + + // Reset config + overrideConfigValue(nasBackupProvider.NASBackupCompressionEnabled, "false"); + } + + @Test + public void testTakeBackupDetailsBandwidthLimit() throws AgentUnavailableException, OperationTimedoutException { + Long vmId = 1L; Long hostId = 2L; Long backupOfferingId = 3L; + Long accountId = 4L; Long domainId = 5L; Long zoneId = 6L; + + VMInstanceVO vm = setupVmForTakeBackup(vmId, hostId, backupOfferingId, accountId, domainId, zoneId); + setupHostAndRepo(hostId, backupOfferingId); + + VolumeVO volume = mock(VolumeVO.class); + Mockito.when(volume.getState()).thenReturn(Volume.State.Ready); + Mockito.when(volume.getSize()).thenReturn(100L); + Mockito.when(volumeDao.findByInstance(vmId)).thenReturn(List.of(volume)); + + overrideConfigValue(nasBackupProvider.NASBackupBandwidthLimitMbps, "50"); + + BackupAnswer answer = mock(BackupAnswer.class); + Mockito.when(answer.getResult()).thenReturn(true); + Mockito.when(answer.getSize()).thenReturn(100L); + + ArgumentCaptor cmdCaptor = ArgumentCaptor.forClass(TakeBackupCommand.class); + Mockito.when(agentManager.send(anyLong(), cmdCaptor.capture())).thenReturn(answer); + Mockito.when(backupDao.persist(Mockito.any(BackupVO.class))).thenAnswer(invocation -> invocation.getArgument(0)); + Mockito.when(backupDao.update(Mockito.anyLong(), Mockito.any(BackupVO.class))).thenReturn(true); + + nasBackupProvider.takeBackup(vm, false); + + TakeBackupCommand capturedCmd = cmdCaptor.getValue(); + Map details = capturedCmd.getDetails(); + Assert.assertEquals("50", details.get(TakeBackupCommand.DETAIL_BANDWIDTH_LIMIT)); + + overrideConfigValue(nasBackupProvider.NASBackupBandwidthLimitMbps, "0"); + } + + @Test + public void testTakeBackupDetailsIntegrityCheck() throws AgentUnavailableException, OperationTimedoutException { + Long vmId = 1L; Long hostId = 2L; Long backupOfferingId = 3L; + Long accountId = 4L; Long domainId = 5L; Long zoneId = 6L; + + VMInstanceVO vm = setupVmForTakeBackup(vmId, hostId, backupOfferingId, accountId, domainId, zoneId); + setupHostAndRepo(hostId, backupOfferingId); + + VolumeVO volume = mock(VolumeVO.class); + Mockito.when(volume.getState()).thenReturn(Volume.State.Ready); + Mockito.when(volume.getSize()).thenReturn(100L); + Mockito.when(volumeDao.findByInstance(vmId)).thenReturn(List.of(volume)); + + overrideConfigValue(nasBackupProvider.NASBackupIntegrityCheckEnabled, "true"); + + BackupAnswer answer = mock(BackupAnswer.class); + Mockito.when(answer.getResult()).thenReturn(true); + Mockito.when(answer.getSize()).thenReturn(100L); + + ArgumentCaptor cmdCaptor = ArgumentCaptor.forClass(TakeBackupCommand.class); + Mockito.when(agentManager.send(anyLong(), cmdCaptor.capture())).thenReturn(answer); + Mockito.when(backupDao.persist(Mockito.any(BackupVO.class))).thenAnswer(invocation -> invocation.getArgument(0)); + Mockito.when(backupDao.update(Mockito.anyLong(), Mockito.any(BackupVO.class))).thenReturn(true); + + nasBackupProvider.takeBackup(vm, false); + + TakeBackupCommand capturedCmd = cmdCaptor.getValue(); + Map details = capturedCmd.getDetails(); + Assert.assertEquals("true", details.get(TakeBackupCommand.DETAIL_INTEGRITY_CHECK)); + + overrideConfigValue(nasBackupProvider.NASBackupIntegrityCheckEnabled, "false"); + } + + @Test + public void testTakeBackupDetailsEncryptionWithPassphrase() throws AgentUnavailableException, OperationTimedoutException { + Long vmId = 1L; Long hostId = 2L; Long backupOfferingId = 3L; + Long accountId = 4L; Long domainId = 5L; Long zoneId = 6L; + + VMInstanceVO vm = setupVmForTakeBackup(vmId, hostId, backupOfferingId, accountId, domainId, zoneId); + setupHostAndRepo(hostId, backupOfferingId); + + VolumeVO volume = mock(VolumeVO.class); + Mockito.when(volume.getState()).thenReturn(Volume.State.Ready); + Mockito.when(volume.getSize()).thenReturn(100L); + Mockito.when(volumeDao.findByInstance(vmId)).thenReturn(List.of(volume)); + + overrideConfigValue(nasBackupProvider.NASBackupEncryptionEnabled, "true"); + overrideConfigValue(nasBackupProvider.NASBackupEncryptionPassphrase, "my-secret-passphrase"); + + BackupAnswer answer = mock(BackupAnswer.class); + Mockito.when(answer.getResult()).thenReturn(true); + Mockito.when(answer.getSize()).thenReturn(100L); + + ArgumentCaptor cmdCaptor = ArgumentCaptor.forClass(TakeBackupCommand.class); + Mockito.when(agentManager.send(anyLong(), cmdCaptor.capture())).thenReturn(answer); + Mockito.when(backupDao.persist(Mockito.any(BackupVO.class))).thenAnswer(invocation -> invocation.getArgument(0)); + Mockito.when(backupDao.update(Mockito.anyLong(), Mockito.any(BackupVO.class))).thenReturn(true); + + nasBackupProvider.takeBackup(vm, false); + + TakeBackupCommand capturedCmd = cmdCaptor.getValue(); + Map details = capturedCmd.getDetails(); + Assert.assertEquals("true", details.get(TakeBackupCommand.DETAIL_ENCRYPTION)); + Assert.assertEquals("my-secret-passphrase", details.get(TakeBackupCommand.DETAIL_ENCRYPTION_PASSPHRASE)); + + overrideConfigValue(nasBackupProvider.NASBackupEncryptionEnabled, "false"); + overrideConfigValue(nasBackupProvider.NASBackupEncryptionPassphrase, ""); + } + + @Test(expected = CloudRuntimeException.class) + public void testTakeBackupEncryptionWithoutPassphraseThrows() throws AgentUnavailableException, OperationTimedoutException { + Long vmId = 1L; Long hostId = 2L; Long backupOfferingId = 3L; + Long accountId = 4L; Long domainId = 5L; Long zoneId = 6L; + + VMInstanceVO vm = setupVmForTakeBackup(vmId, hostId, backupOfferingId, accountId, domainId, zoneId); + setupHostAndRepo(hostId, backupOfferingId); + + VolumeVO volume = mock(VolumeVO.class); + Mockito.when(volume.getState()).thenReturn(Volume.State.Ready); + Mockito.when(volume.getSize()).thenReturn(100L); + Mockito.when(volumeDao.findByInstance(vmId)).thenReturn(List.of(volume)); + + overrideConfigValue(nasBackupProvider.NASBackupEncryptionEnabled, "true"); + overrideConfigValue(nasBackupProvider.NASBackupEncryptionPassphrase, ""); + + Mockito.when(backupDao.persist(Mockito.any(BackupVO.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + try { + nasBackupProvider.takeBackup(vm, false); + } finally { + overrideConfigValue(nasBackupProvider.NASBackupEncryptionEnabled, "false"); + } + } + + @Test + public void testRestoreCommandCarriesPassphraseOnlyWhenConfigured() { + overrideConfigValue(nasBackupProvider.NASBackupEncryptionPassphrase, "my-secret-passphrase"); + RestoreBackupCommand withPassphrase = new RestoreBackupCommand(); + nasBackupProvider.applyRestoreEncryptionDetails(withPassphrase, 6L); + Assert.assertEquals("my-secret-passphrase", withPassphrase.getEncryptionPassphrase()); + + overrideConfigValue(nasBackupProvider.NASBackupEncryptionPassphrase, ""); + RestoreBackupCommand without = new RestoreBackupCommand(); + nasBackupProvider.applyRestoreEncryptionDetails(without, 6L); + Assert.assertNull(without.getEncryptionPassphrase()); + } } diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRestoreBackupCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRestoreBackupCommandWrapper.java index 22dbfbdd67a2..fc78ced1af8f 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRestoreBackupCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRestoreBackupCommandWrapper.java @@ -41,10 +41,12 @@ import org.apache.commons.lang3.StringUtils; import org.libvirt.LibvirtException; +import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.ArrayList; import java.util.List; import java.util.Locale; import java.util.Objects; @@ -60,6 +62,8 @@ public class LibvirtRestoreBackupCommandWrapper extends CommandWrapper backupFiles = command.getBackupFiles(); String newVolumeId = null; + File keyFile = null; try { + keyFile = NasBackupPassphraseFile.write(command.getEncryptionPassphrase()); String mountDirectory = mountBackupDirectory(backupRepoAddress, backupRepoType, mountOptions, mountTimeout); if (Objects.isNull(vmExists)) { PrimaryDataStoreTO volumePool = restoreVolumePools.get(0); @@ -102,32 +108,36 @@ public Answer execute(RestoreBackupCommand command, LibvirtComputingResource ser newVolumeId = getVolumeUuidFromPath(volumePath, volumePool); Long size = command.getRestoreVolumeSizes().get(0); restoreVolume(storagePoolMgr, backupPath, volumePool, volumePath, diskType, backupFile, size, - new Pair<>(vmName, command.getVmState()), mountDirectory, timeout); + new Pair<>(vmName, command.getVmState()), mountDirectory, timeout, keyFile); } else if (Boolean.TRUE.equals(vmExists)) { - restoreVolumesOfExistingVM(storagePoolMgr, restoreVolumePools, restoreVolumePaths, backedVolumeUUIDs, backupPath, backupFiles, mountDirectory, timeout); + restoreVolumesOfExistingVM(storagePoolMgr, restoreVolumePools, restoreVolumePaths, backedVolumeUUIDs, backupPath, backupFiles, mountDirectory, timeout, keyFile); } else { - restoreVolumesOfDestroyedVMs(storagePoolMgr, restoreVolumePools, restoreVolumePaths, backupPath, backupFiles, mountDirectory, timeout); + restoreVolumesOfDestroyedVMs(storagePoolMgr, restoreVolumePools, restoreVolumePaths, backupPath, backupFiles, mountDirectory, timeout, keyFile); } } catch (CloudRuntimeException e) { String errorMessage = e.getMessage() != null ? e.getMessage() : ""; return new BackupAnswer(command, false, errorMessage); + } catch (IOException e) { + return new BackupAnswer(command, false, "Failed to prepare the backup encryption passphrase: " + e.getMessage()); + } finally { + NasBackupPassphraseFile.delete(keyFile); } return new BackupAnswer(command, true, newVolumeId); } - private void verifyBackupFile(String backupPath, String volUuid) { + private void verifyBackupFile(String backupPath, String volUuid, File keyFile) { if (!checkBackupPathExists(backupPath)) { throw new CloudRuntimeException(String.format("Backup file for the volume [%s] does not exist.", volUuid)); } - if (!checkBackupFileImage(backupPath)) { + if (!checkBackupFileImage(backupPath, keyFile)) { throw new CloudRuntimeException(String.format("Backup qcow2 file for the volume [%s] is corrupt.", volUuid)); } } private void restoreVolumesOfExistingVM(KVMStoragePoolManager storagePoolMgr, List restoreVolumePools, List restoreVolumePaths, List backedVolumesUUIDs, - String backupPath, List backupFiles, String mountDirectory, int timeout) { + String backupPath, List backupFiles, String mountDirectory, int timeout, File keyFile) { String diskType = "root"; try { for (int idx = 0; idx < restoreVolumePaths.size(); idx++) { @@ -138,8 +148,8 @@ private void restoreVolumesOfExistingVM(KVMStoragePoolManager storagePoolMgr, Li String fullPath = getBackupPath(mountDirectory, backupPath, backupFile, diskType); diskType = "datadisk"; - verifyBackupFile(fullPath, backupVolumeUuid); - if (!replaceVolumeWithBackup(storagePoolMgr, restoreVolumePool, restoreVolumePath, fullPath, timeout)) { + verifyBackupFile(fullPath, backupVolumeUuid, keyFile); + if (!replaceVolumeWithBackup(storagePoolMgr, restoreVolumePool, restoreVolumePath, fullPath, timeout, keyFile)) { throw new CloudRuntimeException(String.format("Unable to restore contents from the backup volume [%s].", backupVolumeUuid)); } } @@ -150,7 +160,7 @@ private void restoreVolumesOfExistingVM(KVMStoragePoolManager storagePoolMgr, Li } private void restoreVolumesOfDestroyedVMs(KVMStoragePoolManager storagePoolMgr, List volumePools, - List volumePaths, String backupPath, List backupFiles, String mountDirectory, int timeout) { + List volumePaths, String backupPath, List backupFiles, String mountDirectory, int timeout, File keyFile) { String diskType = "root"; try { for (int i = 0; i < volumePaths.size(); i++) { @@ -160,8 +170,8 @@ private void restoreVolumesOfDestroyedVMs(KVMStoragePoolManager storagePoolMgr, String bkpPath = getBackupPath(mountDirectory, backupPath, backupFile, diskType); String volumeUuid = getVolumeUuidFromPath(volumePath, volumePool); diskType = "datadisk"; - verifyBackupFile(bkpPath, volumeUuid); - if (!replaceVolumeWithBackup(storagePoolMgr, volumePool, volumePath, bkpPath, timeout)) { + verifyBackupFile(bkpPath, volumeUuid, keyFile); + if (!replaceVolumeWithBackup(storagePoolMgr, volumePool, volumePath, bkpPath, timeout, keyFile)) { throw new CloudRuntimeException(String.format("Unable to restore contents from the backup volume [%s].", volumeUuid)); } } @@ -172,14 +182,14 @@ private void restoreVolumesOfDestroyedVMs(KVMStoragePoolManager storagePoolMgr, } private void restoreVolume(KVMStoragePoolManager storagePoolMgr, String backupPath, PrimaryDataStoreTO volumePool, String volumePath, String diskType, String backupFile, - Long size, Pair vmNameAndState, String mountDirectory, int timeout) { + Long size, Pair vmNameAndState, String mountDirectory, int timeout, File keyFile) { String bkpPath; String volumeUuid; try { bkpPath = getBackupPath(mountDirectory, backupPath, backupFile, diskType); volumeUuid = getVolumeUuidFromPath(volumePath, volumePool); - verifyBackupFile(bkpPath, volumeUuid); - if (!replaceVolumeWithBackup(storagePoolMgr, volumePool, volumePath, bkpPath, timeout, true, size)) { + verifyBackupFile(bkpPath, volumeUuid, keyFile); + if (!replaceVolumeWithBackup(storagePoolMgr, volumePool, volumePath, bkpPath, timeout, true, size, keyFile)) { throw new CloudRuntimeException(String.format("Unable to restore contents from the backup volume [%s].", volumeUuid)); } @@ -251,9 +261,36 @@ private String getBackupPath(String mountDirectory, String backupPath, String ba return bkpPath; } - private boolean checkBackupFileImage(String backupPath) { - int exitValue = Script.runSimpleBashScriptForExitValue(String.format("qemu-img check %s", backupPath)); - return exitValue == 0; + private boolean checkBackupFileImage(String backupPath, File keyFile) { + if (!isEncryptedImage(backupPath)) { + int exitValue = Script.runSimpleBashScriptForExitValue(String.format("qemu-img check %s", backupPath)); + return exitValue == 0; + } + List cmd = new ArrayList<>(List.of("qemu-img", "check")); + cmd.addAll(encryptedSourceArgs(backupPath, keyFile)); + return Script.executeCommandForExitValue(cmd.toArray(new String[0])) == 0; + } + + /** + * True when qemu reports the backup qcow2 as encrypted (LUKS, produced by nasbackup.sh {@code -e}). + * Reading the header needs no secret, so this works before any passphrase is involved. + */ + private boolean isEncryptedImage(String backupPath) { + String info = Script.executeCommand("qemu-img", "info", "--output=json", backupPath); + return info != null && info.replaceAll("\\s", "").contains("\"encrypted\":true"); + } + + /** + * qemu-img arguments that open an encrypted {@code backupPath} as the source image, with the LUKS + * secret read from {@code keyFile}. Fails clearly when the backup is encrypted but no passphrase + * reached the host, instead of letting qemu-img fail with an opaque "Could not open" error. + */ + private static List encryptedSourceArgs(String backupPath, File keyFile) { + if (keyFile == null) { + throw new CloudRuntimeException(String.format("Backup file [%s] is LUKS-encrypted but no passphrase is configured (nas.backup.encryption.passphrase).", backupPath)); + } + return List.of("--object", "secret,id=" + LUKS_SECRET_ID + ",file=" + keyFile.getAbsolutePath(), + "--image-opts", "driver=qcow2,file.filename=" + backupPath + ",encrypt.key-secret=" + LUKS_SECRET_ID); } private boolean checkBackupPathExists(String backupPath) { @@ -261,20 +298,28 @@ private boolean checkBackupPathExists(String backupPath) { return exitValue == 0; } - private boolean replaceVolumeWithBackup(KVMStoragePoolManager storagePoolMgr, PrimaryDataStoreTO volumePool, String volumePath, String backupPath, int timeout) { - return replaceVolumeWithBackup(storagePoolMgr, volumePool, volumePath, backupPath, timeout, false, null); + private boolean replaceVolumeWithBackup(KVMStoragePoolManager storagePoolMgr, PrimaryDataStoreTO volumePool, String volumePath, String backupPath, int timeout, File keyFile) { + return replaceVolumeWithBackup(storagePoolMgr, volumePool, volumePath, backupPath, timeout, false, null, keyFile); } - private boolean replaceVolumeWithBackup(KVMStoragePoolManager storagePoolMgr, PrimaryDataStoreTO volumePool, String volumePath, String backupPath, int timeout, boolean createTargetVolume, Long size) { + private boolean replaceVolumeWithBackup(KVMStoragePoolManager storagePoolMgr, PrimaryDataStoreTO volumePool, String volumePath, String backupPath, int timeout, boolean createTargetVolume, Long size, File keyFile) { if (List.of(Storage.StoragePoolType.RBD, Storage.StoragePoolType.Linstor).contains(volumePool.getPoolType())) { - return replaceBlockDeviceWithBackup(storagePoolMgr, volumePool, volumePath, backupPath, timeout, createTargetVolume, size); + return replaceBlockDeviceWithBackup(storagePoolMgr, volumePool, volumePath, backupPath, timeout, createTargetVolume, size, keyFile); + } + + if (isEncryptedImage(backupPath)) { + // A plain copy would leave the volume LUKS-encrypted and unbootable: decrypt while converting. + List cmd = new ArrayList<>(List.of("qemu-img", "convert", "-O", "qcow2")); + cmd.addAll(encryptedSourceArgs(backupPath, keyFile)); + cmd.add(volumePath); + return Script.executeCommandForExitValue(timeout, cmd.toArray(new String[0])) == 0; } int exitValue = Script.runSimpleBashScriptForExitValue(String.format(RSYNC_COMMAND, backupPath, volumePath), timeout, false); return exitValue == 0; } - private boolean replaceBlockDeviceWithBackup(KVMStoragePoolManager storagePoolMgr, PrimaryDataStoreTO volumePool, String volumePath, String backupPath, int timeout, boolean createTargetVolume, Long size) { + private boolean replaceBlockDeviceWithBackup(KVMStoragePoolManager storagePoolMgr, PrimaryDataStoreTO volumePool, String volumePath, String backupPath, int timeout, boolean createTargetVolume, Long size, File keyFile) { KVMStoragePool volumeStoragePool = storagePoolMgr.getStoragePool(volumePool.getPoolType(), volumePool.getUuid()); QemuImg qemu; try { @@ -320,6 +365,21 @@ private boolean replaceBlockDeviceWithBackup(KVMStoragePoolManager storagePoolMg } destVolumeFile = new QemuImgFile(destVolume, QemuImg.PhysicalDiskFormat.RAW); logger.debug("Starting convert backup {} to volume {}", backupPath, volumePath); + if (isEncryptedImage(backupPath)) { + // QemuImg cannot pass a secret object, so build the decrypting convert directly. + List cmd = new ArrayList<>(List.of("qemu-img", "convert", "-O", "raw")); + if (!createTargetVolume) { + cmd.add("-n"); + } + cmd.addAll(encryptedSourceArgs(backupPath, keyFile)); + cmd.add(destVolume); + if (Script.executeCommandForExitValue(timeout, cmd.toArray(new String[0])) != 0) { + logger.error("Failed to convert encrypted backup {} to volume {}", backupPath, volumePath); + return false; + } + logger.debug("Successfully converted encrypted backup {} to volume {}", backupPath, volumePath); + return true; + } qemu.convert(srcBackupFile, destVolumeFile); logger.debug("Successfully converted backup {} to volume {}", backupPath, volumePath); } catch (QemuImgException | LibvirtException e) { diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtTakeBackupCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtTakeBackupCommandWrapper.java index 42953aa9f835..c6e786d7142c 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtTakeBackupCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtTakeBackupCommandWrapper.java @@ -34,14 +34,18 @@ import org.apache.cloudstack.backup.TakeBackupCommand; import org.apache.cloudstack.storage.to.PrimaryDataStoreTO; +import java.io.File; +import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Map; import java.util.Objects; @ResourceWrapper(handles = TakeBackupCommand.class) public class LibvirtTakeBackupCommandWrapper extends CommandWrapper { private static final Integer EXIT_CLEANUP_FAILED = 20; + @Override public Answer execute(TakeBackupCommand command, LibvirtComputingResource libvirtComputingResource) { final String vmName = command.getVmName(); @@ -69,20 +73,37 @@ public Answer execute(TakeBackupCommand command, LibvirtComputingResource libvir } } + List cmdArgs = new ArrayList<>(); + cmdArgs.add(libvirtComputingResource.getNasBackupPath()); + cmdArgs.add("-o"); cmdArgs.add("backup"); + cmdArgs.add("-v"); cmdArgs.add(vmName); + cmdArgs.add("-t"); cmdArgs.add(backupRepoType); + cmdArgs.add("-s"); cmdArgs.add(backupRepoAddress); + cmdArgs.add("-m"); cmdArgs.add(Objects.nonNull(mountOptions) ? mountOptions : ""); + cmdArgs.add("-p"); cmdArgs.add(backupPath); + cmdArgs.add("-q"); cmdArgs.add(command.getQuiesce() != null && command.getQuiesce() ? "true" : "false"); + cmdArgs.add("-d"); cmdArgs.add(diskPaths.isEmpty() ? "" : String.join(",", diskPaths)); + + // Append optional enhancement flags (compression / encryption / bandwidth / integrity) + File passphraseFile; + try { + passphraseFile = appendEnhancementFlags(cmdArgs, command.getDetails()); + } catch (BackupConfigException e) { + return new BackupAnswer(command, false, e.getMessage()); + } + List commands = new ArrayList<>(); - commands.add(new String[]{ - libvirtComputingResource.getNasBackupPath(), - "-o", "backup", - "-v", vmName, - "-t", backupRepoType, - "-s", backupRepoAddress, - "-m", Objects.nonNull(mountOptions) ? mountOptions : "", - "-p", backupPath, - "-q", command.getQuiesce() != null && command.getQuiesce() ? "true" : "false", - "-d", diskPaths.isEmpty() ? "" : String.join(",", diskPaths) - }); - - Pair result = Script.executePipedCommands(commands, timeout); + commands.add(cmdArgs.toArray(new String[0])); + + Pair result; + try { + result = Script.executePipedCommands(commands, timeout); + } finally { + // Clean up passphrase file after backup completes (best-effort). + if (passphraseFile != null && passphraseFile.exists()) { + passphraseFile.delete(); + } + } if (result.first() != 0) { logger.debug("Failed to take VM backup: " + result.second()); @@ -111,4 +132,56 @@ public Answer execute(TakeBackupCommand command, LibvirtComputingResource libvir answer.setSize(backupSize); return answer; } + + /** + * Translates the optional backup-enhancement details (compression, encryption, + * bandwidth limit, integrity check) into nasbackup.sh CLI flags appended to + * {@code cmdArgs}. Returns the temporary passphrase file when encryption is + * enabled (the caller deletes it after the backup), or {@code null} otherwise. + */ + File appendEnhancementFlags(List cmdArgs, Map details) throws BackupConfigException { + if (details == null) { + return null; + } + if ("true".equals(details.get(TakeBackupCommand.DETAIL_COMPRESSION))) { + cmdArgs.add("-c"); + } + File passphraseFile = null; + if ("true".equals(details.get(TakeBackupCommand.DETAIL_ENCRYPTION))) { + String passphrase = details.get(TakeBackupCommand.DETAIL_ENCRYPTION_PASSPHRASE); + if (passphrase == null || passphrase.isEmpty()) { + throw new BackupConfigException("Encryption is enabled but no passphrase was provided"); + } + passphraseFile = writePassphraseFile(passphrase); + cmdArgs.add("-e"); cmdArgs.add(passphraseFile.getAbsolutePath()); + } + String bwLimit = details.get(TakeBackupCommand.DETAIL_BANDWIDTH_LIMIT); + if (bwLimit != null && !"0".equals(bwLimit)) { + cmdArgs.add("-b"); cmdArgs.add(bwLimit); + } + if ("true".equals(details.get(TakeBackupCommand.DETAIL_INTEGRITY_CHECK))) { + cmdArgs.add("--verify"); + } + return passphraseFile; + } + + /** + * Writes {@code passphrase} to a 0600 UTF-8 temp file for nasbackup.sh's {@code -e} + * flag. Scheduled for deletion on JVM exit; the caller removes it after the backup. + */ + private File writePassphraseFile(String passphrase) throws BackupConfigException { + try { + return NasBackupPassphraseFile.write(passphrase); + } catch (IOException e) { + logger.error("Failed to create encryption passphrase file", e); + throw new BackupConfigException("Failed to create encryption passphrase file: " + e.getMessage()); + } + } + + /** Signals an invalid or unsatisfiable backup-enhancement configuration. */ + static class BackupConfigException extends Exception { + BackupConfigException(String message) { + super(message); + } + } } diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/NasBackupPassphraseFile.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/NasBackupPassphraseFile.java new file mode 100644 index 000000000000..21f6f13893d2 --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/NasBackupPassphraseFile.java @@ -0,0 +1,72 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package com.cloud.hypervisor.kvm.resource.wrapper; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.attribute.PosixFilePermission; +import java.util.EnumSet; + +/** + * Temporary 0600 key file that hands the NAS backup LUKS passphrase to nasbackup.sh ({@code -e}) and to + * qemu-img ({@code --object secret,file=...}) without ever putting it on a command line or in a log. + * Shared by the take and restore wrappers; the caller deletes it as soon as the command has finished. + */ +final class NasBackupPassphraseFile { + + private static final Logger LOGGER = LogManager.getLogger(NasBackupPassphraseFile.class); + + private NasBackupPassphraseFile() { + } + + /** Writes {@code passphrase} to a fresh owner-only temp file, or returns {@code null} when there is no passphrase. */ + static File write(String passphrase) throws IOException { + if (passphrase == null || passphrase.isEmpty()) { + return null; + } + File passphraseFile = null; + try { + passphraseFile = File.createTempFile("cs-backup-enc-", ".key"); + passphraseFile.deleteOnExit(); + Files.setPosixFilePermissions(passphraseFile.toPath(), + EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE)); + try (Writer writer = new OutputStreamWriter(new FileOutputStream(passphraseFile), StandardCharsets.UTF_8)) { + writer.write(passphrase); + } + return passphraseFile; + } catch (IOException e) { + delete(passphraseFile); + throw e; + } + } + + /** Best-effort removal; safe to call with {@code null}. */ + static void delete(File passphraseFile) { + if (passphraseFile != null && passphraseFile.exists() && !passphraseFile.delete()) { + LOGGER.warn("Could not delete temporary backup passphrase file {}", passphraseFile.getAbsolutePath()); + } + } +} diff --git a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRestoreBackupCommandWrapperTest.java b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRestoreBackupCommandWrapperTest.java index ef6b5c08189d..ee8fb891e29e 100644 --- a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRestoreBackupCommandWrapperTest.java +++ b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRestoreBackupCommandWrapperTest.java @@ -36,9 +36,12 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.Arrays; +import java.util.List; +import java.util.ArrayList; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mockStatic; @@ -567,4 +570,95 @@ public void testExecuteWithMultipleVolumes() throws Exception { } } } + + /** Mockito hands varargs to the answer as individual arguments; rebuild the command line from {@code from}. */ + private static String joinArgs(Object[] args, int from) { + StringBuilder sb = new StringBuilder(); + for (int i = from; i < args.length; i++) { + if (args[i] instanceof String[]) { + sb.append(String.join(" ", (String[]) args[i])); + } else { + sb.append(args[i]); + } + if (i < args.length - 1) { + sb.append(' '); + } + } + return sb.toString(); + } + + private void stubEncryptedNfsRestore(String passphrase) { + when(command.getVmName()).thenReturn("test-vm"); + when(command.getBackupPath()).thenReturn("backup/path"); + when(command.getBackupRepoAddress()).thenReturn("192.168.1.100:/backup"); + when(command.getBackupRepoType()).thenReturn("nfs"); + when(command.getMountOptions()).thenReturn("rw"); + when(command.isVmExists()).thenReturn(false); + when(command.getWait()).thenReturn(60); + when(command.getEncryptionPassphrase()).thenReturn(passphrase); + PrimaryDataStoreTO primaryDataStore = Mockito.mock(PrimaryDataStoreTO.class); + when(primaryDataStore.getPoolType()).thenReturn(Storage.StoragePoolType.NetworkFilesystem); + when(command.getRestoreVolumePools()).thenReturn(Arrays.asList(primaryDataStore)); + when(command.getRestoreVolumePaths()).thenReturn(Arrays.asList("/var/lib/libvirt/images/volume-123")); + when(command.getBackupFiles()).thenReturn(Arrays.asList("volume-123")); + when(command.getMountTimeout()).thenReturn(30); + } + + @Test + public void testEncryptedBackupIsCheckedAndDecryptedWithTheSecret() throws Exception { + stubEncryptedNfsRestore("s3cret"); + List qemuImgCalls = new ArrayList<>(); + + try (MockedStatic filesMock = mockStatic(Files.class)) { + Path tempPath = Mockito.mock(Path.class); + when(tempPath.toString()).thenReturn("/tmp/csbackup.abc123"); + filesMock.when(() -> Files.createTempDirectory(anyString())).thenReturn(tempPath); + filesMock.when(() -> Files.deleteIfExists(any(Path.class))).thenReturn(true); + + try (MockedStatic