This is an automated email from the ASF dual-hosted git repository.
abh1sar pushed a commit to branch 4.22
in repository https://gitbox.apache.org/repos/asf/cloudstack.git
The following commit(s) were added to refs/heads/4.22 by this push:
new 08831859497 Nasbackup quiesce fixes (#14305)
08831859497 is described below
commit 088318594973ce2283a3f2171d6ce207899515fb
Author: MitchDrage <[email protected]>
AuthorDate: Mon Oct 12 21:55:00 2026 +1100
Nasbackup quiesce fixes (#14305)
* NAS backup: keep the schedule id on a failed backup left in Error state
* NAS backup: add nas.backup.quiesce.agent.timeout for guest agent freeze
and thaw
* nasbackup.sh: abort the backup job before cleanup removes its destination
* nasbackup.sh: always thaw after a freeze attempt and log freeze failures
* nasbackup.sh: confirm a failed thaw against guest-fsfreeze-status before
continuing
* nasbackup.sh: only end the abort wait on a successful domjobinfo query
* nasbackup.sh: retry failed job queries in the backup polling loop
* NAS backup: count a backup kept in Error state in the account resource
count
* NAS backup: alert when cleanup fails after a failed backup
---
.../cloudstack/backup/TakeBackupCommand.java | 9 ++
.../cloudstack/backup/NASBackupProvider.java | 37 ++++++-
.../cloudstack/backup/NASBackupProviderTest.java | 79 +++++++++++--
.../wrapper/LibvirtTakeBackupCommandWrapper.java | 8 +-
.../LibvirtTakeBackupCommandWrapperTest.java | 102 +++++++++++++++++
scripts/vm/hypervisor/kvm/nasbackup.sh | 122 +++++++++++++++++++--
.../cloudstack/backup/BackupManagerImpl.java | 30 +++--
.../cloudstack/backup/BackupManagerTest.java | 55 ++++++++++
8 files changed, 406 insertions(+), 36 deletions(-)
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 34f8d7b8bcd..7c2d3fc76fc 100644
--- a/core/src/main/java/org/apache/cloudstack/backup/TakeBackupCommand.java
+++ b/core/src/main/java/org/apache/cloudstack/backup/TakeBackupCommand.java
@@ -33,6 +33,7 @@ public class TakeBackupCommand extends Command {
private List<PrimaryDataStoreTO> volumePools;
private List<String> volumePaths;
private Boolean quiesce;
+ private Integer quiesceTimeout;
@LogLevel(LogLevel.Log4jLevel.Off)
private String mountOptions;
@@ -117,6 +118,14 @@ public class TakeBackupCommand extends Command {
this.quiesce = quiesce;
}
+ public Integer getQuiesceTimeout() {
+ return quiesceTimeout;
+ }
+
+ public void setQuiesceTimeout(Integer quiesceTimeout) {
+ this.quiesceTimeout = quiesceTimeout;
+ }
+
public String getMode() {
return mode;
}
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 08c54100bc2..a72920fbae8 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
@@ -17,6 +17,7 @@
package org.apache.cloudstack.backup;
import com.cloud.agent.AgentManager;
+import com.cloud.alert.AlertManager;
import com.cloud.exception.AgentUnavailableException;
import com.cloud.exception.OperationTimedoutException;
import com.cloud.configuration.Resource;
@@ -117,6 +118,15 @@ public class NASBackupProvider extends AdapterBase
implements BackupProvider, Co
ConfigKey.Scope.Zone,
NASBackupIncrementalEnabled.key());
+ ConfigKey<Integer> NASBackupQuiesceAgentTimeout = new
ConfigKey<>("Advanced", Integer.class,
+ "nas.backup.quiesce.agent.timeout",
+ "30",
+ "Timeout in seconds for the guest agent filesystem freeze and thaw
commands of a quiesced NAS backup. " +
+ "Set to 0 to use the libvirt default (5 seconds unless
changed on the host).",
+ true,
+ ConfigKey.Scope.Zone,
+ BackupFrameworkEnabled.key());
+
@Inject
private BackupDao backupDao;
@@ -171,6 +181,9 @@ public class NASBackupProvider extends AdapterBase
implements BackupProvider, Co
@Inject
private BackupDetailsDao backupDetailsDao;
+ @Inject
+ private AlertManager alertManager;
+
private Long getClusterIdFromRootVolume(VirtualMachine vm) {
VolumeVO rootVolume = volumeDao.getInstanceRootVolume(vm.getId());
StoragePoolVO rootDiskPool =
primaryDataStoreDao.findById(rootVolume.getPoolId());
@@ -578,6 +591,7 @@ public class NASBackupProvider extends AdapterBase
implements BackupProvider, Co
command.setBackupRepoAddress(backupRepository.getAddress());
command.setMountOptions(backupRepository.getMountOptions());
command.setQuiesce(quiesceVM);
+
command.setQuiesceTimeout(NASBackupQuiesceAgentTimeout.valueIn(vm.getDataCenterId()));
command.setMode(decision.mode);
command.setBitmapNew(decision.bitmapNew);
command.setBitmapParent(decision.bitmapParent);
@@ -643,16 +657,30 @@ public class NASBackupProvider extends AdapterBase
implements BackupProvider, Co
logger.error("Failed to take backup for VM {}: {}",
vm.getInstanceName(), answer != null ? answer.getDetails() : "No answer
received");
if (answer != null && answer.getNeedsCleanup()) {
logger.error("Backup cleanup failed for VM {}. Leaving the
backup in Error state. Backup should be manually deleted to free up the space",
vm.getInstanceName());
+ sendBackupCleanupFailedAlert(vm, host, answer.getDetails());
backupVO.setStatus(Backup.Status.Error);
backupDao.update(backupVO.getId(), backupVO);
- } else {
- backupVO.setStatus(Backup.Status.Failed);
- backupDao.remove(backupVO.getId());
+ // Return the row to the caller to record its schedule - e.g.
Scheduled or Manual.
+ return new Pair<>(false, backupVO);
}
+ backupVO.setStatus(Backup.Status.Failed);
+ backupDao.remove(backupVO.getId());
return new Pair<>(false, null);
}
}
+ /**
+ * Alerts when nasbackup.sh could not clean up after a failed backup. The
script's output
+ * names the backup repository mount point it left on the host.
+ */
+ private void sendBackupCleanupFailedAlert(VirtualMachine vm, Host host,
String details) {
+ String subject = String.format("NAS backup cleanup failed for VM %s on
host %s", vm.getInstanceName(), host.getName());
+ String body = String.format("A failed backup of VM %s could not be
cleaned up on host %s (%s). " +
+ "The backup repository may still be mounted on that host.
Script output: %s",
+ vm.getInstanceName(), host.getName(),
host.getPrivateIpAddress(), details);
+
alertManager.sendAlert(AlertManager.AlertType.ALERT_TYPE_BACKUP_STORAGE,
vm.getDataCenterId(), host.getPodId(), subject, body);
+ }
+
private BackupVO createBackupObject(VirtualMachine vm, String backupPath,
String type) {
BackupVO backup = new BackupVO();
backup.setVmId(vm.getId());
@@ -1254,7 +1282,8 @@ public class NASBackupProvider extends AdapterBase
implements BackupProvider, Co
return new ConfigKey[]{
NASBackupRestoreMountTimeout,
NASBackupFullEvery,
- NASBackupIncrementalEnabled
+ NASBackupIncrementalEnabled,
+ NASBackupQuiesceAgentTimeout
};
}
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 7c5edba1e2b..787d3ff9456 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
@@ -36,6 +36,7 @@ import org.mockito.junit.MockitoJUnitRunner;
import org.springframework.test.util.ReflectionTestUtils;
import com.cloud.agent.AgentManager;
+import com.cloud.alert.AlertManager;
import com.cloud.exception.AgentUnavailableException;
import com.cloud.exception.OperationTimedoutException;
import com.cloud.configuration.Resource;
@@ -125,6 +126,9 @@ public class NASBackupProviderTest {
@Mock
private DataStoreManager dataStoreMgr;
+ @Mock
+ private AlertManager alertManager;
+
@Test
public void testDeleteBackup() throws OperationTimedoutException,
AgentUnavailableException {
Long hostId = 1L;
@@ -202,16 +206,7 @@ public class NASBackupProviderTest {
Assert.assertEquals(Long.valueOf(3000L), result.second());
}
- @Test
- public void takeBackupSuccessfully() throws AgentUnavailableException,
OperationTimedoutException {
- Long vmId = 1L;
- Long hostId = 2L;
- Long backupOfferingId = 3L;
- Long accountId = 4L;
- Long domainId = 5L;
- Long zoneId = 6L;
- Long backupId = 7L;
-
+ private VMInstanceVO mockRunningVmForBackup(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);
@@ -241,6 +236,16 @@ public class NASBackupProviderTest {
Mockito.when(volume2.getState()).thenReturn(Volume.State.Ready);
Mockito.when(volume2.getSize()).thenReturn(200L);
Mockito.when(volumeDao.findByInstance(vmId)).thenReturn(List.of(volume1,
volume2));
+ return vm;
+ }
+
+ @Test
+ public void takeBackupSuccessfully() throws AgentUnavailableException,
OperationTimedoutException {
+ Long backupOfferingId = 3L;
+ Long accountId = 4L;
+ Long domainId = 5L;
+ Long zoneId = 6L;
+ VMInstanceVO vm = mockRunningVmForBackup(1L, 2L, backupOfferingId,
accountId, domainId, zoneId);
BackupAnswer answer = mock(BackupAnswer.class);
Mockito.when(answer.getResult()).thenReturn(true);
@@ -269,6 +274,60 @@ public class NASBackupProviderTest {
Mockito.verify(agentManager).send(anyLong(),
Mockito.any(TakeBackupCommand.class));
}
+ @Test
+ public void takeBackupFailureNeedingCleanupReturnsErrorBackup() throws
AgentUnavailableException, OperationTimedoutException {
+ VMInstanceVO vm = mockRunningVmForBackup(1L, 2L, 3L, 4L, 5L, 6L);
+ HostVO host = hostDao.findById(2L);
+ Mockito.when(host.getName()).thenReturn("kvm01");
+ Mockito.when(host.getPodId()).thenReturn(7L);
+
+ BackupAnswer answer = mock(BackupAnswer.class);
+ Mockito.when(answer.getResult()).thenReturn(false);
+ Mockito.when(answer.getNeedsCleanup()).thenReturn(true);
+ Mockito.when(answer.getDetails()).thenReturn("leaving
/tmp/csbackup.abcde/i-2-3-VM mounted at /tmp/csbackup.abcde");
+ ArgumentCaptor<TakeBackupCommand> commandCaptor =
ArgumentCaptor.forClass(TakeBackupCommand.class);
+ Mockito.when(agentManager.send(anyLong(),
commandCaptor.capture())).thenReturn(answer);
+
+
Mockito.when(backupDao.persist(Mockito.any(BackupVO.class))).thenAnswer(invocation
-> invocation.getArgument(0));
+
+ Pair<Boolean, Backup> result = nasBackupProvider.takeBackup(vm, true);
+
+ Assert.assertFalse(result.first());
+ Assert.assertNotNull(result.second());
+ Assert.assertEquals(Backup.Status.Error, result.second().getStatus());
+ Mockito.verify(backupDao, Mockito.never()).remove(Mockito.anyLong());
+
+ TakeBackupCommand command = commandCaptor.getValue();
+ Assert.assertTrue(command.getQuiesce());
+ Assert.assertEquals(Integer.valueOf(30), command.getQuiesceTimeout());
+
+ ArgumentCaptor<String> subjectCaptor =
ArgumentCaptor.forClass(String.class);
+ ArgumentCaptor<String> bodyCaptor =
ArgumentCaptor.forClass(String.class);
+
Mockito.verify(alertManager).sendAlert(Mockito.eq(AlertManager.AlertType.ALERT_TYPE_BACKUP_STORAGE),
Mockito.eq(6L), Mockito.eq(7L),
+ subjectCaptor.capture(), bodyCaptor.capture());
+ Assert.assertTrue(subjectCaptor.getValue().contains("kvm01"));
+
Assert.assertTrue(bodyCaptor.getValue().contains("/tmp/csbackup.abcde"));
+ }
+
+ @Test
+ public void takeBackupFailureWithoutCleanupRemovesBackup() throws
AgentUnavailableException, OperationTimedoutException {
+ VMInstanceVO vm = mockRunningVmForBackup(1L, 2L, 3L, 4L, 5L, 6L);
+
+ BackupAnswer answer = mock(BackupAnswer.class);
+ Mockito.when(answer.getResult()).thenReturn(false);
+ Mockito.when(answer.getNeedsCleanup()).thenReturn(false);
+ Mockito.when(agentManager.send(anyLong(),
Mockito.any(TakeBackupCommand.class))).thenReturn(answer);
+
+
Mockito.when(backupDao.persist(Mockito.any(BackupVO.class))).thenAnswer(invocation
-> invocation.getArgument(0));
+
+ Pair<Boolean, Backup> result = nasBackupProvider.takeBackup(vm, false);
+
+ Assert.assertFalse(result.first());
+ Assert.assertNull(result.second());
+ Mockito.verify(backupDao).remove(Mockito.anyLong());
+ Mockito.verifyNoInteractions(alertManager);
+ }
+
@Test
public void testGetVMHypervisorHost() {
Long hostId = 1L;
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 e76c9a2a387..b7e60958765 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
@@ -143,6 +143,7 @@ public class LibvirtTakeBackupCommandWrapper extends
CommandWrapper<TakeBackupCo
TakeBackupCommand command, String vmName, String backupRepoType,
String backupRepoAddress,
String mountOptions, String backupPath, List<String> diskPaths,
String mode,
String bitmapNew, String bitmapParent, List<String> parentPaths,
int timeout) {
+ boolean quiesce = command.getQuiesce() != null && command.getQuiesce();
List<String> argv = new ArrayList<>(Arrays.asList(
libvirtComputingResource.getNasBackupPath(),
"-o", "backup",
@@ -151,9 +152,14 @@ public class LibvirtTakeBackupCommandWrapper extends
CommandWrapper<TakeBackupCo
"-s", backupRepoAddress,
"-m", Objects.nonNull(mountOptions) ? mountOptions : "",
"-p", backupPath,
- "-q", command.getQuiesce() != null && command.getQuiesce() ?
"true" : "false",
+ "-q", quiesce ? "true" : "false",
"-d", CollectionUtils.isEmpty(diskPaths) ? "" :
String.join(",", diskPaths)
));
+ Integer quiesceTimeout = command.getQuiesceTimeout();
+ if (quiesce && quiesceTimeout != null && quiesceTimeout > 0) {
+ argv.add("--quiesce-timeout");
+ argv.add(String.valueOf(quiesceTimeout));
+ }
if (StringUtils.isNotBlank(mode)) {
argv.add("-M");
argv.add(mode);
diff --git
a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtTakeBackupCommandWrapperTest.java
b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtTakeBackupCommandWrapperTest.java
new file mode 100644
index 00000000000..7ef64fd53b5
--- /dev/null
+++
b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtTakeBackupCommandWrapperTest.java
@@ -0,0 +1,102 @@
+// 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 static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.when;
+
+import java.util.Arrays;
+import java.util.List;
+
+import org.apache.cloudstack.backup.BackupAnswer;
+import org.apache.cloudstack.backup.TakeBackupCommand;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+import org.mockito.junit.MockitoJUnitRunner;
+
+import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource;
+import com.cloud.utils.Pair;
+import com.cloud.utils.script.Script;
+
+@RunWith(MockitoJUnitRunner.class)
+public class LibvirtTakeBackupCommandWrapperTest {
+
+ private LibvirtTakeBackupCommandWrapper wrapper;
+ private LibvirtComputingResource libvirtComputingResource;
+ private TakeBackupCommand command;
+
+ @Before
+ public void setUp() {
+ wrapper = new LibvirtTakeBackupCommandWrapper();
+ libvirtComputingResource =
Mockito.mock(LibvirtComputingResource.class);
+
when(libvirtComputingResource.getNasBackupPath()).thenReturn("nasbackup.sh");
+ command = new TakeBackupCommand("i-2-3-VM",
"i-2-3-VM/2026.10.04.00.00.00");
+ command.setBackupRepoType("nfs");
+ command.setBackupRepoAddress("10.0.0.1:/backup");
+ command.setWait(60);
+ }
+
+ @SuppressWarnings("unchecked")
+ private List<String> runAndCaptureArgv() {
+ try (MockedStatic<Script> scriptMock = mockStatic(Script.class)) {
+ scriptMock.when(() -> Script.executePipedCommands(anyList(),
anyLong())).thenReturn(new Pair<>(0, "1024"));
+
+ BackupAnswer answer = (BackupAnswer) wrapper.execute(command,
libvirtComputingResource);
+ Assert.assertTrue(answer.getResult());
+
+ ArgumentCaptor<List<String[]>> captor =
ArgumentCaptor.forClass(List.class);
+ scriptMock.verify(() ->
Script.executePipedCommands(captor.capture(), anyLong()));
+ return Arrays.asList(captor.getValue().get(0));
+ }
+ }
+
+ @Test
+ public void executePassesQuiesceTimeoutWhenQuiescing() {
+ command.setQuiesce(true);
+ command.setQuiesceTimeout(30);
+
+ List<String> argv = runAndCaptureArgv();
+
+ int idx = argv.indexOf("--quiesce-timeout");
+ Assert.assertTrue(idx > 0);
+ Assert.assertEquals("30", argv.get(idx + 1));
+ Assert.assertEquals("true", argv.get(argv.indexOf("-q") + 1));
+ }
+
+ @Test
+ public void executeOmitsQuiesceTimeoutWithoutQuiesce() {
+ command.setQuiesce(false);
+ command.setQuiesceTimeout(30);
+
+ Assert.assertFalse(runAndCaptureArgv().contains("--quiesce-timeout"));
+ }
+
+ @Test
+ public void executeOmitsQuiesceTimeoutWhenNotPositive() {
+ command.setQuiesce(true);
+ command.setQuiesceTimeout(0);
+
+ Assert.assertFalse(runAndCaptureArgv().contains("--quiesce-timeout"));
+ }
+}
diff --git a/scripts/vm/hypervisor/kvm/nasbackup.sh
b/scripts/vm/hypervisor/kvm/nasbackup.sh
index ee044459a1b..04cc7cd86f2 100755
--- a/scripts/vm/hypervisor/kvm/nasbackup.sh
+++ b/scripts/vm/hypervisor/kvm/nasbackup.sh
@@ -32,6 +32,7 @@ MOUNT_OPTS=""
BACKUP_DIR=""
DISK_PATHS=""
QUIESCE=""
+QUIESCE_TIMEOUT="" # Seconds to wait for the guest agent freeze/thaw; empty
=> libvirt default
# Incremental backup parameters (all optional; legacy callers omit them)
MODE="" # "full" or "incremental"; empty => legacy full-only
behavior (no checkpoint created)
BITMAP_NEW="" # Bitmap/checkpoint name to create with this backup
(e.g. "backup-1711586400")
@@ -44,6 +45,7 @@ PARENT_PATHS="" # For incremental: comma-separated list
of parent backup f
logFile="/var/log/cloudstack/agent/agent.log"
EXIT_CLEANUP_FAILED=20
+BACKUP_JOB_ACTIVE=0
log() {
[[ "$verb" -eq 1 ]] && builtin echo "$@"
@@ -134,6 +136,53 @@ get_linstor_uuid_from_device() {
return 1
}
+qemu_agent_command() {
+ local timeout_args=()
+ if [[ -n "$QUIESCE_TIMEOUT" ]]; then
+ timeout_args=(--timeout "$QUIESCE_TIMEOUT")
+ fi
+ virsh -c qemu:///system qemu-agent-command "$VM" "${timeout_args[@]}" "$1"
+}
+
+guest_fsfreeze_status() {
+ local response
+ response=$(qemu_agent_command '{"execute":"guest-fsfreeze-status"}'
2>>"$logFile") || return 1
+ grep -oE 'thawed|frozen' <<< "$response" | head -n 1
+}
+
+guest_agent_connected() {
+ local xml
+ xml=$(virsh -c qemu:///system dumpxml "$VM" 2>/dev/null) || return 1
+ grep -q "name='org.qemu.guest_agent.0' state='connected'" <<< "$xml"
+}
+
+# Thaws the guest filesystems. A thaw that times out on the host can still
complete in the
+# guest, so each failure is checked against guest-fsfreeze-status before
retrying. Succeeds
+# once the guest reports thawed, or when the freeze failed and the guest agent
is not
+# connected, which means the freeze never reached the guest.
+thaw_guest() {
+ local freeze_ok=$1
+ local attempt response state
+ for ((attempt = 1; attempt <= 3; attempt++)); do
+ if response=$(qemu_agent_command '{"execute":"guest-fsfreeze-thaw"}'
2>&1); then
+ return 0
+ fi
+ log -e "Thaw attempt $attempt for vm $VM failed: $response"
+ state=$(guest_fsfreeze_status || true)
+ if [[ "$state" == "thawed" ]]; then
+ log -e "Guest filesystem of vm $VM reports thawed after the failed thaw"
+ return 0
+ fi
+ if [[ -z "$state" && $freeze_ok -eq 0 ]] && ! guest_agent_connected; then
+ log -e "Guest agent of vm $VM is not connected, so the failed freeze
never reached the guest"
+ return 0
+ fi
+ sleep 5
+ done
+ echo "Failed to thaw the filesystem for vm $VM, guest filesystem state:
${state:-unknown}: $response"
+ return 1
+}
+
backup_running_vm() {
mount_operation
mkdir -p "$dest" || { echo "Failed to create backup directory $dest"; exit
1; }
@@ -257,9 +306,15 @@ print(len(files))
fi
local thaw=0
+ local freeze_ok=0
if [[ ${QUIESCE} == "true" ]]; then
- if virsh -c qemu:///system qemu-agent-command "$VM"
'{"execute":"guest-fsfreeze-freeze"}' > /dev/null 2>/dev/null; then
- thaw=1
+ # Always thaw after a freeze attempt: a freeze that times out on the host
can still
+ # complete inside the guest, and nothing else would unfreeze it.
+ thaw=1
+ if freeze_err=$(qemu_agent_command '{"execute":"guest-fsfreeze-freeze"}'
2>&1 > /dev/null); then
+ freeze_ok=1
+ else
+ log -e "Failed to freeze the filesystem for vm $VM, continuing without
quiescing: $freeze_err"
fi
fi
@@ -276,13 +331,13 @@ print(len(files))
backup_begin=1;
fi
fi
+ if [[ $backup_begin -eq 1 ]]; then
+ BACKUP_JOB_ACTIVE=1
+ fi
- if [[ $thaw -eq 1 ]]; then
- if ! response=$(virsh -c qemu:///system qemu-agent-command "$VM"
'{"execute":"guest-fsfreeze-thaw"}' 2>&1); then
- echo "Failed to thaw the filesystem for vm $VM: $response"
- cleanup
- exit 1
- fi
+ if [[ $thaw -eq 1 ]] && ! thaw_guest "$freeze_ok"; then
+ cleanup
+ exit 1
fi
if [[ $backup_begin -ne 1 ]]; then
@@ -296,14 +351,30 @@ print(len(files))
virsh -c qemu:///system domiflist $VM > $dest/domiflist.xml 2>/dev/null
virsh -c qemu:///system domblklist $VM > $dest/domblklist.xml 2>/dev/null
+ local info query_failures=0
while true; do
- status=$(virsh -c qemu:///system domjobinfo $VM --completed
--keep-completed | awk '/Job type:/ {print $3}')
+ # A failed query says nothing about the job, so retry before giving up on
it.
+ if ! info=$(virsh -c qemu:///system domjobinfo $VM --completed
--keep-completed 2>&1); then
+ log -e "Backup job query $((query_failures + 1)) for vm $VM failed:
$info"
+ if (( ++query_failures >= 12 )); then
+ echo "Unable to query the backup job for vm $VM"
+ cleanup
+ exit 1
+ fi
+ sleep 5
+ continue
+ fi
+ query_failures=0
+ status=$(awk '/Job type:/ {print $3}' <<< "$info")
case "$status" in
Completed)
+ BACKUP_JOB_ACTIVE=0
break ;;
Failed)
+ BACKUP_JOB_ACTIVE=0
echo "Virsh backup job failed"
- cleanup ;;
+ cleanup
+ exit 1 ;;
esac
sleep 5
done
@@ -503,9 +574,34 @@ mount_operation() {
fi
}
+abort_backup_job() {
+ [[ $BACKUP_JOB_ACTIVE -eq 1 ]] || return 0
+
+ # The push backup job writes into $dest until it ends, so it must be gone
before the
+ # destination is removed or unmounted.
+ virsh -c qemu:///system domjobabort "$VM" > /dev/null 2>>"$logFile" || true
+ local i info
+ for ((i = 0; i < 60; i++)); do
+ # Only a successful query reporting no job proves the job has ended.
+ if info=$(virsh -c qemu:///system domjobinfo "$VM" 2>>"$logFile") \
+ && [[ "$(awk '/Job type:/ {print $3}' <<< "$info")" == "None" ]]; then
+ BACKUP_JOB_ACTIVE=0
+ return 0
+ fi
+ sleep 1
+ done
+ return 1
+}
+
cleanup() {
local status=0
+ if ! abort_backup_job; then
+ echo "Backup job for vm $VM is still running after abort, leaving $dest
mounted at $mount_point"
+ echo "Backup cleanup failed"
+ exit $EXIT_CLEANUP_FAILED
+ fi
+
rm -rf "$dest" || { echo "Failed to delete $dest"; status=1; }
umount "$mount_point" || { echo "Failed to unmount $mount_point"; status=1; }
rmdir "$mount_point" || { echo "Failed to remove mount point $mount_point";
status=1; }
@@ -519,6 +615,7 @@ cleanup() {
function usage {
echo ""
echo "Usage: $0 -o <operation> -v|--vm <domain name> -t <storage type> -s
<storage address> -m <mount options> -p <backup path> -d <disks path>
-q|--quiesce <true|false>"
+ echo " [--quiesce-timeout <seconds>]"
echo " [-M|--mode <full|incremental>] [--bitmap-new <name>]
[--bitmap-parent <name>] [--parent-paths <p1,p2,...>]"
echo ""
echo "Incremental backup options (running VMs only; requires QEMU >= 4.2 and
libvirt >= 7.2):"
@@ -569,6 +666,11 @@ while [[ $# -gt 0 ]]; do
shift
shift
;;
+ --quiesce-timeout)
+ QUIESCE_TIMEOUT="$2"
+ shift
+ shift
+ ;;
-d|--diskpaths)
DISK_PATHS="$2"
shift
diff --git
a/server/src/main/java/org/apache/cloudstack/backup/BackupManagerImpl.java
b/server/src/main/java/org/apache/cloudstack/backup/BackupManagerImpl.java
index 867f4111b66..1f42bc413eb 100644
--- a/server/src/main/java/org/apache/cloudstack/backup/BackupManagerImpl.java
+++ b/server/src/main/java/org/apache/cloudstack/backup/BackupManagerImpl.java
@@ -826,20 +826,18 @@ public class BackupManagerImpl extends ManagerBase
implements BackupManager {
true, 0);
Pair<Boolean, Backup> result = backupProvider.takeBackup(vm,
cmd.getQuiesceVM());
- if (!result.first()) {
- throw new CloudRuntimeException("Failed to create VM backup");
- }
Backup backup = result.second();
if (backup != null) {
- BackupVO vmBackup =
backupDao.findById(result.second().getId());
- vmBackup.setBackupScheduleId(backupScheduleId);
- if (cmd.getName() != null) {
- vmBackup.setName(cmd.getName());
- }
- vmBackup.setDescription(cmd.getDescription());
- backupDao.update(vmBackup.getId(), vmBackup);
+ updateBackupFromCmd(backup.getId(), cmd, backupScheduleId);
+ // A failed backup returned here is kept in Error state and
still holds space on
+ // the repository. The account's backup resource count
recalculation includes such
+ // rows and deleting one decrements it, so add it to that
count here as well.
resourceLimitMgr.incrementResourceCount(vm.getAccountId(),
Resource.ResourceType.backup);
- resourceLimitMgr.incrementResourceCount(vm.getAccountId(),
Resource.ResourceType.backup_storage, backup.getSize());
+ resourceLimitMgr.incrementResourceCount(vm.getAccountId(),
Resource.ResourceType.backup_storage,
+ backup.getSize() != null ? backup.getSize() : 0L);
+ }
+ if (!result.first()) {
+ throw new CloudRuntimeException("Failed to create VM backup");
}
} catch (ResourceAllocationException e) {
if (isScheduledBackup &&
(Resource.ResourceType.backup.equals(e.getResourceType()) ||
@@ -850,6 +848,16 @@ public class BackupManagerImpl extends ManagerBase
implements BackupManager {
}
}
+ private void updateBackupFromCmd(long backupId, CreateBackupCmd cmd, Long
backupScheduleId) {
+ BackupVO vmBackup = backupDao.findById(backupId);
+ vmBackup.setBackupScheduleId(backupScheduleId);
+ if (cmd.getName() != null) {
+ vmBackup.setName(cmd.getName());
+ }
+ vmBackup.setDescription(cmd.getDescription());
+ backupDao.update(vmBackup.getId(), vmBackup);
+ }
+
/**
* Sends an alert when the backup limit has been exceeded for a given
account.
*
diff --git
a/server/src/test/java/org/apache/cloudstack/backup/BackupManagerTest.java
b/server/src/test/java/org/apache/cloudstack/backup/BackupManagerTest.java
index db75602b600..67e263a3267 100644
--- a/server/src/test/java/org/apache/cloudstack/backup/BackupManagerTest.java
+++ b/server/src/test/java/org/apache/cloudstack/backup/BackupManagerTest.java
@@ -733,6 +733,61 @@ public class BackupManagerTest {
}
}
+ @Test
+ public void createBackupTestFailedScheduledBackupKeepsSchedule() throws
ResourceAllocationException {
+ Long vmId = 1L;
+ Long zoneId = 2L;
+ Long scheduleId = 3L;
+ Long backupOfferingId = 4L;
+ Long accountId = 5L;
+ Long backupId = 6L;
+ long domainId = 101L;
+
+ when(vmInstanceDao.findById(vmId)).thenReturn(vmInstanceVOMock);
+ when(vmInstanceVOMock.getDataCenterId()).thenReturn(zoneId);
+
when(vmInstanceVOMock.getBackupOfferingId()).thenReturn(backupOfferingId);
+ when(vmInstanceVOMock.getAccountId()).thenReturn(accountId);
+
+ overrideBackupFrameworkConfigValue();
+
when(backupOfferingDao.findById(backupOfferingId)).thenReturn(backupOfferingVOMock);
+
when(backupOfferingVOMock.isUserDrivenBackupAllowed()).thenReturn(true);
+
when(backupOfferingVOMock.getProvider()).thenReturn("testbackupprovider");
+
+
Mockito.doReturn(scheduleId).when(backupManager).getBackupScheduleId(asyncJobVOMock);
+
+ when(accountManager.getAccount(accountId)).thenReturn(accountVOMock);
+ when(accountVOMock.getDomainId()).thenReturn(domainId);
+
+ when(volumeDao.findByInstance(vmId)).thenReturn(List.of());
+
+ BackupProvider backupProvider = mock(BackupProvider.class);
+ Backup backup = mock(Backup.class);
+ when(backup.getId()).thenReturn(backupId);
+ when(backupProvider.getName()).thenReturn("testbackupprovider");
+ when(backupProvider.takeBackup(vmInstanceVOMock, null)).thenReturn(new
Pair<>(false, backup));
+ Map<String, BackupProvider> backupProvidersMap = new HashMap<>();
+ backupProvidersMap.put(backupProvider.getName().toLowerCase(),
backupProvider);
+ ReflectionTestUtils.setField(backupManager, "backupProvidersMap",
backupProvidersMap);
+
+ BackupVO backupVO = mock(BackupVO.class);
+ when(backupVO.getId()).thenReturn(backupId);
+ when(backupDao.findById(backupId)).thenReturn(backupVO);
+
+ CreateBackupCmd cmd = Mockito.mock(CreateBackupCmd.class);
+ when(cmd.getVmId()).thenReturn(vmId);
+ when(cmd.getQuiesceVM()).thenReturn(null);
+
+ try (MockedStatic<ActionEventUtils> ignored =
Mockito.mockStatic(ActionEventUtils.class)) {
+ Assert.assertThrows(CloudRuntimeException.class, () ->
backupManager.createBackup(cmd, asyncJobVOMock));
+
+ Mockito.verify(backupVO, times(1)).setBackupScheduleId(scheduleId);
+ Mockito.verify(backupDao, times(1)).update(backupId, backupVO);
+ Mockito.verify(resourceLimitMgr,
times(1)).incrementResourceCount(accountId, Resource.ResourceType.backup);
+ Mockito.verify(resourceLimitMgr,
times(1)).incrementResourceCount(accountId,
Resource.ResourceType.backup_storage, 0L);
+ Mockito.verify(backupManager,
Mockito.never()).deleteOldestBackupFromScheduleIfRequired(vmId, scheduleId);
+ }
+ }
+
@Test(expected = ResourceAllocationException.class)
public void createBackupTestResourceLimitReached() throws
ResourceAllocationException {
Long vmId = 1L;