Skip to content

Commit ec83614

Browse files
committed
kvm: fix restore-and-attach of a backed up volume
Restoring a volume from a backup and attaching it to a VM has been broken since the restore commands were changed to run without a shell, in three independent ways. getDeviceToAttachDisk pipes virsh domblklist through awk, but passes the awk program still wrapped in the single quotes a shell would have stripped. Run directly, awk fails with "invalid char" and returns nothing, so the device name is empty and charAt throws StringIndexOutOfBoundsException before any attach is attempted. This affects every storage type. The exit value was also never checked, and the output not trimmed, so even a working awk would leave the trailing line separator and increment that instead of the device letter. The RBD branch passes the literal string "<<EOF%sEOF" as a virsh argument. The placeholder is never substituted with the disk XML, and a here-document cannot work without a shell, so virsh is handed a bogus argument and fails. The XML is now written to a temporary file that virsh reads. The Linstor branch declares "--subdriver qcow2", inverting the previous behaviour where Linstor got a raw attach and every other pool got qcow2. A Linstor volume is a raw DRBD block device, so libvirt rejects it with "Image is not in qcow2 format". The condition is restored, along with the "--driver qemu" that was dropped.
1 parent 7ea1dca commit ec83614

2 files changed

Lines changed: 175 additions & 20 deletions

File tree

plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRestoreBackupCommandWrapper.java

Lines changed: 51 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
package com.cloud.hypervisor.kvm.resource.wrapper;
2121

