From 919cce9b5824ec3ae8174bf362a495d115d02e64 Mon Sep 17 00:00:00 2001 From: Anatol Belski Date: Sun, 25 Jan 2026 21:35:47 +0100 Subject: [PATCH] tests: qcow: Add dirty bit integration tests Add tests to verify dirty bit is set while VM runs and cleared on clean shutdown. As part of it, ensure graceful shutdown when OS disk verification requires consistent image state. Signed-off-by: Anatol Belski --- cloud-hypervisor/tests/integration.rs | 159 +++++++++++++++++++++++++- 1 file changed, 155 insertions(+), 4 deletions(-) diff --git a/cloud-hypervisor/tests/integration.rs b/cloud-hypervisor/tests/integration.rs index c8b31030f..6a7acfaa6 100644 --- a/cloud-hypervisor/tests/integration.rs +++ b/cloud-hypervisor/tests/integration.rs @@ -2528,7 +2528,7 @@ EOF mod common_parallel { use std::cmp; - use std::fs::{File, OpenOptions}; + use std::fs::{File, OpenOptions, copy}; use std::io::{self, SeekFrom}; use crate::*; @@ -3499,7 +3499,13 @@ mod common_parallel { ); }); - let _ = cloud_child.kill(); + if verify_os_disk { + // Use clean shutdown to allow cloud-hypervisor to clear + // the dirty bit in the QCOW2 v3 image. + kill_child(&mut cloud_child); + } else { + let _ = cloud_child.kill(); + } let output = cloud_child.wait_with_output().unwrap(); handle_child_output(r, &output); @@ -3537,12 +3543,25 @@ mod common_parallel { } fn get_image_info(path: &std::path::Path) -> Option { - let output = run_qemu_img(path, &["info", "--output=json"]); + let output = run_qemu_img(path, &["info", "-U", "--output=json"]); output.status.success().then(|| ())?; serde_json::from_slice(&output.stdout).ok() } + fn check_dirty_flag(path: &std::path::Path) -> Result, String> { + let info = get_image_info(path) + .ok_or_else(|| format!("qemu-img info failed for {}", path.display()))?; + if info["format"].as_str() != Some("qcow2") { + return Ok(None); + } + // QCOW2 v3 has compat "1.1", v2 has "0.10" and doesn't support dirty flag + if info["format-specific"]["data"]["compat"].as_str() != Some("1.1") { + return Ok(None); + } + Ok(info["dirty-flag"].as_bool()) + } + fn resolve_disk_path(path_or_image_name: impl AsRef) -> std::path::PathBuf { if path_or_image_name.as_ref().exists() { // A full path is provided @@ -3662,9 +3681,11 @@ mod common_parallel { /// It takes either a full path to the image or just the name of /// the image located in the `workloads` directory. /// - /// For qcow2 images with backing files, also verifies the backing file + /// For QCOW2 images with backing files, also verifies the backing file /// integrity and checks that the backing file hasn't been modified /// during the test. + /// + /// For QCOW2 v3 images, also verifies the dirty bit is cleared. fn disk_check_consistency( path_or_image_name: impl AsRef, initial_backing_checksum: Option<(std::path::PathBuf, String, u32)>, @@ -3678,6 +3699,14 @@ mod common_parallel { String::from_utf8_lossy(&output.stderr) ); + match check_dirty_flag(&path) { + Ok(Some(dirty)) => { + assert!(!dirty, "QCOW2 image shutdown unclean"); + } + Ok(None) => {} // Not a QCOW2 v3 image, skip dirty flag check + Err(e) => panic!("Failed to check dirty flag: {e}"), + } + if let Some((backing_path, format, initial_checksum)) = initial_backing_checksum { if format.parse::().ok() != Some(block::qcow::ImageType::Raw) { let output = run_qemu_img(&backing_path, &["check"]); @@ -3733,6 +3762,128 @@ mod common_parallel { _test_virtio_block(JAMMY_IMAGE_NAME_QCOW2_BACKING_RAW_FILE, false, false, true); } + #[test] + fn test_virtio_block_qcow2_dirty_bit_unclean_shutdown() { + let disk_config = UbuntuDiskConfig::new(JAMMY_IMAGE_NAME_QCOW2.to_string()); + let guest = Guest::new(Box::new(disk_config)); + let kernel_path = direct_kernel_boot_path(); + + let test_image_path = guest.tmp_dir.as_path().join("test-dirty.qcow2"); + let original_image = guest.disk_config.disk(DiskType::OperatingSystem).unwrap(); + + copy(original_image, &test_image_path).expect("Failed to copy qcow2 image"); + + assert_eq!( + check_dirty_flag(&test_image_path).expect("Failed to check dirty flag"), + Some(false), + "Image should start with dirty bit cleared" + ); + + let mut child = GuestCommand::new(&guest) + .args(["--cpus", "boot=1"]) + .args(["--memory", "size=512M"]) + .args(["--kernel", kernel_path.to_str().unwrap()]) + .args(["--cmdline", DIRECT_KERNEL_BOOT_CMDLINE]) + .args([ + "--disk", + &format!("path={}", test_image_path.to_str().unwrap()), + &format!( + "path={}", + guest.disk_config.disk(DiskType::CloudInit).unwrap() + ), + ]) + .default_net() + .capture_output() + .spawn() + .unwrap(); + + let r = std::panic::catch_unwind(|| { + guest.wait_vm_boot(None).unwrap(); + + assert_eq!( + check_dirty_flag(&test_image_path).expect("Failed to check dirty flag"), + Some(true), + "Dirty bit should be set while VM is running" + ); + }); + + if r.is_err() { + let _ = child.kill(); + let output = child.wait_with_output().unwrap(); + handle_child_output(r, &output); + return; + } + + // Simulate unclean shutdown with SIGKILL + let _ = unsafe { libc::kill(child.id() as i32, libc::SIGKILL) }; + let _ = child.wait(); + + assert_eq!( + check_dirty_flag(&test_image_path).expect("Failed to check dirty flag"), + Some(true), + "Dirty bit should remain set after unclean shutdown" + ); + } + + #[test] + fn test_virtio_block_qcow2_dirty_bit_clean_shutdown() { + let disk_config = UbuntuDiskConfig::new(JAMMY_IMAGE_NAME_QCOW2.to_string()); + let guest = Guest::new(Box::new(disk_config)); + let kernel_path = direct_kernel_boot_path(); + + let test_image_path = guest.tmp_dir.as_path().join("test-dirty.qcow2"); + let original_image = guest.disk_config.disk(DiskType::OperatingSystem).unwrap(); + + copy(original_image, &test_image_path).expect("Failed to copy qcow2 image"); + + assert_eq!( + check_dirty_flag(&test_image_path).expect("Failed to check dirty flag"), + Some(false), + "Image should start with dirty bit cleared" + ); + + let mut child = GuestCommand::new(&guest) + .args(["--cpus", "boot=1"]) + .args(["--memory", "size=512M"]) + .args(["--kernel", kernel_path.to_str().unwrap()]) + .args(["--cmdline", DIRECT_KERNEL_BOOT_CMDLINE]) + .args([ + "--disk", + &format!("path={}", test_image_path.to_str().unwrap()), + &format!( + "path={}", + guest.disk_config.disk(DiskType::CloudInit).unwrap() + ), + ]) + .default_net() + .capture_output() + .spawn() + .unwrap(); + + let r = std::panic::catch_unwind(|| { + guest.wait_vm_boot(None).unwrap(); + + assert_eq!( + check_dirty_flag(&test_image_path).expect("Failed to check dirty flag"), + Some(true), + "Dirty bit should be set while VM is running" + ); + }); + + // Clean shutdown using SIGTERM + kill_child(&mut child); + + if r.is_err() { + let output = child.wait_with_output().unwrap(); + handle_child_output(r, &output); + return; + } + + let _ = child.wait(); + + disk_check_consistency(&test_image_path, None); + } + #[test] fn test_virtio_block_vhd() { let mut workload_path = dirs::home_dir().unwrap();