block: Add integration tests

Adds integration tests for vmdk to cover i/o scenarios
for single extent & multi-extent span read writes. All tests
run disk consistency check in the end. The tests also extend
direct_io for VMDK backend.

Signed-off-by: Sumedh Alok Sharma <sumsharma@microsoft.com>
This commit is contained in:
Sumedh Alok Sharma
2026-07-13 10:46:33 +00:00
committed by Wei Liu
parent 82b65c3352
commit d69fb67f5b
2 changed files with 339 additions and 1 deletions

View File

@@ -2170,6 +2170,303 @@ pub fn _test_virtio_block_dynamic_vhdx_expand(guest: &Guest) {
disk_check_consistency(vhdx_path, None);
}
pub(crate) fn _test_virtio_block_vmdk(guest: &Guest, subformat: &str) {
let raw_os_disk = guest.disk_config.disk(DiskType::OperatingSystem).unwrap();
let vmdk_path = guest.tmp_dir.as_path().join("osdisk.vmdk");
let vmdk_path_str = vmdk_path.to_str().unwrap();
// Convert the prepared RAW OS disk into a flat VMDK. qemu-img writes the
// extent file(s) next to the descriptor automatically.
let convert = Command::new("qemu-img")
.arg("convert")
.args(["-f", "raw"])
.args(["-O", "vmdk"])
.args(["-o", &format!("subformat={subformat}")])
.arg(&raw_os_disk)
.arg(vmdk_path_str)
.output()
.expect("Expect generating flat VMDK image from RAW image");
assert!(
convert.status.success(),
"qemu-img convert to VMDK (subformat={subformat}) failed: {}",
String::from_utf8_lossy(&convert.stderr)
);
let mut cloud_child = GuestCommand::new(guest)
.default_cpus()
.default_memory()
.default_kernel_cmdline()
.args([
"--disk",
// Force the flat VMDK backend explicitly instead of relying on
// image-format auto-detection.
format!("path={vmdk_path_str},image_type=vmdk").as_str(),
format!(
"path={}",
guest.disk_config.disk(DiskType::CloudInit).unwrap()
)
.as_str(),
])
.default_net()
.capture_output()
.spawn()
.unwrap();
let r = panic::catch_unwind(|| {
guest.wait_vm_boot().unwrap();
assert_eq!(guest.get_cpu_count().unwrap_or_default(), 1);
assert!(guest.get_total_memory().unwrap_or_default() > 480_000);
// Exercise the flat VMDK write + read paths through the guest root
// filesystem: write a marker file, flush, and read it back.
guest
.ssh_command(
"echo cloud-hypervisor-vmdk | sudo tee /root/vmdk_marker > /dev/null && sync",
)
.unwrap();
assert_eq!(
guest
.ssh_command("sudo cat /root/vmdk_marker")
.unwrap()
.trim(),
"cloud-hypervisor-vmdk"
);
});
kill_child(&mut cloud_child);
let output = cloud_child.wait_with_output().unwrap();
handle_child_output(r, &output);
disk_check_consistency(vmdk_path_str, None);
}
pub(crate) fn _test_virtio_block_vmdk_enospc(guest: &Guest, subformat: &str) {
const DISK_SIZE_MIB: u64 = 16;
let vmdk_path = guest.tmp_dir.as_path().join("data.vmdk");
let vmdk_path_str = vmdk_path.to_str().unwrap();
let create = Command::new("qemu-img")
.arg("create")
.args(["-f", "vmdk"])
.args(["-o", &format!("subformat={subformat}")])
.arg(vmdk_path_str)
.arg(format!("{DISK_SIZE_MIB}M"))
.output()
.expect("Expect creating flat VMDK data disk");
assert!(
create.status.success(),
"qemu-img create VMDK (subformat={subformat}) failed: {}",
String::from_utf8_lossy(&create.stderr)
);
let mut cloud_child = GuestCommand::new(guest)
.default_cpus()
.default_memory()
.default_kernel_cmdline()
.args([
"--disk",
format!(
"path={}",
guest.disk_config.disk(DiskType::OperatingSystem).unwrap()
)
.as_str(),
format!(
"path={}",
guest.disk_config.disk(DiskType::CloudInit).unwrap()
)
.as_str(),
// Force the flat VMDK backend explicitly.
format!("path={vmdk_path_str},image_type=vmdk").as_str(),
])
.default_net()
.capture_output()
.spawn()
.unwrap();
let r = panic::catch_unwind(|| {
guest.wait_vm_boot().unwrap();
// The flat VMDK data disk shows up as vdc.
assert_eq!(
guest
.ssh_command("lsblk | grep -c vdc")
.unwrap()
.trim()
.parse::<u32>()
.unwrap_or_default(),
1
);
// 1) In-bounds direct-IO round trip: write a random pattern near the
// start of the device and read it back to confirm the VMDK I/O
// path is correct.
guest
.ssh_command(
"sudo dd if=/dev/urandom of=/tmp/pattern bs=1M count=4 && \
sudo dd if=/tmp/pattern of=/dev/vdc bs=1M count=4 seek=0 \
oflag=direct conv=fsync && \
sudo dd if=/dev/vdc of=/tmp/readback bs=1M count=4 skip=0 \
iflag=direct && \
cmp /tmp/pattern /tmp/readback",
)
.expect("in-bounds flat VMDK direct IO round trip failed");
// 2) Writing past the fixed virtual capacity must fail with ENOSPC:
// a flat VMDK is pre-allocated and cannot grow. dd exits non-zero
// once the device is full, so `|| true` keeps ssh_command happy
// while we assert on the reported error.
let enospc = guest
.ssh_command(&format!(
"sudo dd if=/dev/zero of=/dev/vdc bs=1M count={} oflag=direct 2>&1 || true",
DISK_SIZE_MIB + 8
))
.unwrap();
assert!(
enospc.contains("No space left on device"),
"expected ENOSPC writing past end of pre-allocated flat VMDK, got: {enospc}"
);
});
kill_child(&mut cloud_child);
let output = cloud_child.wait_with_output().unwrap();
handle_child_output(r, &output);
disk_check_consistency(vmdk_path_str, None);
}
pub(crate) fn _test_virtio_block_vmdk_extent_spanning(guest: &Guest, direct: bool) {
// 3 GiB virtual size => a 2 GiB first extent + a 1 GiB second extent.
// `direct=on` opens the extents with `O_DIRECT`, which tmpfs rejects with
// EINVAL. guest.tmp_dir is backed by tmpfs, so for the direct
// case place the VMDK on ~/workloads instead (same rationale as
// test_virtio_block_direct_and_firmware).
let workloads_tmp_dir;
let base_dir = if direct {
let mut workloads_path = dirs::home_dir().unwrap();
workloads_path.push("workloads");
workloads_tmp_dir = TempDir::new_in(workloads_path.as_path()).unwrap();
workloads_tmp_dir.as_path()
} else {
guest.tmp_dir.as_path()
};
let vmdk_path = base_dir.join("spanning.vmdk");
let vmdk_path_str = vmdk_path.to_str().unwrap();
let create = Command::new("qemu-img")
.arg("create")
.args(["-f", "vmdk"])
.args(["-o", "subformat=twoGbMaxExtentFlat"])
.arg(vmdk_path_str)
.arg("3G")
.output()
.expect("Expect creating twoGbMaxExtentFlat VMDK data disk");
assert!(
create.status.success(),
"qemu-img create twoGbMaxExtentFlat VMDK failed: {}",
String::from_utf8_lossy(&create.stderr)
);
let descriptor = fs::read_to_string(&vmdk_path).expect("read VMDK descriptor");
let extent_lines: Vec<&str> = descriptor
.lines()
.filter(|l| l.starts_with("RW ") && l.contains("FLAT"))
.collect();
assert!(
extent_lines.len() >= 2,
"twoGbMaxExtentFlat should produce >= 2 extents, descriptor was:\n{descriptor}"
);
let first_extent_sectors: u64 = extent_lines[0]
.split_whitespace()
.nth(1)
.and_then(|s| s.parse().ok())
.expect("parse first extent sector count from descriptor");
let boundary_bytes = first_extent_sectors * 512;
// Straddle the boundary with a 2 MiB, 4 KiB-aligned direct-IO window
// (~1 MiB in each extent). Round the start down to the O_DIRECT alignment
// without assuming the extent size itself is 4 KiB aligned.
const BS: u64 = 4096;
const HALF: u64 = 1024 * 1024;
let raw_start = boundary_bytes - HALF;
let start = raw_start - (raw_start % BS);
let seek = start / BS;
let count = (2 * HALF) / BS;
assert!(
start < boundary_bytes && boundary_bytes < start + count * BS,
"write window [{start}, {}) does not straddle extent boundary {boundary_bytes}",
start + count * BS
);
// Force the flat VMDK backend explicitly, and honor the requested cache
// mode. `direct=on` opens the extents with `O_DIRECT` on the host.
let data_disk = if direct {
format!("path={vmdk_path_str},image_type=vmdk,direct=on")
} else {
format!("path={vmdk_path_str},image_type=vmdk")
};
let mut cloud_child = GuestCommand::new(guest)
.default_cpus()
.default_memory()
.default_kernel_cmdline()
.args([
"--disk",
format!(
"path={}",
guest.disk_config.disk(DiskType::OperatingSystem).unwrap()
)
.as_str(),
format!(
"path={}",
guest.disk_config.disk(DiskType::CloudInit).unwrap()
)
.as_str(),
data_disk.as_str(),
])
.default_net()
.capture_output()
.spawn()
.unwrap();
let r = panic::catch_unwind(|| {
guest.wait_vm_boot().unwrap();
// The twoGbMaxExtentFlat data disk shows up as vdc.
assert_eq!(
guest
.ssh_command("lsblk | grep -c vdc")
.unwrap()
.trim()
.parse::<u32>()
.unwrap_or_default(),
1
);
// Write a random pattern that spans the 2 GiB extent boundary.
guest
.ssh_command(&format!(
"sudo dd if=/dev/urandom of=/tmp/pattern bs={BS} count={count} && \
sudo dd if=/tmp/pattern of=/dev/vdc bs={BS} count={count} seek={seek} \
oflag=direct conv=fsync && \
sudo dd if=/dev/vdc of=/tmp/readback bs={BS} count={count} skip={seek} \
iflag=direct && \
cmp /tmp/pattern /tmp/readback",
))
.expect("extent-spanning direct IO round trip across the 2 GiB boundary failed");
});
kill_child(&mut cloud_child);
let output = cloud_child.wait_with_output().unwrap();
handle_child_output(r, &output);
disk_check_consistency(vmdk_path_str, None);
}
fn vhdx_image_size(disk_name: &str) -> u64 {
fs::File::open(disk_name)
.unwrap()

View File

@@ -1597,7 +1597,7 @@ mod common_parallel {
ImageType::Qcow2 => ("qcow2", &[]),
ImageType::FixedVhd => ("vpc", &["-o", "subformat=fixed"]),
ImageType::Vhdx => ("vhdx", &[]),
ImageType::FlatVmdk => panic!("unsupported image_type {image_type}"),
ImageType::FlatVmdk => ("vmdk", &["-o", "subformat=monolithicFlat"]),
ImageType::Unknown => panic!("unsupported image_type {image_type}"),
};
let image_type_str = image_type.to_string();
@@ -1734,6 +1734,11 @@ mod common_parallel {
_test_virtio_block_direct_io_data_disk_4k(ImageType::Vhdx);
}
#[test]
fn test_virtio_block_direct_io_data_disk_4k_vmdk() {
_test_virtio_block_direct_io_data_disk_4k(ImageType::FlatVmdk);
}
#[test]
fn test_virtio_block_qcow2_dirty_bit_unclean_shutdown() {
let disk_config = UbuntuDiskConfig::new(JAMMY_IMAGE_NAME_QCOW2.to_string());
@@ -2044,6 +2049,42 @@ mod common_parallel {
_test_virtio_block_dynamic_vhdx_expand(&guest);
}
#[test]
fn test_virtio_block_vmdk_monolithic_flat() {
let guest = basic_regular_guest!(JAMMY_IMAGE_NAME);
_test_virtio_block_vmdk(&guest, "monolithicFlat");
}
#[test]
fn test_virtio_block_vmdk_two_gb_max_extent_flat() {
let guest = basic_regular_guest!(JAMMY_IMAGE_NAME);
_test_virtio_block_vmdk(&guest, "twoGbMaxExtentFlat");
}
#[test]
fn test_virtio_block_vmdk_monolithic_flat_enospc() {
let guest = basic_regular_guest!(JAMMY_IMAGE_NAME);
_test_virtio_block_vmdk_enospc(&guest, "monolithicFlat");
}
#[test]
fn test_virtio_block_vmdk_two_gb_max_extent_flat_enospc() {
let guest = basic_regular_guest!(JAMMY_IMAGE_NAME);
_test_virtio_block_vmdk_enospc(&guest, "twoGbMaxExtentFlat");
}
#[test]
fn test_virtio_block_vmdk_two_gb_max_extent_flat_spanning() {
let guest = basic_regular_guest!(JAMMY_IMAGE_NAME);
_test_virtio_block_vmdk_extent_spanning(&guest, false);
}
#[test]
fn test_virtio_block_vmdk_two_gb_max_extent_flat_spanning_direct() {
let guest = basic_regular_guest!(JAMMY_IMAGE_NAME);
_test_virtio_block_vmdk_extent_spanning(&guest, true);
}
#[test]
fn test_virtio_block_direct_and_firmware() {
let disk_config = UbuntuDiskConfig::new(JAMMY_IMAGE_NAME.to_string());