2222
import java.io.IOException;
23+
import java.nio.charset.StandardCharsets;
2324
import java.nio.file.Files;
2425
import java.nio.file.Path;
2526
import java.nio.file.Paths;
@@ -340,38 +341,68 @@ private boolean replaceBlockDeviceWithBackup(KVMStoragePoolManager storagePoolMg
340341

341342
private boolean attachVolumeToVm(KVMStoragePoolManager storagePoolMgr, String vmName, PrimaryDataStoreTO volumePool, String volumePath) {
342343
String deviceToAttachDiskTo = getDeviceToAttachDisk(vmName);
344+
if (Storage.StoragePoolType.RBD.equals(volumePool.getPoolType())) {
345+
return attachRbdVolumeToVm(storagePoolMgr, vmName, volumePool, volumePath, deviceToAttachDiskTo);
346+
}
343347
List<String> virshCmd = new ArrayList<>();
344348
virshCmd.add(Script.getExecutableAbsolutePath("virsh"));
345-
if (volumePool.getPoolType() == Storage.StoragePoolType.RBD) {
346-
String xmlForRbdDisk = getXmlForRbdDisk(storagePoolMgr, volumePool, volumePath, deviceToAttachDiskTo);
347-
logger.debug("RBD disk xml to attach: {}", xmlForRbdDisk);
348-
virshCmd.add("attach-device");
349-
virshCmd.add(vmName);
350-
virshCmd.add("/dev/stdin");
351-
virshCmd.add("<<EOF%sEOF");
352-
} else {
353-
virshCmd.add("attach-disk");
354-
virshCmd.add(vmName);
355-
virshCmd.add(volumePath);
356-
virshCmd.add(deviceToAttachDiskTo);
357-
if (Storage.StoragePoolType.Linstor.equals(volumePool.getPoolType())) {
358-
virshCmd.add("--subdriver");
359-
virshCmd.add("qcow2");
360-
}
361-
virshCmd.add("--cache");
362-
virshCmd.add("none");
349+
virshCmd.add("attach-disk");
350+
virshCmd.add(vmName);
351+
virshCmd.add(volumePath);
352+
virshCmd.add(deviceToAttachDiskTo);
353+
virshCmd.add("--driver");
354+
virshCmd.add("qemu");
355+
if (!Storage.StoragePoolType.Linstor.equals(volumePool.getPoolType())) {
356+
virshCmd.add("--subdriver");
357+
virshCmd.add("qcow2");
363358
}
359+
virshCmd.add("--cache");
360+
virshCmd.add("none");
364361
int exitValue = Script.executeCommandForExitValue(virshCmd.toArray(new String[0]));
365362
return exitValue == 0;
366363
}
367364

365+
private boolean attachRbdVolumeToVm(KVMStoragePoolManager storagePoolMgr, String vmName, PrimaryDataStoreTO volumePool, String volumePath,
366+
String deviceToAttachDiskTo) {
367+
String xmlForRbdDisk = getXmlForRbdDisk(storagePoolMgr, volumePool, volumePath, deviceToAttachDiskTo);
368+
logger.debug("RBD disk xml to attach: {}", xmlForRbdDisk);
369+
// The command is executed without a shell, so the XML cannot be piped in through a
370+
// here-document. Write it to a temporary file and pass virsh the path instead.
371+
Path xmlFile = null;
372+
try {
373+
xmlFile = Files.createTempFile("csrestore-rbd-", ".xml");
374+
Files.write(xmlFile, xmlForRbdDisk.getBytes(StandardCharsets.UTF_8));
375+
String[] virshCmd = new String[] { Script.getExecutableAbsolutePath("virsh"), "attach-device", vmName, xmlFile.toString() };
376+
return Script.executeCommandForExitValue(virshCmd) == 0;
377+
} catch (IOException e) {
378+
logger.error("Failed to write the RBD disk XML used to attach volume [{}] to VM [{}]", volumePath, vmName, e);
379+
return false;
380+
} finally {
381+
if (xmlFile != null) {
382+
try {
383+
Files.deleteIfExists(xmlFile);
384+
} catch (IOException e) {
385+
logger.warn("Failed to delete the temporary RBD disk XML file [{}].", xmlFile, e);
386+
}
387+
}
388+
}
389+
}
390+
368391
private String getDeviceToAttachDisk(String vmName) {
369392
String[] domblkCmd = new String[] { Script.getExecutableAbsolutePath("virsh"), "domblklist", "--domain", vmName };
370393
String[] tailCmd = new String[] { Script.getExecutableAbsolutePath("tail"), "-n", "3" };
371394
String[] headCmd = new String[] { Script.getExecutableAbsolutePath("head"), "-n", "1" };
372-
String[] awkCmd = new String[] { Script.getExecutableAbsolutePath("awk"), "'{print $1}'" };
395+
// The commands are executed without a shell, so the awk program must be passed as a plain
396+
// argument. Keeping the quotes a shell would have stripped makes awk fail with
397+
// "invalid char" and produce no output.
398+
String[] awkCmd = new String[] { Script.getExecutableAbsolutePath("awk"), "{print $1}" };
373399
Pair<Integer, String> result = Script.executePipedCommands(Arrays.asList(domblkCmd, tailCmd, headCmd, awkCmd), 0);
374-
String currentDevice = result.second();
400+
// executePipedCommands appends a line separator to every line it reads, so the device
401+
// name has to be trimmed before the last character can be incremented.
402+
String currentDevice = result.second() == null ? "" : result.second().trim();
403+
if (result.first() == null || result.first() != 0 || StringUtils.isBlank(currentDevice)) {
404+
throw new CloudRuntimeException(String.format("Failed to determine the device to attach the restored volume to on VM [%s].", vmName));
405+
}
375406
char lastChar = currentDevice.charAt(currentDevice.length() - 1);
376407
char incrementedChar = (char) (lastChar + 1);
377408
return currentDevice.substring(0, currentDevice.length() - 1) + incrementedChar;

plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRestoreBackupCommandWrapperTest.java

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,11 @@
2525
import static org.mockito.Mockito.when;
2626

2727
import java.io.IOException;
28+
import java.lang.reflect.Method;
2829
import java.nio.file.Files;
2930
import java.nio.file.Path;
3031
import java.util.Arrays;
32+
import java.util.List;
3133

3234
import org.apache.cloudstack.backup.BackupAnswer;
3335
import org.apache.cloudstack.backup.RestoreBackupCommand;
@@ -42,8 +44,11 @@
4244

4345
import com.cloud.agent.api.Answer;
4446
import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource;
47+
import com.cloud.hypervisor.kvm.storage.KVMStoragePool;
48+
import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager;
4549
import com.cloud.storage.Storage;
4650
import com.cloud.utils.Pair;
51+
import com.cloud.utils.exception.CloudRuntimeException;
4752
import com.cloud.utils.script.Script;
4853
import com.cloud.vm.VirtualMachine;
4954

@@ -579,4 +584,123 @@ public void testExecuteWithMultipleVolumes() throws Exception {
579584
}
580585
}
581586
}
587+
588+
private String invokeGetDeviceToAttachDisk(String vmName) throws Exception {
589+
Method method = LibvirtRestoreBackupCommandWrapper.class.getDeclaredMethod("getDeviceToAttachDisk", String.class);
590+
method.setAccessible(true);
591+
try {
592+
return (String) method.invoke(wrapper, vmName);
593+
} catch (java.lang.reflect.InvocationTargetException e) {
594+
throw (Exception) e.getCause();
595+
}
596+
}
597+
598+
private String[] captureAttachCommand(Storage.StoragePoolType poolType) throws Exception {
599+
PrimaryDataStoreTO volumePool = Mockito.mock(PrimaryDataStoreTO.class);
600+
lenient().when(volumePool.getPoolType()).thenReturn(poolType);
601+
lenient().when(volumePool.getHost()).thenReturn("10.0.0.1");
602+
lenient().when(volumePool.getUuid()).thenReturn("pool-uuid");
603+
KVMStoragePoolManager storagePoolMgr = Mockito.mock(KVMStoragePoolManager.class);
604+
KVMStoragePool primaryPool = Mockito.mock(KVMStoragePool.class);
605+
lenient().when(storagePoolMgr.getStoragePool(any(), anyString())).thenReturn(primaryPool);
606+
lenient().when(primaryPool.getAuthUserName()).thenReturn("cloudstack");
607+
608+
Method method = LibvirtRestoreBackupCommandWrapper.class.getDeclaredMethod("attachVolumeToVm",
609+
KVMStoragePoolManager.class, String.class, PrimaryDataStoreTO.class, String.class);
610+
method.setAccessible(true);
611+
612+
final String[][] captured = new String[1][];
613+
try (MockedStatic<Script> scriptMock = mockStatic(Script.class)) {
614+
scriptMock.when(() -> Script.getExecutableAbsolutePath(anyString()))
615+
.thenAnswer(invocation -> invocation.getArgument(0));
616+
scriptMock.when(() -> Script.executePipedCommands(anyList(), anyLong()))
617+
.thenReturn(new Pair<>(0, "vda" + System.lineSeparator()));
618+
scriptMock.when(() -> Script.executeCommandForExitValue(any(String[].class)))
619+
.thenAnswer(invocation -> {
620+
// Mockito expands varargs, so the command comes back as individual arguments.
621+
captured[0] = Arrays.stream(invocation.getArguments()).map(String::valueOf).toArray(String[]::new);
622+
return 0;
623+
});
624+
method.invoke(wrapper, storagePoolMgr, "test-vm", volumePool, "/path/to/volume");
625+
}
626+
return captured[0];
627+
}
628+
629+
@Test
630+
public void testGetDeviceToAttachDiskTrimsOutputBeforeIncrementing() throws Exception {
631+
try (MockedStatic<Script> scriptMock = mockStatic(Script.class)) {
632+
scriptMock.when(() -> Script.getExecutableAbsolutePath(anyString()))
633+
.thenAnswer(invocation -> invocation.getArgument(0));
634+
// executePipedCommands appends a line separator to each line it reads.
635+
scriptMock.when(() -> Script.executePipedCommands(anyList(), anyLong()))
636+
.thenReturn(new Pair<>(0, "vda" + System.lineSeparator()));
637+
638+
Assert.assertEquals("vdb", invokeGetDeviceToAttachDisk("test-vm"));
639+
}
640+
}
641+
642+
@Test
643+
public void testGetDeviceToAttachDiskPassesUnquotedAwkProgram() throws Exception {
644+
try (MockedStatic<Script> scriptMock = mockStatic(Script.class)) {
645+
scriptMock.when(() -> Script.getExecutableAbsolutePath(anyString()))
646+
.thenAnswer(invocation -> invocation.getArgument(0));
647+
final List<String[]>[] captured = new List[1];
648+
scriptMock.when(() -> Script.executePipedCommands(anyList(), anyLong()))
649+
.thenAnswer(invocation -> {
650+
captured[0] = invocation.getArgument(0);
651+
return new Pair<>(0, "vda" + System.lineSeparator());
652+
});
653+
654+
invokeGetDeviceToAttachDisk("test-vm");
655+
656+
String[] awkCmd = captured[0].get(captured[0].size() - 1);
657+
// The commands are executed without a shell, so the program must carry no shell quotes.
658+
Assert.assertEquals("awk", awkCmd[0]);
659+
Assert.assertEquals("{print $1}", awkCmd[1]);
660+
}
661+
}
662+
663+
@Test(expected = CloudRuntimeException.class)
664+
public void testGetDeviceToAttachDiskFailsWhenNoDeviceIsReturned() throws Exception {
665+
try (MockedStatic<Script> scriptMock = mockStatic(Script.class)) {
666+
scriptMock.when(() -> Script.getExecutableAbsolutePath(anyString()))
667+
.thenAnswer(invocation -> invocation.getArgument(0));
668+
scriptMock.when(() -> Script.executePipedCommands(anyList(), anyLong()))
669+
.thenReturn(new Pair<>(1, ""));
670+
671+
invokeGetDeviceToAttachDisk("test-vm");
672+
}
673+
}
674+
675+
@Test
676+
public void testAttachVolumeUsesQcow2SubdriverForFileBackedPool() throws Exception {
677+
String[] cmd = captureAttachCommand(Storage.StoragePoolType.NetworkFilesystem);
678+
List<String> args = Arrays.asList(cmd);
679+
680+
Assert.assertTrue(args.contains("attach-disk"));
681+
Assert.assertTrue(args.contains("--driver"));
682+
Assert.assertTrue(args.contains("qemu"));
683+
Assert.assertEquals("qcow2", args.get(args.indexOf("--subdriver") + 1));
684+
}
685+
686+
@Test
687+
public void testAttachVolumeOmitsQcow2SubdriverForLinstor() throws Exception {
688+
String[] cmd = captureAttachCommand(Storage.StoragePoolType.Linstor);
689+
List<String> args = Arrays.asList(cmd);
690+
691+
// Linstor volumes are raw DRBD block devices, declaring qcow2 makes libvirt reject them.
692+
Assert.assertTrue(args.contains("attach-disk"));
693+
Assert.assertFalse(args.contains("--subdriver"));
694+
}
695+
696+
@Test
697+
public void testAttachVolumePassesRbdXmlThroughAFile() throws Exception {
698+
String[] cmd = captureAttachCommand(Storage.StoragePoolType.RBD);
699+
List<String> args = Arrays.asList(cmd);
700+
701+
Assert.assertTrue(args.contains("attach-device"));
702+
// The XML has to reach virsh as a file, a here-document cannot work without a shell.
703+
Assert.assertFalse(args.stream().anyMatch(arg -> arg.contains("EOF")));
704+
Assert.assertTrue(args.get(args.size() - 1).endsWith(".xml"));
705+
}
582706
}

0 commit comments

Comments
 (0)