From 1d880fc5f205159bf7e1c6dea77314a07e1d1532 Mon Sep 17 00:00:00 2001 From: Abhisar Sinha <63767682+abh1sar@users.noreply.github.com> Date: Mon, 9 Nov 2026 11:30:17 +0530 Subject: [PATCH 01/11] kvm: delete a checkpoint's bitmap from every layer of the disk chain The checkpoint delete on the host removed the bitmap only where libvirt still listed the checkpoint, and only from the active layer. CloudStack domains are transient, so a restart or a migration makes libvirt forget every checkpoint while the bitmaps stay in the images, and a VM snapshot pushes a volume that carries bitmaps down the backing chain, where the active-layer cleanup cannot reach them. Both left a bitmap per backup behind, recording every guest write forever, and the second resurfaced as an orphan in the live image once the snapshot was committed. For a running VM all bitmap removal is now left to libvirt, which walks the backing chain and reopens read-only backing files itself. When libvirt refuses, because it does not know the checkpoint or a listed disk has lost its bitmap, the agent asks qemu which disks still hold the bitmap in any layer, redefines the checkpoint with exactly those disks and the creation time the command carries, and deletes again; when no layer has it, only libvirt's record is dropped. For a stopped VM every layer of each volume's backing chain that lists the bitmap is cleaned with qemu-img; the template never carries one and is never opened for writing. The passphrase of an encrypted volume is passed along so its images can be opened for writing, the key file is deleted after the call, and a layer that no longer has the bitmap does not fail the delete, so a partial failure can be retried. --- .../backup/DeleteVmCheckpointCommand.java | 20 + ...bvirtDeleteVmCheckpointCommandWrapper.java | 267 +++++++++++-- ...tDeleteVmCheckpointCommandWrapperTest.java | 353 +++++++++++++++++- 3 files changed, 583 insertions(+), 57 deletions(-) diff --git a/core/src/main/java/org/apache/cloudstack/backup/DeleteVmCheckpointCommand.java b/core/src/main/java/org/apache/cloudstack/backup/DeleteVmCheckpointCommand.java index 81cf6c1abfcc..03fa2d23f6aa 100644 --- a/core/src/main/java/org/apache/cloudstack/backup/DeleteVmCheckpointCommand.java +++ b/core/src/main/java/org/apache/cloudstack/backup/DeleteVmCheckpointCommand.java @@ -20,21 +20,32 @@ import java.util.Map; import com.cloud.agent.api.Command; +import com.cloud.agent.api.LogLevel; public class DeleteVmCheckpointCommand extends Command { private String vmName; private String checkpointId; private Map diskPathUuidMap; + @LogLevel(LogLevel.Log4jLevel.Off) + private Map diskPathPassphraseMap; private boolean stoppedVM; + private Long checkpointCreateTime; public DeleteVmCheckpointCommand() { } public DeleteVmCheckpointCommand(String vmName, String checkpointId, Map diskPathUuidMap, boolean stoppedVM) { + this(vmName, checkpointId, diskPathUuidMap, null, stoppedVM, null); + } + + public DeleteVmCheckpointCommand(String vmName, String checkpointId, Map diskPathUuidMap, + Map diskPathPassphraseMap, boolean stoppedVM, Long checkpointCreateTime) { this.vmName = vmName; this.checkpointId = checkpointId; this.diskPathUuidMap = diskPathUuidMap; + this.diskPathPassphraseMap = diskPathPassphraseMap; this.stoppedVM = stoppedVM; + this.checkpointCreateTime = checkpointCreateTime; } public String getVmName() { @@ -45,10 +56,19 @@ public String getCheckpointId() { return checkpointId; } + // Creation time of the checkpoint on the host in epoch seconds, needed to redefine it in libvirt. + public Long getCheckpointCreateTime() { + return checkpointCreateTime; + } + public Map getDiskPathUuidMap() { return diskPathUuidMap; } + public Map getDiskPathPassphraseMap() { + return diskPathPassphraseMap; + } + public boolean isStoppedVM() { return stoppedVM; } diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtDeleteVmCheckpointCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtDeleteVmCheckpointCommandWrapper.java index ddb84ab29cb3..86fa543ccc1b 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtDeleteVmCheckpointCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtDeleteVmCheckpointCommandWrapper.java @@ -1,79 +1,270 @@ -//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 -//the License. You may obtain a copy of the License at // -//http://www.apache.org/licenses/LICENSE-2.0 +// 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. // -//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 java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; +import java.util.Set; import org.apache.cloudstack.backup.DeleteVmCheckpointCommand; +import org.apache.cloudstack.utils.cryptsetup.KeyFile; +import org.apache.cloudstack.utils.qemu.QemuCommand; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.json.JSONArray; +import org.json.JSONObject; +import org.libvirt.Domain; +import org.libvirt.LibvirtException; import com.cloud.agent.api.Answer; import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; import com.cloud.resource.CommandWrapper; import com.cloud.resource.ResourceWrapper; +import com.cloud.utils.exception.CloudRuntimeException; +import com.cloud.utils.script.OutputInterpreter; import com.cloud.utils.script.Script; +// Deletes a checkpoint of the KVM backup export: the libvirt checkpoint of a running VM and the dirty bitmap of the +// same name in every layer of the disk images. A missing bitmap is not an error, so a delete can be retried. @ResourceWrapper(handles = DeleteVmCheckpointCommand.class) public class LibvirtDeleteVmCheckpointCommandWrapper extends CommandWrapper { + protected Logger logger = LogManager.getLogger(getClass()); @Override public Answer execute(DeleteVmCheckpointCommand cmd, LibvirtComputingResource resource) { if (cmd.isStoppedVM()) { return deleteBitmapsOnDisks(cmd); } - return deleteDomainCheckpoint(cmd); + return deleteDomainCheckpoint(cmd, resource); } - private Answer deleteDomainCheckpoint(DeleteVmCheckpointCommand cmd) { + // Running VM: libvirt's delete removes the bitmap from every layer of the listed disks. It fails on a forgotten + // checkpoint and touches nothing on one redefined without disks, so qemu is asked afterwards what is left. + private Answer deleteDomainCheckpoint(DeleteVmCheckpointCommand cmd, LibvirtComputingResource resource) { String vmName = cmd.getVmName(); String checkpointId = cmd.getCheckpointId(); - String virshCmd = String.format("virsh checkpoint-delete %s %s", vmName, checkpointId); - Script script = new Script("/bin/bash"); - script.add("-c"); - script.add(virshCmd); - String result = script.execute(); + String result = virsh("checkpoint-delete", vmName, checkpointId); + if (result == null) { + return new Answer(cmd, true, "Checkpoint deleted"); + } + logger.info("Deleting checkpoint {} of VM {} failed ({}), redefining it from the disks that carry its bitmap", checkpointId, vmName, result); + + Map diskHasBitmap; + try { + diskHasBitmap = findDisksWithBitmap(resource, vmName, checkpointId); + } catch (Exception e) { + logger.error("Failed to look up the bitmap of checkpoint {} on the disks of VM {}", checkpointId, vmName, e); + return new Answer(cmd, false, "Failed to delete checkpoint " + checkpointId + ": " + result + "; bitmap lookup failed: " + e.getMessage()); + } + List disksWithBitmap = new ArrayList<>(); + diskHasBitmap.forEach((label, has) -> { if (has) { disksWithBitmap.add(label); } }); + + if (disksWithBitmap.isEmpty()) { + // Nothing left in the images; drop libvirt's record if it still has one. + if (virsh("checkpoint-dumpxml", "--domain", vmName, "--checkpointname", checkpointId, "--no-domain") == null) { + String metadata = virsh("checkpoint-delete", vmName, checkpointId, "--metadata"); + if (metadata != null) { + return new Answer(cmd, false, "Failed to delete checkpoint " + checkpointId + ": " + metadata); + } + } + return new Answer(cmd, true, "Checkpoint not present"); + } + + String redefine = redefineCheckpoint(vmName, checkpointId, cmd.getCheckpointCreateTime(), diskHasBitmap); + if (redefine != null) { + return new Answer(cmd, false, "Failed to redefine checkpoint " + checkpointId + " with disks " + disksWithBitmap + ": " + redefine); + } + result = virsh("checkpoint-delete", vmName, checkpointId); if (result != null) { - return new Answer(cmd, false, "Failed to delete checkpoint: " + result); + return new Answer(cmd, false, "Failed to delete checkpoint " + checkpointId + " after redefining it with disks " + disksWithBitmap + ": " + result); + } + return new Answer(cmd, true, "Checkpoint deleted, bitmap removed from disks " + disksWithBitmap); + } + + private String virsh(String... args) { + Script script = new Script("virsh", logger); + script.add(args); + return script.execute(); + } + + private String redefineCheckpoint(String vmName, String checkpointId, Long createTime, Map diskHasBitmap) { + StringBuilder xml = new StringBuilder("\n"); + xml.append(" ").append(checkpointId).append("\n"); + // libvirt requires a creation time on a redefine but does not check it. + xml.append(" ").append(createTime != null ? createTime : System.currentTimeMillis() / 1000).append("\n"); + xml.append(" \n"); + for (Map.Entry disk : diskHasBitmap.entrySet()) { + xml.append(String.format(" \n", disk.getKey(), disk.getValue() ? "bitmap" : "no")); + } + xml.append(" \n\n"); + File xmlFile; + try { + xmlFile = File.createTempFile("checkpoint-redefine-", ".xml"); + try (FileWriter writer = new FileWriter(xmlFile)) { + writer.write(xml.toString()); + } + } catch (IOException e) { + return "could not write the checkpoint XML: " + e.getMessage(); + } + try { + return virsh("checkpoint-create", "--domain", vmName, "--xmlfile", xmlFile.getAbsolutePath(), "--redefine"); + } finally { + xmlFile.delete(); + } + } + + // Whether each disk of the running domain has the bitmap in any layer of its chain. Asked through QMP, since + // qemu-img info on an image a running qemu holds shows stale bitmap metadata. + Map findDisksWithBitmap(LibvirtComputingResource resource, String vmName, String bitmapName) throws LibvirtException { + Map diskPathLabelMap = resource.getDiskPathLabelMap(vmName); + Domain vm = resource.getDomain(resource.getLibvirtUtilitiesHelper().getConnection(), vmName); + try { + String nodes = vm.qemuMonitorCommand(QemuCommand.buildQemuCommand("query-named-block-nodes", null), 0); + return findDisksWithBitmap(nodes, diskPathLabelMap, bitmapName); + } finally { + vm.free(); } - return new Answer(cmd, true, "Checkpoint deleted"); } - /** - * Stopped VM: persistent bitmaps on disk images ({@code qemu-img bitmap --remove}), matching {@link LibvirtStartBackupCommandWrapper} bitmap --add. - */ + static Map findDisksWithBitmap(String namedBlockNodes, Map diskPathLabelMap, String bitmapName) { + JSONArray nodes = new JSONObject(namedBlockNodes).optJSONArray("return"); + Set filesWithBitmap = new HashSet<>(); + Map imageByFile = new LinkedHashMap<>(); + for (int i = 0; nodes != null && i < nodes.length(); i++) { + JSONObject node = nodes.getJSONObject(i); + String file = node.optString("file", null); + if (file == null) { + continue; + } + JSONObject image = node.optJSONObject("image"); + if (image != null && !imageByFile.containsKey(file)) { + imageByFile.put(file, image); + } + JSONArray bitmaps = node.optJSONArray("dirty-bitmaps"); + for (int j = 0; bitmaps != null && j < bitmaps.length(); j++) { + if (bitmapName.equals(bitmaps.getJSONObject(j).optString("name"))) { + filesWithBitmap.add(file); + } + } + } + Map diskHasBitmap = new LinkedHashMap<>(); + for (Map.Entry disk : diskPathLabelMap.entrySet()) { + boolean has = false; + for (JSONObject image = imageByFile.get(disk.getKey()); image != null; image = image.optJSONObject("backing-image")) { + if (filesWithBitmap.contains(image.optString("filename"))) { + has = true; + break; + } + } + diskHasBitmap.put(disk.getValue(), has); + } + return diskHasBitmap; + } + + // Stopped VM: removes the persistent bitmap from every layer of each volume's chain that lists it. The template + // at the bottom of a root volume's chain never carries one and is never opened for writing. private Answer deleteBitmapsOnDisks(DeleteVmCheckpointCommand cmd) { String checkpointId = cmd.getCheckpointId(); Map diskPathUuidMap = cmd.getDiskPathUuidMap(); if (diskPathUuidMap == null || diskPathUuidMap.isEmpty()) { return new Answer(cmd, false, "No disks provided for bitmap removal"); } - for (Map.Entry entry : diskPathUuidMap.entrySet()) { - String diskPath = entry.getKey(); - Script script = new Script("qemu-img"); - script.add("bitmap"); - script.add("--remove"); - script.add(diskPath); - script.add(checkpointId); - String result = script.execute(); - if (result != null) { - return new Answer(cmd, false, - "Failed to remove bitmap " + checkpointId + " from disk " + diskPath + ": " + result); + Map diskPathPassphraseMap = cmd.getDiskPathPassphraseMap() != null ? cmd.getDiskPathPassphraseMap() : Collections.emptyMap(); + List removedFrom = new ArrayList<>(); + for (String diskPath : diskPathUuidMap.keySet()) { + byte[] passphrase = diskPathPassphraseMap.get(diskPath); + List layers; + try { + layers = queryChainLayersWithBitmap(diskPath, checkpointId); + } catch (Exception e) { + return new Answer(cmd, false, "Failed to read the backing chain of disk " + diskPath + ": " + e.getMessage()); + } + for (String layer : layers) { + String result; + try { + result = removeBitmapFromImage(layer, passphrase, checkpointId); + } catch (IOException e) { + return new Answer(cmd, false, "Failed to create the key file for removing bitmap " + checkpointId + " from disk " + layer); + } + if (result == null) { + removedFrom.add(layer); + } else if (result.contains(String.format("Dirty bitmap '%s' not found", checkpointId))) { + logger.info("Bitmap {} is not present on {}: {}", checkpointId, layer, result); + } else { + return new Answer(cmd, false, "Failed to remove bitmap " + checkpointId + " from disk " + layer + ": " + result); + } } } - return new Answer(cmd, true, "Checkpoint bitmap removed from disks"); + return new Answer(cmd, true, removedFrom.isEmpty() ? "Checkpoint not present" : "Checkpoint bitmap removed from " + removedFrom); + } + + // The files of the volume's backing chain, top first, whose bitmap list contains the name. + private List queryChainLayersWithBitmap(String diskPath, String bitmapName) { + Script script = new Script("qemu-img", logger); + script.add("info", "-U", "--backing-chain", "--output=json", diskPath); + OutputInterpreter.AllLinesParser parser = new OutputInterpreter.AllLinesParser(); + String result = script.execute(parser); + if (result != null) { + throw new CloudRuntimeException(result); + } + return findChainLayersWithBitmap(parser.getLines(), bitmapName); + } + + static List findChainLayersWithBitmap(String backingChainInfo, String bitmapName) { + List layers = new ArrayList<>(); + JSONArray chain = new JSONArray(backingChainInfo); + for (int i = 0; i < chain.length(); i++) { + JSONObject image = chain.getJSONObject(i); + JSONObject specific = image.optJSONObject("format-specific"); + JSONObject data = specific != null ? specific.optJSONObject("data") : null; + JSONArray bitmaps = data != null ? data.optJSONArray("bitmaps") : null; + for (int j = 0; bitmaps != null && j < bitmaps.length(); j++) { + if (bitmapName.equals(bitmaps.getJSONObject(j).optString("name"))) { + layers.add(image.getString("filename")); + break; + } + } + } + return layers; + } + + private String removeBitmapFromImage(String diskPath, byte[] passphrase, String bitmapName) throws IOException { + Script script = new Script("qemu-img", logger); + script.add("bitmap", "--remove"); + if (passphrase == null || passphrase.length == 0) { + script.add(diskPath, bitmapName); + return script.execute(); + } + // The key file holds the plaintext passphrase, so it only lives for the duration of the call. + try (KeyFile key = new KeyFile(passphrase)) { + script.add("--object", String.format("secret,id=sec0,file=%s", key)); + script.add("--image-opts", String.format("driver=qcow2,file.driver=file,file.filename=%s,encrypt.key-secret=sec0", diskPath)); + script.add(bitmapName); + return script.execute(); + } } } diff --git a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtDeleteVmCheckpointCommandWrapperTest.java b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtDeleteVmCheckpointCommandWrapperTest.java index 769fdc9aa7b6..adfe6aab0b49 100644 --- a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtDeleteVmCheckpointCommandWrapperTest.java +++ b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtDeleteVmCheckpointCommandWrapperTest.java @@ -14,75 +14,390 @@ // 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 java.io.BufferedReader; +import java.io.File; +import java.io.StringReader; +import java.nio.file.Files; +import java.util.ArrayList; import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; import org.apache.cloudstack.backup.DeleteVmCheckpointCommand; import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import org.libvirt.Connect; +import org.libvirt.Domain; +import org.libvirt.LibvirtException; import org.mockito.MockedConstruction; import org.mockito.Mockito; import org.mockito.junit.MockitoJUnitRunner; import com.cloud.agent.api.Answer; import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; +import com.cloud.utils.script.OutputInterpreter; import com.cloud.utils.script.Script; @RunWith(MockitoJUnitRunner.class) public class LibvirtDeleteVmCheckpointCommandWrapperTest { + // vda is an overlay on /p/base.qcow2 (a VM snapshot): cp-5 in the top layer, cp-4 only in the base; vdb has no + // bitmaps. Each disk has a format node and a storage node with the same file, as libvirt builds them. + private static final String NAMED_NODES = "{\"return\": [" + + "{\"node-name\": \"libvirt-2-format\", \"file\": \"/p/vda.qcow2\", \"drv\": \"qcow2\", \"ro\": false," + + " \"image\": {\"filename\": \"/p/vda.qcow2\", \"backing-image\": {\"filename\": \"/p/base.qcow2\"}}," + + " \"dirty-bitmaps\": [{\"name\": \"cp-5\"}]}," + + "{\"node-name\": \"libvirt-2-storage\", \"file\": \"/p/vda.qcow2\", \"drv\": \"file\", \"ro\": false, \"image\": {\"filename\": \"/p/vda.qcow2\"}}," + + "{\"node-name\": \"libvirt-3-format\", \"file\": \"/p/base.qcow2\", \"drv\": \"qcow2\", \"ro\": true," + + " \"image\": {\"filename\": \"/p/base.qcow2\"}, \"dirty-bitmaps\": [{\"name\": \"cp-4\"}, {\"name\": \"cp-5\"}]}," + + "{\"node-name\": \"libvirt-3-storage\", \"file\": \"/p/base.qcow2\", \"drv\": \"file\", \"ro\": false, \"image\": {\"filename\": \"/p/base.qcow2\"}}," + + "{\"node-name\": \"libvirt-1-format\", \"file\": \"/p/vdb.qcow2\", \"drv\": \"qcow2\", \"ro\": false," + + " \"image\": {\"filename\": \"/p/vdb.qcow2\"}, \"dirty-bitmaps\": []}," + + "{\"node-name\": \"libvirt-1-storage\", \"file\": \"/p/vdb.qcow2\", \"drv\": \"file\", \"ro\": false, \"image\": {\"filename\": \"/p/vdb.qcow2\"}}" + + "]}"; + private static final Map DISK_LABELS = new LinkedHashMap<>(Map.of("/p/vda.qcow2", "vda")); + static { + DISK_LABELS.put("/p/vdb.qcow2", "vdb"); + } + + // qemu-img info --backing-chain of a stopped volume whose overlay has no bitmap and whose base has cp-2. + private static final String CHAIN_INFO = "[" + + "{\"filename\": \"/p/vol.qcow2\", \"format\": \"qcow2\", \"backing-filename\": \"/p/old.qcow2\"," + + " \"format-specific\": {\"type\": \"qcow2\", \"data\": {\"compat\": \"1.1\"}}}," + + "{\"filename\": \"/p/old.qcow2\", \"format\": \"qcow2\", \"backing-filename\": \"/p/template.qcow2\"," + + " \"format-specific\": {\"type\": \"qcow2\", \"data\": {\"bitmaps\": [{\"name\": \"cp-1\"}, {\"name\": \"cp-2\"}]}}}," + + "{\"filename\": \"/p/template.qcow2\", \"format\": \"qcow2\", \"format-specific\": {\"type\": \"qcow2\", \"data\": {}}}" + + "]"; + private static final String JSON_OUTPUT = "JSON:"; + private LibvirtDeleteVmCheckpointCommandWrapper wrapper; private DeleteVmCheckpointCommand command; + private LibvirtComputingResource resource; + private Domain domain; @Before - public void setUp() { + public void setUp() throws Exception { wrapper = new LibvirtDeleteVmCheckpointCommandWrapper(); command = Mockito.mock(DeleteVmCheckpointCommand.class); + resource = Mockito.mock(LibvirtComputingResource.class); + domain = Mockito.mock(Domain.class); + LibvirtUtilitiesHelper helper = Mockito.mock(LibvirtUtilitiesHelper.class); + Connect conn = Mockito.mock(Connect.class); + Mockito.lenient().when(resource.getLibvirtUtilitiesHelper()).thenReturn(helper); + Mockito.lenient().when(helper.getConnection()).thenReturn(conn); + Mockito.lenient().when(resource.getDomain(conn, "i-2-3-VM")).thenReturn(domain); + Mockito.lenient().when(resource.getDiskPathLabelMap("i-2-3-VM")).thenReturn(DISK_LABELS); + } + + private void runningVmCommand(String checkpointId) { + Mockito.when(command.isStoppedVM()).thenReturn(false); + Mockito.when(command.getVmName()).thenReturn("i-2-3-VM"); + Mockito.when(command.getCheckpointId()).thenReturn(checkpointId); + } + + private void stoppedVmCommand(String checkpointId, String diskPath) { + Mockito.when(command.isStoppedVM()).thenReturn(true); + Mockito.when(command.getCheckpointId()).thenReturn(checkpointId); + Mockito.when(command.getDiskPathUuidMap()).thenReturn(Map.of(diskPath, "vol-1")); + } + + private void qmpReturns(String namedBlockNodes) throws LibvirtException { + Mockito.when(domain.qemuMonitorCommand(Mockito.contains("query-named-block-nodes"), Mockito.eq(0))).thenReturn(namedBlockNodes); + } + + // Each Script gets the next result; its arguments, plus the content of an --xmlfile (deleted afterwards), are + // recorded in order. A result starting with JSON_OUTPUT feeds the parser of execute(parser) and counts as success. + private static MockedConstruction