From 95c084756ac6cf4e36c29edf29e55884fef68980 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Mon, 23 Mar 2026 13:30:45 +0000 Subject: [PATCH] itest: Add reusable integration test library crate We were growing too much duplication between projects around integration tests. Extract a shared helper library that we'll vendor via git crate dependencies. Assisted-by: OpenCode (Claude Opus 4.6) Signed-off-by: Colin Walters --- Cargo.lock | 143 +++++++--- crates/integration-tests/Cargo.toml | 7 +- crates/integration-tests/src/lib.rs | 184 ++----------- crates/integration-tests/src/main.rs | 64 ++--- .../src/tests/libvirt_base_disks.rs | 10 +- .../src/tests/libvirt_port_forward.rs | 12 +- .../src/tests/libvirt_verb.rs | 34 +-- .../src/tests/mount_feature.rs | 6 +- .../src/tests/run_ephemeral.rs | 28 +- .../src/tests/run_ephemeral_ssh.rs | 28 +- crates/integration-tests/src/tests/to_disk.rs | 18 +- crates/integration-tests/src/tests/varlink.rs | 46 ++-- crates/itest-selftest/Cargo.container.toml | 15 ++ crates/itest-selftest/Cargo.toml | 17 ++ crates/itest-selftest/Containerfile | 34 +++ crates/itest-selftest/Justfile | 22 ++ crates/itest-selftest/src/main.rs | 51 ++++ crates/itest-selftest/src/privileged.rs | 20 ++ crates/itest/Cargo.toml | 17 ++ crates/itest/src/harness.rs | 123 +++++++++ crates/itest/src/junit.rs | 52 ++++ crates/itest/src/lib.rs | 247 ++++++++++++++++++ crates/itest/src/privilege.rs | 106 ++++++++ 23 files changed, 942 insertions(+), 342 deletions(-) create mode 100644 crates/itest-selftest/Cargo.container.toml create mode 100644 crates/itest-selftest/Cargo.toml create mode 100644 crates/itest-selftest/Containerfile create mode 100644 crates/itest-selftest/Justfile create mode 100644 crates/itest-selftest/src/main.rs create mode 100644 crates/itest-selftest/src/privileged.rs create mode 100644 crates/itest/Cargo.toml create mode 100644 crates/itest/src/harness.rs create mode 100644 crates/itest/src/junit.rs create mode 100644 crates/itest/src/lib.rs create mode 100644 crates/itest/src/privilege.rs diff --git a/Cargo.lock b/Cargo.lock index 720ad29..67e985a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -60,7 +60,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" dependencies = [ "anstyle", - "anstyle-parse", + "anstyle-parse 0.2.7", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse 1.0.0", "anstyle-query", "anstyle-wincon", "colorchoice", @@ -83,6 +98,15 @@ dependencies = [ "utf8parse", ] +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + [[package]] name = "anstyle-query" version = "1.1.4" @@ -220,7 +244,7 @@ dependencies = [ "nix", "notify", "oci-spec", - "quick-xml", + "quick-xml 0.36.2", "rand", "regex", "reqwest", @@ -454,7 +478,7 @@ version = "4.5.48" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2ba64afa3c0a6df7fa517765e31314e983f51dda798ffba27b988194fb65dc9" dependencies = [ - "anstream", + "anstream 0.6.20", "anstyle", "clap_lex", "strsim", @@ -1448,16 +1472,15 @@ dependencies = [ name = "integration-tests" version = "0.1.0" dependencies = [ + "anyhow", "bcvk", "camino", "cap-std-ext", "cfg-if", - "color-eyre", "dirs", + "itest", "libc", - "libtest-mimic", "linkme", - "paste", "rand", "regex", "rustix", @@ -1538,6 +1561,27 @@ dependencies = [ "either", ] +[[package]] +name = "itest" +version = "0.1.0" +dependencies = [ + "libtest-mimic", + "linkme", + "paste", + "quick-junit", + "rustix", + "xshell", +] + +[[package]] +name = "itest-selftest" +version = "0.1.0" +dependencies = [ + "itest", + "linkme", + "rustix", +] + [[package]] name = "itoa" version = "1.0.15" @@ -1617,14 +1661,14 @@ dependencies = [ [[package]] name = "libtest-mimic" -version = "0.7.3" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc0bda45ed5b3a2904262c1bb91e526127aa70e7ef3758aba2ef93cf896b9b58" +checksum = "14e6ba06f0ade6e504aff834d7c34298e5155c6baca353cc6a4aaff2f9fd7f33" dependencies = [ + "anstream 1.0.0", + "anstyle", "clap", "escape8259", - "termcolor", - "threadpool", ] [[package]] @@ -1766,6 +1810,15 @@ dependencies = [ "tempfile", ] +[[package]] +name = "newtype-uuid" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c012d14ef788ab066a347d19e3dda699916c92293b05b85ba2c76b8c82d2830" +dependencies = [ + "uuid", +] + [[package]] name = "nix" version = "0.29.0" @@ -1825,16 +1878,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "num_cpus" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" -dependencies = [ - "hermit-abi", - "libc", -] - [[package]] name = "number_prefix" version = "0.4.0" @@ -2063,6 +2106,21 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-junit" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ee9342d671fae8d66b3ae9fd7a9714dfd089c04d2a8b1ec0436ef77aee15e5f" +dependencies = [ + "chrono", + "indexmap", + "newtype-uuid", + "quick-xml 0.38.4", + "strip-ansi-escapes", + "thiserror 2.0.17", + "uuid", +] + [[package]] name = "quick-xml" version = "0.36.2" @@ -2072,6 +2130,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.41" @@ -2508,6 +2575,15 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "strip-ansi-escapes" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" +dependencies = [ + "vte", +] + [[package]] name = "strsim" version = "0.11.1" @@ -2625,15 +2701,6 @@ dependencies = [ "windows-sys 0.61.1", ] -[[package]] -name = "termcolor" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", -] - [[package]] name = "thiserror" version = "1.0.69" @@ -2683,15 +2750,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "threadpool" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" -dependencies = [ - "num_cpus", -] - [[package]] name = "tinystr" version = "0.8.1" @@ -3048,6 +3106,15 @@ dependencies = [ "nix", ] +[[package]] +name = "vte" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" +dependencies = [ + "memchr", +] + [[package]] name = "walkdir" version = "2.5.0" diff --git a/crates/integration-tests/Cargo.toml b/crates/integration-tests/Cargo.toml index 0aeb8f3..05a9fbd 100644 --- a/crates/integration-tests/Cargo.toml +++ b/crates/integration-tests/Cargo.toml @@ -17,8 +17,10 @@ name = "test-cleanup" path = "src/bin/cleanup.rs" [dependencies] +itest = { path = "../itest" } bcvk = { path = "../kit" } -color-eyre = { workspace = true } +anyhow = "1" +linkme = "0.3" dirs = "5.0" tracing = { workspace = true } tracing-subscriber = { workspace = true } @@ -29,13 +31,10 @@ serde = { version = "1.0.199", features = ["derive"] } serde_json = "1.0.116" zlink = "0.4" tokio = { version = "1", features = ["rt", "net", "macros"] } -libtest-mimic = "0.7.3" tempfile = "3" uuid = { version = "1.18.1", features = ["v4"] } camino = "1.1.12" regex = "1" -linkme = "0.3.30" -paste = "1.0" rand = { workspace = true } scopeguard = "1" cap-std-ext = { workspace = true } diff --git a/crates/integration-tests/src/lib.rs b/crates/integration-tests/src/lib.rs index f2fd930..8a9b3e2 100644 --- a/crates/integration-tests/src/lib.rs +++ b/crates/integration-tests/src/lib.rs @@ -1,174 +1,24 @@ -//! Shared library code for integration tests +//! Shared library code for bcvk integration tests. //! -//! This module contains constants and utilities that are shared between -//! the main test binary and helper binaries like cleanup. +//! Re-exports the core test infrastructure from [`itest`] and adds +//! bcvk-specific constants. -// Unfortunately needed here to work with linkme +// linkme (via itest) requires unsafe for distributed slices #![allow(unsafe_code)] -/// Label used to identify containers created by integration tests +// Re-export everything consumers need from itest so that test modules +// can continue to write `use integration_tests::integration_test;`. +pub use itest::image_to_test_suffix; +pub use itest::integration_test; +pub use itest::parameterized_integration_test; +pub use itest::IntegrationTest; +pub use itest::ParameterizedIntegrationTest; +pub use itest::TestFn; +pub use itest::INTEGRATION_TESTS; +pub use itest::PARAMETERIZED_INTEGRATION_TESTS; + +/// Label used to identify containers created by integration tests. pub const INTEGRATION_TEST_LABEL: &str = "bcvk.integration-test=1"; -/// Label used to identify libvirt VMs created by integration tests +/// Label used to identify libvirt VMs created by integration tests. pub const LIBVIRT_INTEGRATION_TEST_LABEL: &str = "bcvk-integration"; - -/// A test function that returns a Result -pub type TestFn = fn() -> color_eyre::Result<()>; - -/// A parameterized test function that takes an image parameter -pub type ParameterizedTestFn = fn(&str) -> color_eyre::Result<()>; - -/// Metadata for a registered integration test -#[derive(Debug)] -pub struct IntegrationTest { - /// Name of the integration test - pub name: &'static str, - /// Test function to execute - pub f: TestFn, -} - -impl IntegrationTest { - /// Create a new integration test with the given name and function - pub const fn new(name: &'static str, f: TestFn) -> Self { - Self { name, f } - } -} - -/// Metadata for a parameterized integration test that runs once per image -#[derive(Debug)] -pub struct ParameterizedIntegrationTest { - /// Base name of the integration test (will be suffixed with image identifier) - pub name: &'static str, - /// Parameterized test function to execute - pub f: ParameterizedTestFn, -} - -impl ParameterizedIntegrationTest { - /// Create a new parameterized integration test with the given name and function - pub const fn new(name: &'static str, f: ParameterizedTestFn) -> Self { - Self { name, f } - } -} - -/// Distributed slice holding all registered integration tests -#[linkme::distributed_slice] -pub static INTEGRATION_TESTS: [IntegrationTest]; - -/// Distributed slice holding all registered parameterized integration tests -#[linkme::distributed_slice] -pub static PARAMETERIZED_INTEGRATION_TESTS: [ParameterizedIntegrationTest]; - -/// Register an integration test with less boilerplate. -/// -/// This macro generates the static registration for an integration test function. -/// -/// # Examples -/// -/// ```ignore -/// fn test_basic_functionality() -> Result<()> { -/// let sh = shell()?; -/// let bck = get_bck_command()?; -/// cmd!(sh, "{bck} some args").run()?; -/// Ok(()) -/// } -/// integration_test!(test_basic_functionality); -/// ``` -#[macro_export] -macro_rules! integration_test { - ($fn_name:ident) => { - ::paste::paste! { - #[::linkme::distributed_slice($crate::INTEGRATION_TESTS)] - static [<$fn_name:upper>]: $crate::IntegrationTest = - $crate::IntegrationTest::new(stringify!($fn_name), $fn_name); - } - }; -} - -/// Register a parameterized integration test with less boilerplate. -/// -/// This macro generates the static registration for a parameterized integration test function. -/// -/// # Examples -/// -/// ```ignore -/// fn test_with_image(image: &str) -> Result<()> { -/// let sh = shell()?; -/// let bck = get_bck_command()?; -/// cmd!(sh, "{bck} command {image}").run()?; -/// Ok(()) -/// } -/// parameterized_integration_test!(test_with_image); -/// ``` -#[macro_export] -macro_rules! parameterized_integration_test { - ($fn_name:ident) => { - ::paste::paste! { - #[::linkme::distributed_slice($crate::PARAMETERIZED_INTEGRATION_TESTS)] - static [<$fn_name:upper>]: $crate::ParameterizedIntegrationTest = - $crate::ParameterizedIntegrationTest::new(stringify!($fn_name), $fn_name); - } - }; -} - -/// Create a test suffix from an image name by replacing invalid characters with underscores -/// -/// Replaces all non-alphanumeric characters with `_` to create a predictable, filesystem-safe -/// test name suffix. -/// -/// Examples: -/// - "quay.io/fedora/fedora-bootc:42" -> "quay_io_fedora_fedora_bootc_42" -/// - "quay.io/centos-bootc/centos-bootc:stream10" -> "quay_io_centos_bootc_centos_bootc_stream10" -/// - "quay.io/image@sha256:abc123" -> "quay_io_image_sha256_abc123" -pub fn image_to_test_suffix(image: &str) -> String { - image.replace(|c: char| !c.is_alphanumeric(), "_") -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_image_to_test_suffix_basic() { - assert_eq!( - image_to_test_suffix("quay.io/fedora/fedora-bootc:42"), - "quay_io_fedora_fedora_bootc_42" - ); - } - - #[test] - fn test_image_to_test_suffix_stream() { - assert_eq!( - image_to_test_suffix("quay.io/centos-bootc/centos-bootc:stream10"), - "quay_io_centos_bootc_centos_bootc_stream10" - ); - } - - #[test] - fn test_image_to_test_suffix_digest() { - assert_eq!( - image_to_test_suffix("quay.io/image@sha256:abc123"), - "quay_io_image_sha256_abc123" - ); - } - - #[test] - fn test_image_to_test_suffix_complex() { - assert_eq!( - image_to_test_suffix("registry.example.com:5000/my-org/my-image:v1.2.3"), - "registry_example_com_5000_my_org_my_image_v1_2_3" - ); - } - - #[test] - fn test_image_to_test_suffix_only_alphanumeric() { - assert_eq!(image_to_test_suffix("simpleimage"), "simpleimage"); - } - - #[test] - fn test_image_to_test_suffix_special_chars() { - assert_eq!( - image_to_test_suffix("image/with@special:chars-here.now"), - "image_with_special_chars_here_now" - ); - } -} diff --git a/crates/integration-tests/src/main.rs b/crates/integration-tests/src/main.rs index 14186da..83e379b 100644 --- a/crates/integration-tests/src/main.rs +++ b/crates/integration-tests/src/main.rs @@ -2,16 +2,13 @@ use camino::Utf8Path; -use color_eyre::eyre::{eyre, Context}; -use color_eyre::Result; -use libtest_mimic::{Arguments, Trial}; +use anyhow::{anyhow, Context}; use serde_json::Value; use xshell::{cmd, Shell}; -// Re-export constants from lib for internal use +// Re-export from the lib crate for internal use pub(crate) use integration_tests::{ - image_to_test_suffix, integration_test, INTEGRATION_TESTS, INTEGRATION_TEST_LABEL, - LIBVIRT_INTEGRATION_TEST_LABEL, PARAMETERIZED_INTEGRATION_TESTS, + integration_test, INTEGRATION_TEST_LABEL, LIBVIRT_INTEGRATION_TEST_LABEL, }; mod tests { @@ -27,13 +24,13 @@ mod tests { } /// Create a new xshell Shell for running commands -pub(crate) fn shell() -> Result { - Shell::new().map_err(|e| eyre!("Failed to create shell: {}", e)) +pub(crate) fn shell() -> anyhow::Result { + Shell::new().map_err(|e| anyhow!("Failed to create shell: {}", e)) } /// Get the path to the bcvk binary, checking BCVK_PATH env var first, then falling back to "bcvk" -pub(crate) fn get_bck_command() -> Result { - if let Some(path) = std::env::var("BCVK_PATH").ok() { +pub(crate) fn get_bck_command() -> anyhow::Result { + if let Ok(path) = std::env::var("BCVK_PATH") { return Ok(path); } // Force the user to set this if we're running from the project dir @@ -41,11 +38,11 @@ pub(crate) fn get_bck_command() -> Result { .into_iter() .find(|p| Utf8Path::new(p).exists()) { - return Err(eyre!( + return Err(anyhow!( "Detected {path} - set BCVK_PATH={path} to run using this binary" )); } - return Ok("bcvk".to_owned()); + Ok("bcvk".to_owned()) } /// Get the primary bootc image to use for tests @@ -83,7 +80,7 @@ pub(crate) fn get_all_test_images() -> Vec { } } -fn test_images_list() -> Result<()> { +fn test_images_list() -> itest::TestResult { println!("Running test: bcvk images list --json"); let sh = shell()?; @@ -98,16 +95,12 @@ fn test_images_list() -> Result<()> { // Verify the structure and content of the JSON let images_array = images .as_array() - .ok_or_else(|| eyre!("Expected JSON array in output, got: {}", stdout))?; + .ok_or_else(|| anyhow!("Expected JSON array in output, got: {}", stdout))?; // Verify that the array contains valid image objects for (index, image) in images_array.iter().enumerate() { if !image.is_object() { - return Err(eyre!( - "Image entry {} is not a JSON object: {}", - index, - image - )); + return Err(anyhow!("Image entry {} is not a JSON object: {}", index, image).into()); } } @@ -121,32 +114,11 @@ fn test_images_list() -> Result<()> { integration_test!(test_images_list); fn main() { - let args = Arguments::from_args(); - - let mut tests: Vec = Vec::new(); - - // Collect regular tests from the distributed slice - tests.extend(INTEGRATION_TESTS.iter().map(|test| { - let name = test.name; - let f = test.f; - Trial::test(name, move || f().map_err(|e| format!("{:?}", e).into())) - })); - - // Collect parameterized tests and generate variants for each image - let all_images = get_all_test_images(); - for param_test in PARAMETERIZED_INTEGRATION_TESTS.iter() { - for image in &all_images { - let image = image.clone(); - let test_suffix = image_to_test_suffix(&image); - let test_name = format!("{}_{}", param_test.name, test_suffix); - let f = param_test.f; - - tests.push(Trial::test(test_name, move || { - f(&image).map_err(|e| format!("{:?}", e).into()) - })); - } - } + let config = itest::TestConfig { + report_name: "bcvk-integration-tests".into(), + suite_name: "integration".into(), + parameters: get_all_test_images(), + }; - // Run the tests and exit with the result - libtest_mimic::run(&args, tests).exit(); + itest::run_tests_with_config(config); } diff --git a/crates/integration-tests/src/tests/libvirt_base_disks.rs b/crates/integration-tests/src/tests/libvirt_base_disks.rs index 6ae372f..ab43fd6 100644 --- a/crates/integration-tests/src/tests/libvirt_base_disks.rs +++ b/crates/integration-tests/src/tests/libvirt_base_disks.rs @@ -6,8 +6,8 @@ //! - base-disks list command //! - base-disks prune command -use color_eyre::Result; use integration_tests::integration_test; +use itest::TestResult; use scopeguard::defer; use xshell::cmd; @@ -16,7 +16,7 @@ use regex::Regex; use crate::{get_bck_command, get_test_image, shell}; /// Test that base disk is created and reused for multiple VMs -fn test_base_disk_creation_and_reuse() -> Result<()> { +fn test_base_disk_creation_and_reuse() -> TestResult { let sh = shell()?; let bck = get_bck_command()?; let test_image = get_test_image(); @@ -103,7 +103,7 @@ fn test_base_disk_creation_and_reuse() -> Result<()> { integration_test!(test_base_disk_creation_and_reuse); /// Test base-disks list command -fn test_base_disks_list_command() -> Result<()> { +fn test_base_disks_list_command() -> TestResult { let sh = shell()?; let bck = get_bck_command()?; @@ -129,7 +129,7 @@ fn test_base_disks_list_command() -> Result<()> { integration_test!(test_base_disks_list_command); /// Test base-disks prune command with dry-run -fn test_base_disks_prune_dry_run() -> Result<()> { +fn test_base_disks_prune_dry_run() -> TestResult { let sh = shell()?; let bck = get_bck_command()?; @@ -151,7 +151,7 @@ fn test_base_disks_prune_dry_run() -> Result<()> { integration_test!(test_base_disks_prune_dry_run); /// Test that VM disks reference base disks correctly -fn test_vm_disk_references_base() -> Result<()> { +fn test_vm_disk_references_base() -> TestResult { let sh = shell()?; let bck = get_bck_command()?; let test_image = get_test_image(); diff --git a/crates/integration-tests/src/tests/libvirt_port_forward.rs b/crates/integration-tests/src/tests/libvirt_port_forward.rs index ded9d82..fb34a54 100644 --- a/crates/integration-tests/src/tests/libvirt_port_forward.rs +++ b/crates/integration-tests/src/tests/libvirt_port_forward.rs @@ -5,15 +5,15 @@ //! - QEMU netdev configuration with hostfwd //! - Actual network connectivity through forwarded ports -use color_eyre::Result; use integration_tests::integration_test; +use itest::TestResult; use scopeguard::defer; use xshell::cmd; use crate::{get_bck_command, get_test_image, shell, LIBVIRT_INTEGRATION_TEST_LABEL}; /// Test port forwarding argument parsing -fn test_libvirt_port_forward_parsing() -> Result<()> { +fn test_libvirt_port_forward_parsing() -> TestResult { let sh = shell()?; let bck = get_bck_command()?; @@ -41,7 +41,7 @@ fn test_libvirt_port_forward_parsing() -> Result<()> { integration_test!(test_libvirt_port_forward_parsing); /// Test port forwarding error handling for invalid formats -fn test_libvirt_port_forward_invalid() -> Result<()> { +fn test_libvirt_port_forward_invalid() -> TestResult { let sh = shell()?; let bck = get_bck_command()?; let test_image = get_test_image(); @@ -98,7 +98,7 @@ fn test_libvirt_port_forward_invalid() -> Result<()> { integration_test!(test_libvirt_port_forward_invalid); /// Test that port forwarding is correctly configured in domain XML -fn test_libvirt_port_forward_xml() -> Result<()> { +fn test_libvirt_port_forward_xml() -> TestResult { let sh = shell()?; let bck = get_bck_command()?; let test_image = get_test_image(); @@ -179,7 +179,7 @@ fn test_libvirt_port_forward_xml() -> Result<()> { integration_test!(test_libvirt_port_forward_xml); /// Test actual network connectivity through forwarded ports -fn test_libvirt_port_forward_connectivity() -> Result<()> { +fn test_libvirt_port_forward_connectivity() -> TestResult { let sh = shell()?; let bck = get_bck_command()?; let test_image = get_test_image(); @@ -354,7 +354,7 @@ fn cleanup_domain(domain_name: &str) { } /// Find an available port on the host -fn find_available_port() -> Result { +fn find_available_port() -> anyhow::Result { use std::net::TcpListener; // Try to bind to port 0, which will allocate an available port diff --git a/crates/integration-tests/src/tests/libvirt_verb.rs b/crates/integration-tests/src/tests/libvirt_verb.rs index db2c56f..a955b20 100644 --- a/crates/integration-tests/src/tests/libvirt_verb.rs +++ b/crates/integration-tests/src/tests/libvirt_verb.rs @@ -7,8 +7,8 @@ //! - `bcvk libvirt ssh` - SSH into domains //! - Domain lifecycle management (start/stop/rm/inspect) -use color_eyre::Result; use integration_tests::integration_test; +use itest::TestResult; use scopeguard::defer; use xshell::cmd; @@ -26,7 +26,7 @@ fn random_suffix() -> String { } /// Test libvirt list functionality (lists domains) -fn test_libvirt_list_functionality() -> Result<()> { +fn test_libvirt_list_functionality() -> TestResult { let sh = shell()?; let bck = get_bck_command()?; @@ -46,7 +46,7 @@ fn test_libvirt_list_functionality() -> Result<()> { integration_test!(test_libvirt_list_functionality); /// Test libvirt list with JSON output -fn test_libvirt_list_json_output() -> Result<()> { +fn test_libvirt_list_json_output() -> TestResult { let sh = shell()?; let bck = get_bck_command()?; @@ -67,7 +67,7 @@ fn test_libvirt_list_json_output() -> Result<()> { integration_test!(test_libvirt_list_json_output); /// Test domain resource configuration options -fn test_libvirt_run_resource_options() -> Result<()> { +fn test_libvirt_run_resource_options() -> TestResult { let sh = shell()?; let bck = get_bck_command()?; @@ -93,7 +93,7 @@ fn test_libvirt_run_resource_options() -> Result<()> { integration_test!(test_libvirt_run_resource_options); /// Test domain networking configuration -fn test_libvirt_run_networking() -> Result<()> { +fn test_libvirt_run_networking() -> TestResult { let sh = shell()?; let bck = get_bck_command()?; @@ -118,7 +118,7 @@ fn test_libvirt_run_networking() -> Result<()> { integration_test!(test_libvirt_run_networking); /// Test SSH integration with created domains (syntax only) -fn test_libvirt_ssh_integration() -> Result<()> { +fn test_libvirt_ssh_integration() -> TestResult { let sh = shell()?; let bck = get_bck_command()?; @@ -145,7 +145,7 @@ integration_test!(test_libvirt_ssh_integration); /// Comprehensive workflow test: creates a VM and tests multiple features /// This consolidates several smaller tests to reduce expensive disk image creation -fn test_libvirt_comprehensive_workflow() -> Result<()> { +fn test_libvirt_comprehensive_workflow() -> TestResult { let sh = shell()?; let bck = get_bck_command()?; let test_image = get_test_image(); @@ -390,7 +390,7 @@ fn cleanup_domain(domain_name: &str) { /// /// Creates a VM using cmd! with the given prefix and test image. /// Returns the created domain name on success. -fn create_test_vm_and_assert(domain_prefix: &str, test_image: &str) -> Result { +fn create_test_vm_and_assert(domain_prefix: &str, test_image: &str) -> anyhow::Result { let sh = shell()?; let bck = get_bck_command()?; let label = LIBVIRT_INTEGRATION_TEST_LABEL; @@ -409,7 +409,7 @@ fn create_test_vm_and_assert(domain_prefix: &str, test_image: &str) -> Result Result { +fn check_libvirt_supports_readonly_virtiofs() -> anyhow::Result { let sh = shell()?; let bck = get_bck_command()?; @@ -432,7 +432,7 @@ fn check_libvirt_supports_readonly_virtiofs() -> Result { } /// Test VM startup and shutdown with libvirt run -fn test_libvirt_run_vm_lifecycle() -> Result<()> { +fn test_libvirt_run_vm_lifecycle() -> TestResult { let sh = shell()?; let bck = get_bck_command()?; let test_volume = "test-vm-lifecycle"; @@ -512,7 +512,7 @@ fn test_libvirt_run_vm_lifecycle() -> Result<()> { integration_test!(test_libvirt_run_vm_lifecycle); /// Test container storage binding functionality end-to-end -fn test_libvirt_run_bind_storage_ro() -> Result<()> { +fn test_libvirt_run_bind_storage_ro() -> TestResult { // Check if libvirt supports readonly virtiofs (requires libvirt 11.0+) if !check_libvirt_supports_readonly_virtiofs()? { return Ok(()); @@ -618,7 +618,7 @@ fn test_libvirt_run_bind_storage_ro() -> Result<()> { integration_test!(test_libvirt_run_bind_storage_ro); /// Test that STORAGE_OPTS credentials are NOT injected when --bind-storage-ro is not used -fn test_libvirt_run_no_storage_opts_without_bind_storage() -> Result<()> { +fn test_libvirt_run_no_storage_opts_without_bind_storage() -> TestResult { let sh = shell()?; let bck = get_bck_command()?; let test_image = get_test_image(); @@ -692,7 +692,7 @@ fn test_libvirt_run_no_storage_opts_without_bind_storage() -> Result<()> { integration_test!(test_libvirt_run_no_storage_opts_without_bind_storage); /// Test print-firmware command (hidden debugging command) -fn test_libvirt_print_firmware() -> Result<()> { +fn test_libvirt_print_firmware() -> TestResult { let sh = shell()?; let bck = get_bck_command()?; @@ -727,7 +727,7 @@ fn test_libvirt_print_firmware() -> Result<()> { integration_test!(test_libvirt_print_firmware); /// Test error handling for invalid configurations -fn test_libvirt_error_handling() -> Result<()> { +fn test_libvirt_error_handling() -> TestResult { let sh = shell()?; let bck = get_bck_command()?; @@ -767,7 +767,7 @@ fn test_libvirt_error_handling() -> Result<()> { integration_test!(test_libvirt_error_handling); /// Test transient VM functionality -fn test_libvirt_run_transient_vm() -> Result<()> { +fn test_libvirt_run_transient_vm() -> TestResult { let sh = shell()?; let bck = get_bck_command()?; let test_image = get_test_image(); @@ -885,7 +885,7 @@ integration_test!(test_libvirt_run_transient_vm); /// 1. Create a transient VM /// 2. Replace it with another transient VM using --replace /// 3. Verify the replacement works (no errors about undefine on transient domains) -fn test_libvirt_run_transient_replace() -> Result<()> { +fn test_libvirt_run_transient_replace() -> TestResult { let sh = shell()?; let bck = get_bck_command()?; let test_image = get_test_image(); @@ -959,7 +959,7 @@ integration_test!(test_libvirt_run_transient_replace); /// Test automatic bind mount functionality with systemd mount units /// Also validates kernel argument (--karg) functionality -fn test_libvirt_run_bind_mounts() -> Result<()> { +fn test_libvirt_run_bind_mounts() -> TestResult { use camino::Utf8Path; use std::fs; use tempfile::TempDir; diff --git a/crates/integration-tests/src/tests/mount_feature.rs b/crates/integration-tests/src/tests/mount_feature.rs index c276776..e145f7d 100644 --- a/crates/integration-tests/src/tests/mount_feature.rs +++ b/crates/integration-tests/src/tests/mount_feature.rs @@ -15,8 +15,8 @@ //! - Warning and continuing on failures use camino::Utf8Path; -use color_eyre::Result; use integration_tests::integration_test; +use itest::TestResult; use std::fs; use tempfile::TempDir; @@ -71,7 +71,7 @@ StandardError=journal+console Ok(()) } -fn test_mount_feature_bind() -> Result<()> { +fn test_mount_feature_bind() -> TestResult { // Create a temporary directory to test bind mounting let temp_dir = TempDir::new().expect("Failed to create temp directory"); let temp_dir_path = Utf8Path::from_path(temp_dir.path()).expect("temp dir path is not utf8"); @@ -117,7 +117,7 @@ fn test_mount_feature_bind() -> Result<()> { } integration_test!(test_mount_feature_bind); -fn test_mount_feature_ro_bind() -> Result<()> { +fn test_mount_feature_ro_bind() -> TestResult { // Create a temporary directory to test read-only bind mounting let temp_dir = TempDir::new().expect("Failed to create temp directory"); let temp_dir_path = Utf8Path::from_path(temp_dir.path()).expect("temp dir path is not utf8"); diff --git a/crates/integration-tests/src/tests/run_ephemeral.rs b/crates/integration-tests/src/tests/run_ephemeral.rs index 221ec5a..234eeea 100644 --- a/crates/integration-tests/src/tests/run_ephemeral.rs +++ b/crates/integration-tests/src/tests/run_ephemeral.rs @@ -14,8 +14,8 @@ //! - "This is acceptable in CI/testing environments" //! - Warning and continuing on failures -use color_eyre::Result; use integration_tests::integration_test; +use itest::TestResult; use xshell::cmd; use std::fs; @@ -37,7 +37,7 @@ pub fn get_container_kernel_version(image: &str) -> String { .expect("Failed to get container kernel version") } -fn test_run_ephemeral_correct_kernel() -> Result<()> { +fn test_run_ephemeral_correct_kernel() -> TestResult { let sh = shell()?; let bck = get_bck_command()?; let image = get_test_image(); @@ -54,7 +54,7 @@ fn test_run_ephemeral_correct_kernel() -> Result<()> { } integration_test!(test_run_ephemeral_correct_kernel); -fn test_run_ephemeral_poweroff() -> Result<()> { +fn test_run_ephemeral_poweroff() -> TestResult { let sh = shell()?; let bck = get_bck_command()?; let image = get_test_image(); @@ -69,7 +69,7 @@ fn test_run_ephemeral_poweroff() -> Result<()> { } integration_test!(test_run_ephemeral_poweroff); -fn test_run_ephemeral_with_memory_limit() -> Result<()> { +fn test_run_ephemeral_with_memory_limit() -> TestResult { let sh = shell()?; let bck = get_bck_command()?; let image = get_test_image(); @@ -84,7 +84,7 @@ fn test_run_ephemeral_with_memory_limit() -> Result<()> { } integration_test!(test_run_ephemeral_with_memory_limit); -fn test_run_ephemeral_with_vcpus() -> Result<()> { +fn test_run_ephemeral_with_vcpus() -> TestResult { let sh = shell()?; let bck = get_bck_command()?; let image = get_test_image(); @@ -99,7 +99,7 @@ fn test_run_ephemeral_with_vcpus() -> Result<()> { } integration_test!(test_run_ephemeral_with_vcpus); -fn test_run_ephemeral_execute() -> Result<()> { +fn test_run_ephemeral_execute() -> TestResult { let sh = shell()?; let bck = get_bck_command()?; let image = get_test_image(); @@ -134,7 +134,7 @@ fn test_run_ephemeral_execute() -> Result<()> { } integration_test!(test_run_ephemeral_execute); -fn test_run_ephemeral_container_ssh_access() -> Result<()> { +fn test_run_ephemeral_container_ssh_access() -> TestResult { let sh = shell()?; let bck = get_bck_command()?; let image = get_test_image(); @@ -170,7 +170,7 @@ fn test_run_ephemeral_container_ssh_access() -> Result<()> { } integration_test!(test_run_ephemeral_container_ssh_access); -fn test_run_ephemeral_with_instancetype() -> Result<()> { +fn test_run_ephemeral_with_instancetype() -> TestResult { let sh = shell()?; let bck = get_bck_command()?; let image = get_test_image(); @@ -227,7 +227,7 @@ fn test_run_ephemeral_with_instancetype() -> Result<()> { } integration_test!(test_run_ephemeral_with_instancetype); -fn test_run_ephemeral_instancetype_invalid() -> Result<()> { +fn test_run_ephemeral_instancetype_invalid() -> TestResult { let sh = shell()?; let bck = get_bck_command()?; let image = get_test_image(); @@ -262,7 +262,7 @@ integration_test!(test_run_ephemeral_instancetype_invalid); /// /// This tests compatibility with bootc images that only ship a Unified Kernel Image, /// verifying that bcvk can extract kernel/initramfs from the UKI using objcopy. -fn test_run_ephemeral_uki_only() -> Result<()> { +fn test_run_ephemeral_uki_only() -> TestResult { let sh = shell()?; let base_image = get_test_image(); let uki_image = "bcvk-test-uki-only:latest"; @@ -334,7 +334,7 @@ integration_test!(test_run_ephemeral_uki_only); /// /// This tests a real-world UKI image that may have both UKI and traditional /// kernel files, verifying that bcvk correctly prefers the UKI. -fn test_run_ephemeral_centos_uki() -> Result<()> { +fn test_run_ephemeral_centos_uki() -> TestResult { const CENTOS_UKI_IMAGE: &str = "ghcr.io/bootc-dev/dev-bootc:centos-10-uki"; debug!("Testing ephemeral boot with {}", CENTOS_UKI_IMAGE); @@ -372,7 +372,7 @@ integration_test!(test_run_ephemeral_centos_uki); /// /// This test verifies that shared libraries can be loaded (which requires mmap()) /// and that we can explicitly mmap a file from the virtiofs root. -fn test_run_ephemeral_virtiofs_mmap() -> Result<()> { +fn test_run_ephemeral_virtiofs_mmap() -> TestResult { let sh = shell()?; let bck = get_bck_command()?; let image = get_test_image(); @@ -407,7 +407,7 @@ integration_test!(test_run_ephemeral_virtiofs_mmap); /// - / is read-only virtiofs /// - /etc is overlayfs with tmpfs upper (writable) /// - /var is tmpfs (not overlayfs, so podman can use overlayfs inside) -fn test_run_ephemeral_mount_layout() -> Result<()> { +fn test_run_ephemeral_mount_layout() -> TestResult { let sh = shell()?; let bck = get_bck_command()?; let image = get_test_image(); @@ -470,7 +470,7 @@ integration_test!(test_run_ephemeral_mount_layout); /// (which inject_systemd_units() knows how to copy), let the system boot /// normally, then use --execute to check the journal for the expected /// "ordering cycle" diagnostic. -fn test_run_ephemeral_detect_ordering_cycle() -> Result<()> { +fn test_run_ephemeral_detect_ordering_cycle() -> TestResult { let sh = shell()?; let bck = get_bck_command()?; let label = INTEGRATION_TEST_LABEL; diff --git a/crates/integration-tests/src/tests/run_ephemeral_ssh.rs b/crates/integration-tests/src/tests/run_ephemeral_ssh.rs index 635417b..c257bbc 100644 --- a/crates/integration-tests/src/tests/run_ephemeral_ssh.rs +++ b/crates/integration-tests/src/tests/run_ephemeral_ssh.rs @@ -14,8 +14,8 @@ //! - "This is acceptable in CI/testing environments" //! - Warning and continuing on failures -use color_eyre::Result; use integration_tests::{integration_test, parameterized_integration_test}; +use itest::TestResult; use xshell::cmd; use std::time::{Duration, Instant}; @@ -26,7 +26,7 @@ use crate::{get_bck_command, get_test_image, shell, INTEGRATION_TEST_LABEL}; /// /// Returns Ok(()) if container is removed within timeout, Err otherwise. /// Timeout is set to 60 seconds to account for slow CI runners. -fn wait_for_container_removal(container_name: &str) -> Result<()> { +fn wait_for_container_removal(container_name: &str) -> anyhow::Result<()> { let sh = shell()?; let timeout = Duration::from_secs(60); let start = Instant::now(); @@ -43,7 +43,7 @@ fn wait_for_container_removal(container_name: &str) -> Result<()> { } if start.elapsed() >= timeout { - return Err(color_eyre::eyre::eyre!( + return Err(anyhow::anyhow!( "Timeout waiting for container {} to be removed. Active containers: {}", container_name, containers @@ -55,7 +55,7 @@ fn wait_for_container_removal(container_name: &str) -> Result<()> { } /// Build a test fixture image with the kernel removed -fn build_broken_image() -> Result { +fn build_broken_image() -> anyhow::Result { let sh = shell()?; let fixture_path = concat!(env!("CARGO_MANIFEST_DIR"), "/fixtures/Dockerfile.no-kernel"); let image_name = format!("localhost/bcvk-test-no-kernel:{}", std::process::id()); @@ -71,7 +71,7 @@ fn build_broken_image() -> Result { } /// Test running a non-interactive command via SSH -fn test_run_ephemeral_ssh_command() -> Result<()> { +fn test_run_ephemeral_ssh_command() -> TestResult { let sh = shell()?; let bck = get_bck_command()?; let image = get_test_image(); @@ -93,7 +93,7 @@ fn test_run_ephemeral_ssh_command() -> Result<()> { integration_test!(test_run_ephemeral_ssh_command); /// Test that the container is cleaned up when SSH exits -fn test_run_ephemeral_ssh_cleanup() -> Result<()> { +fn test_run_ephemeral_ssh_cleanup() -> TestResult { let sh = shell()?; let bck = get_bck_command()?; let image = get_test_image(); @@ -114,7 +114,7 @@ fn test_run_ephemeral_ssh_cleanup() -> Result<()> { integration_test!(test_run_ephemeral_ssh_cleanup); /// Test running system commands via SSH -fn test_run_ephemeral_ssh_system_command() -> Result<()> { +fn test_run_ephemeral_ssh_system_command() -> TestResult { let sh = shell()?; let bck = get_bck_command()?; let image = get_test_image(); @@ -130,7 +130,7 @@ fn test_run_ephemeral_ssh_system_command() -> Result<()> { integration_test!(test_run_ephemeral_ssh_system_command); /// Test that ephemeral run-ssh properly forwards exit codes -fn test_run_ephemeral_ssh_exit_code() -> Result<()> { +fn test_run_ephemeral_ssh_exit_code() -> TestResult { let sh = shell()?; let bck = get_bck_command()?; let image = get_test_image(); @@ -157,7 +157,7 @@ integration_test!(test_run_ephemeral_ssh_exit_code); /// This parameterized test runs once per image in BCVK_ALL_IMAGES and verifies /// that our systemd version compatibility fix works correctly with both newer /// systemd (Fedora) and older systemd (CentOS Stream 9) -fn test_run_ephemeral_ssh_cross_distro_compatibility(image: &str) -> Result<()> { +fn test_run_ephemeral_ssh_cross_distro_compatibility(image: &str) -> TestResult { let sh = shell()?; let bck = get_bck_command()?; let label = INTEGRATION_TEST_LABEL; @@ -211,7 +211,7 @@ fn test_run_ephemeral_ssh_cross_distro_compatibility(image: &str) -> Result<()> parameterized_integration_test!(test_run_ephemeral_ssh_cross_distro_compatibility); /// Test that /run is mounted as tmpfs and supports unix domain sockets -fn test_run_tmpfs() -> Result<()> { +fn test_run_tmpfs() -> TestResult { use std::fs; use tempfile::TempDir; @@ -282,7 +282,7 @@ integration_test!(test_run_tmpfs); /// when ephemeral run-ssh fails early due to a broken image (missing kernel). /// Previously this would fail with "setns `mnt`: Bad file descriptor" when using /// podman's --rm flag. Now it should fail cleanly and remove the container. -fn test_run_ephemeral_ssh_broken_image_cleanup() -> Result<()> { +fn test_run_ephemeral_ssh_broken_image_cleanup() -> TestResult { // Build a broken test image (bootc image with kernel removed) eprintln!("Building broken test image..."); let broken_image = build_broken_image()?; @@ -335,7 +335,7 @@ integration_test!(test_run_ephemeral_ssh_broken_image_cleanup); /// /// Verifies that ephemeral bootc VMs can access the network and resolve DNS correctly. /// Uses HTTP request to quay.io to test both DNS resolution and network connectivity. -fn test_run_ephemeral_dns_resolution() -> Result<()> { +fn test_run_ephemeral_dns_resolution() -> TestResult { let sh = shell()?; let bck = get_bck_command()?; let image = get_test_image(); @@ -364,7 +364,7 @@ integration_test!(test_run_ephemeral_dns_resolution); /// Note: This tests the ephemeral timeout (~240s), not the libvirt SSH timeout (~60s). /// The libvirt SSH timeout (60s) is used by `bcvk libvirt ssh` and would require /// creating a libvirt VM to test properly. -fn test_run_ephemeral_ssh_timeout() -> Result<()> { +fn test_run_ephemeral_ssh_timeout() -> TestResult { eprintln!("Testing SSH timeout with masked sshd.service..."); eprintln!("This test takes ~240 seconds to complete..."); @@ -443,7 +443,7 @@ fn parse_journal_entries(output: &str) -> Vec { /// /// Uses `journalctl -o json` for structured output parsed with serde, /// avoiding brittle text parsing of human-readable journal formats. -fn test_systemd_health_cross_distro(image: &str) -> Result<()> { +fn test_systemd_health_cross_distro(image: &str) -> TestResult { let sh = shell()?; let bck = get_bck_command()?; let label = INTEGRATION_TEST_LABEL; diff --git a/crates/integration-tests/src/tests/to_disk.rs b/crates/integration-tests/src/tests/to_disk.rs index 0a5c08b..bc68c41 100644 --- a/crates/integration-tests/src/tests/to_disk.rs +++ b/crates/integration-tests/src/tests/to_disk.rs @@ -17,8 +17,8 @@ use std::process::Output; use camino::Utf8PathBuf; -use color_eyre::Result; use integration_tests::{integration_test, parameterized_integration_test}; +use itest::TestResult; use xshell::cmd; use tempfile::TempDir; @@ -34,7 +34,11 @@ use crate::{get_bck_command, get_test_image, shell, INTEGRATION_TEST_LABEL}; /// /// Note: sfdisk can only read partition tables from raw disk images, not qcow2. /// For qcow2 images, partition validation is skipped. -fn validate_disk_image(disk_path: &Utf8PathBuf, output: &Output, context: &str) -> Result<()> { +fn validate_disk_image( + disk_path: &Utf8PathBuf, + output: &Output, + context: &str, +) -> anyhow::Result<()> { let metadata = std::fs::metadata(disk_path).expect("Failed to get disk metadata"); assert!(metadata.len() > 0, "{}: Disk image is empty", context); @@ -76,7 +80,7 @@ fn validate_disk_image(disk_path: &Utf8PathBuf, output: &Output, context: &str) } /// Test actual bootc installation to a disk image -fn test_to_disk() -> Result<()> { +fn test_to_disk() -> TestResult { let sh = shell()?; let bck = get_bck_command()?; let label = INTEGRATION_TEST_LABEL; @@ -93,7 +97,7 @@ fn test_to_disk() -> Result<()> { integration_test!(test_to_disk); /// Test bootc installation to a qcow2 disk image -fn test_to_disk_qcow2() -> Result<()> { +fn test_to_disk_qcow2() -> TestResult { let sh = shell()?; let bck = get_bck_command()?; let label = INTEGRATION_TEST_LABEL; @@ -124,7 +128,7 @@ fn test_to_disk_qcow2() -> Result<()> { integration_test!(test_to_disk_qcow2); /// Test disk image caching functionality -fn test_to_disk_caching() -> Result<()> { +fn test_to_disk_caching() -> TestResult { let sh = shell()?; let bck = get_bck_command()?; let label = INTEGRATION_TEST_LABEL; @@ -176,7 +180,7 @@ fn test_to_disk_caching() -> Result<()> { integration_test!(test_to_disk_caching); /// Test that different image references with the same digest create separate cached disks -fn test_to_disk_different_imgref_same_digest() -> Result<()> { +fn test_to_disk_different_imgref_same_digest() -> TestResult { let sh = shell()?; let bck = get_bck_command()?; let label = INTEGRATION_TEST_LABEL; @@ -232,7 +236,7 @@ integration_test!(test_to_disk_different_imgref_same_digest); /// /// This parameterized test runs to-disk with multiple container images, /// particularly testing AlmaLinux which had cross-device link issues (issue #125) -fn test_to_disk_for_image(image: &str) -> Result<()> { +fn test_to_disk_for_image(image: &str) -> TestResult { let sh = shell()?; let bck = get_bck_command()?; let label = INTEGRATION_TEST_LABEL; diff --git a/crates/integration-tests/src/tests/varlink.rs b/crates/integration-tests/src/tests/varlink.rs index a56eaa1..f28757c 100644 --- a/crates/integration-tests/src/tests/varlink.rs +++ b/crates/integration-tests/src/tests/varlink.rs @@ -16,7 +16,7 @@ use std::process::Command; use std::sync::{Arc, OnceLock}; use cap_std_ext::cmdext::CapStdExtCommandExt; -use color_eyre::Result; +use itest::TestResult; use serde::Deserialize; use crate::{get_bck_command, get_test_image, integration_test, shell}; @@ -201,7 +201,7 @@ struct ActivatedBcvk { /// The child process is bound to the calling thread via /// `lifecycle_bind_to_parent_thread`, so it is automatically killed when the /// test thread exits. This must NOT be called inside `spawn_blocking`. -fn activated_connection() -> Result { +fn activated_connection() -> anyhow::Result { let bck = get_bck_command()?; let (ours, theirs) = UnixStream::pair()?; let theirs_fd: Arc = Arc::new(theirs.into()); @@ -245,7 +245,7 @@ fn cleanup_container(id: &str) { // =========================================================================== /// Verify that the images `List` method returns a vec of image name strings. -fn test_varlink_images_list() -> Result<()> { +fn test_varlink_images_list() -> TestResult { let mut bcvk = activated_connection()?; let reply = bcvk.rt.block_on(async { bcvk.conn.list().await })??; // In CI there may be no bootc images; just verify deserialization succeeds. @@ -260,7 +260,7 @@ integration_test!(test_varlink_images_list); /// /// This test pulls the primary test image (which has the `containers.bootc=1` /// label) and then verifies it appears in the varlink List response. -fn test_varlink_images_list_contains_test_image() -> Result<()> { +fn test_varlink_images_list_contains_test_image() -> TestResult { let image = get_test_image(); // Ensure the image is pulled @@ -284,7 +284,7 @@ integration_test!(test_varlink_images_list_contains_test_image); // =========================================================================== /// Verify that the ephemeral `Ps` method returns container ID strings. -fn test_varlink_ephemeral_ps() -> Result<()> { +fn test_varlink_ephemeral_ps() -> TestResult { let mut bcvk = activated_connection()?; let reply = bcvk.rt.block_on(async { bcvk.conn.ps().await })??; for id in &reply.container_ids { @@ -295,7 +295,7 @@ fn test_varlink_ephemeral_ps() -> Result<()> { integration_test!(test_varlink_ephemeral_ps); /// Test that `Run` with a nonexistent image returns an error. -fn test_varlink_ephemeral_run_bad_image() -> Result<()> { +fn test_varlink_ephemeral_run_bad_image() -> TestResult { let mut bcvk = activated_connection()?; let result = bcvk.rt.block_on(async { bcvk.conn @@ -307,17 +307,18 @@ fn test_varlink_ephemeral_run_bad_image() -> Result<()> { })?; match result { Err(EphemeralError::PodmanError { .. }) => Ok(()), - Ok(reply) => Err(color_eyre::eyre::eyre!( + Ok(reply) => Err(anyhow::anyhow!( "expected error for nonexistent image, got container_id: {}", reply.container_id - )), + ) + .into()), } } integration_test!(test_varlink_ephemeral_run_bad_image); /// End-to-end test: Run a VM, verify it in Ps, get SSH connection info, /// and actually SSH into it using the returned values. -fn test_varlink_ephemeral_run_ps_and_ssh() -> Result<()> { +fn test_varlink_ephemeral_run_ps_and_ssh() -> TestResult { let image = get_test_image(); let mut bcvk = activated_connection()?; @@ -390,9 +391,10 @@ fn test_varlink_ephemeral_run_ps_and_ssh() -> Result<()> { Ok(status) if status.success() => break, _ if std::time::Instant::now() > deadline => { cleanup_container(&run_reply.container_id); - return Err(color_eyre::eyre::eyre!( + return Err(anyhow::anyhow!( "SSH did not become ready within 120s using info from GetSshConnectionInfo" - )); + ) + .into()); } _ => std::thread::sleep(std::time::Duration::from_secs(2)), } @@ -409,7 +411,7 @@ integration_test!(test_varlink_ephemeral_run_ps_and_ssh); // =========================================================================== /// Test that `ToDisk` with a nonexistent image returns a `Failed` error. -fn test_varlink_todisk_bad_image() -> Result<()> { +fn test_varlink_todisk_bad_image() -> TestResult { let mut bcvk = activated_connection()?; let target = tempfile::NamedTempFile::new()?; let target_path = target.path().to_str().unwrap().to_string(); @@ -431,16 +433,17 @@ fn test_varlink_todisk_bad_image() -> Result<()> { })?; match result { Err(ToDiskError::Failed { .. }) => Ok(()), - Ok(reply) => Err(color_eyre::eyre::eyre!( + Ok(reply) => Err(anyhow::anyhow!( "expected Failed error for nonexistent image, got path: {}", reply.path - )), + ) + .into()), } } integration_test!(test_varlink_todisk_bad_image); /// Test that `ToDisk` rejects invalid format strings. -fn test_varlink_todisk_bad_format() -> Result<()> { +fn test_varlink_todisk_bad_format() -> TestResult { let mut bcvk = activated_connection()?; let td = tempfile::TempDir::new()?; let target_path = td.path().join("disk.img"); @@ -468,10 +471,11 @@ fn test_varlink_todisk_bad_format() -> Result<()> { ); Ok(()) } - Ok(reply) => Err(color_eyre::eyre::eyre!( + Ok(reply) => Err(anyhow::anyhow!( "expected Failed error for invalid format, got path: {}", reply.path - )), + ) + .into()), } } integration_test!(test_varlink_todisk_bad_format); @@ -481,7 +485,7 @@ integration_test!(test_varlink_todisk_bad_format); /// This is a heavyweight test that launches a VM internally. It verifies /// the reply contains a valid path, that the file exists, and that it is /// not marked as cached (first run). -fn test_varlink_todisk_creates_disk() -> Result<()> { +fn test_varlink_todisk_creates_disk() -> TestResult { let image = get_test_image(); let td = tempfile::TempDir::new()?; let target_path = td.path().join("test-disk.raw"); @@ -578,7 +582,7 @@ fn varlinkctl_is_compatible() -> bool { /// too old or suffers from the zlink introspection deserialization bug /// (), the cross-check is /// skipped with a log message. -fn test_varlink_images_list_crosscheck() -> Result<()> { +fn test_varlink_images_list_crosscheck() -> TestResult { let image = get_test_image(); // Ensure the test image is pulled so we have at least one image to compare @@ -646,7 +650,7 @@ integration_test!(test_varlink_images_list_crosscheck); /// /// Skipped when `varlinkctl` is not compatible with the zlink server /// (e.g. systemd < 258 due to ). -fn test_varlink_exec_varlinkctl() -> Result<()> { +fn test_varlink_exec_varlinkctl() -> TestResult { if !varlinkctl_is_compatible() { eprintln!( "note: skipping test_varlink_exec_varlinkctl (varlinkctl missing or incompatible, \ @@ -669,7 +673,7 @@ integration_test!(test_varlink_exec_varlinkctl); /// Test that `varlinkctl introspect` shows all three interface names. /// /// Skipped when `varlinkctl` is not compatible with the zlink server. -fn test_varlink_introspect_varlinkctl() -> Result<()> { +fn test_varlink_introspect_varlinkctl() -> TestResult { if !varlinkctl_is_compatible() { eprintln!( "note: skipping test_varlink_introspect_varlinkctl (varlinkctl missing or incompatible, \ diff --git a/crates/itest-selftest/Cargo.container.toml b/crates/itest-selftest/Cargo.container.toml new file mode 100644 index 0000000..f7b910c --- /dev/null +++ b/crates/itest-selftest/Cargo.container.toml @@ -0,0 +1,15 @@ +# Workspace root for container builds. +# +# This is used by the Containerfile to create a minimal workspace +# containing only itest and itest-selftest, without needing the +# full bcvk workspace. + +[workspace] +members = ["itest-selftest"] +resolver = "2" + +[workspace.dependencies] +xshell = "0.2.7" + +[workspace.lints.rust] +unsafe_code = "deny" diff --git a/crates/itest-selftest/Cargo.toml b/crates/itest-selftest/Cargo.toml new file mode 100644 index 0000000..09bc25d --- /dev/null +++ b/crates/itest-selftest/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "itest-selftest" +version = "0.1.0" +edition = "2021" +publish = false + +[[bin]] +name = "itest-selftest" +path = "src/main.rs" + +[dependencies] +itest = { path = "../itest" } +linkme = "0.3" +rustix = { version = "1", default-features = false, features = ["process"] } + +[lints] +workspace = true diff --git a/crates/itest-selftest/Containerfile b/crates/itest-selftest/Containerfile new file mode 100644 index 0000000..09af5ab --- /dev/null +++ b/crates/itest-selftest/Containerfile @@ -0,0 +1,34 @@ +# Build the itest-selftest binary and bake it into a bootc image. +# +# Build context must be the bcvk repo root: +# podman build -t localhost/itest-selftest:latest \ +# -f crates/itest-selftest/Containerfile . + +ARG base=quay.io/centos-bootc/centos-bootc:stream10 + +FROM $base AS build +RUN dnf -y install cargo rust && dnf clean all + +# Copy only the crates needed to compile itest-selftest. +# We give it its own workspace so we don't need the rest of bcvk. +COPY crates/itest /build/crates/itest +COPY crates/itest-selftest /build/crates/itest-selftest + +WORKDIR /build/crates/itest-selftest + +# Use a minimal workspace root so we don't need the full bcvk workspace. +COPY crates/itest-selftest/Cargo.container.toml /build/crates/Cargo.toml + +RUN --mount=type=cache,target=/root/.cargo/registry \ + --mount=type=cache,target=/root/.cargo/git \ + cargo fetch --manifest-path /build/crates/Cargo.toml + +RUN --network=none \ + --mount=type=cache,target=/root/.cargo/registry \ + --mount=type=cache,target=/root/.cargo/git \ + --mount=type=cache,target=/build/crates/target \ + cargo build --release --manifest-path /build/crates/Cargo.toml && \ + install -m 755 /build/crates/target/release/itest-selftest /usr/bin/itest-selftest + +FROM $base +COPY --from=build /usr/bin/itest-selftest /usr/bin/itest-selftest diff --git a/crates/itest-selftest/Justfile b/crates/itest-selftest/Justfile new file mode 100644 index 0000000..2ece80e --- /dev/null +++ b/crates/itest-selftest/Justfile @@ -0,0 +1,22 @@ +image := "localhost/itest-selftest:latest" +bcvk := env("BCVK_PATH", "bcvk") + +# Build the selftest container image +build: + podman build -t {{ image }} -f Containerfile ../.. + +# Run all tests (build image first if needed) +test *ARGS: build + {{ bcvk }} ephemeral run-ssh {{ image }} -- itest-selftest {{ ARGS }} + +# Run all tests against a pre-built image (skip rebuild) +test-quick *ARGS: + {{ bcvk }} ephemeral run-ssh {{ image }} -- itest-selftest {{ ARGS }} + +# List available tests +list: build + {{ bcvk }} ephemeral run-ssh {{ image }} -- itest-selftest --list + +# Clean up +clean: + -podman rmi {{ image }} diff --git a/crates/itest-selftest/src/main.rs b/crates/itest-selftest/src/main.rs new file mode 100644 index 0000000..776f467 --- /dev/null +++ b/crates/itest-selftest/src/main.rs @@ -0,0 +1,51 @@ +//! Self-tests for the itest integration test framework. +//! +//! This binary exercises every major feature of itest by actually +//! running privileged tests inside bcvk VMs. It is NOT a unit test +//! target — it requires a container image with this binary baked in. +//! +//! Build the image and run via the Justfile: +//! +//! cd crates/itest-selftest && just + +#![allow(unsafe_code)] + +mod privileged; + +// ── Unprivileged tests ────────────────────────────────────────────── + +/// Simplest possible test: proves registration and harness work. +fn selftest_register_and_pass() -> itest::TestResult { + Ok(()) +} +itest::integration_test!(selftest_register_and_pass); + +/// Verify the test process can introspect its own environment. +fn selftest_env_sanity() -> itest::TestResult { + let _ = std::env::current_exe()?; + Ok(()) +} +itest::integration_test!(selftest_env_sanity); + +// ── Parameterized tests ───────────────────────────────────────────── + +/// Verifies that the parameter is actually forwarded and non-empty. +fn selftest_parameterized(param: &str) -> itest::TestResult { + if param.is_empty() { + return Err("parameter must not be empty".into()); + } + Ok(()) +} +itest::parameterized_integration_test!(selftest_parameterized); + +// ── Harness entry point ───────────────────────────────────────────── + +fn main() { + let config = itest::TestConfig { + report_name: "itest-selftest".into(), + suite_name: "selftest".into(), + parameters: vec!["alpha".into(), "beta".into()], + }; + + itest::run_tests_with_config(config); +} diff --git a/crates/itest-selftest/src/privileged.rs b/crates/itest-selftest/src/privileged.rs new file mode 100644 index 0000000..fa45102 --- /dev/null +++ b/crates/itest-selftest/src/privileged.rs @@ -0,0 +1,20 @@ +//! Privileged self-tests for the itest framework. +//! +//! These tests use `itest::privileged_test!` — the exact same macro +//! that consumers like ostree and bootc use. When run without root +//! they auto-dispatch to a bcvk ephemeral VM (which must have the +//! binary installed — see the Containerfile). +//! +//! To run: +//! cd crates/itest-selftest && just + +/// Binary name as installed inside the container image. +const BIN: &str = "itest-selftest"; + +itest::privileged_test!(BIN, selftest_is_root, { + if !rustix::process::getuid().is_root() { + let e: itest::TestError = "expected to be running as root (uid 0)".into(); + return Err(e); + } + Ok(()) +}); diff --git a/crates/itest/Cargo.toml b/crates/itest/Cargo.toml new file mode 100644 index 0000000..4246afe --- /dev/null +++ b/crates/itest/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "itest" +version = "0.1.0" +edition = "2021" +publish = false +description = "Reusable integration test infrastructure for bootc-dev projects" + +[dependencies] +libtest-mimic = "0.8" +linkme = "0.3" +paste = "1" +quick-junit = "0.5" +rustix = { version = "1", default-features = false, features = ["process"] } +xshell = { workspace = true } + +[lints] +workspace = true diff --git a/crates/itest/src/harness.rs b/crates/itest/src/harness.rs new file mode 100644 index 0000000..277db2b --- /dev/null +++ b/crates/itest/src/harness.rs @@ -0,0 +1,123 @@ +//! Test harness that wires libtest-mimic, distributed slices, and +//! optional JUnit output together. + +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +use libtest_mimic::{Arguments, Trial}; + +use crate::junit::{write_junit, TestOutcome}; +use crate::{image_to_test_suffix, INTEGRATION_TESTS, PARAMETERIZED_INTEGRATION_TESTS}; + +/// Per-project configuration for the test harness. +#[derive(Debug, Clone)] +pub struct TestConfig { + /// Name used in JUnit XML reports (e.g. the binary name). + /// Defaults to `"integration-tests"`. + pub report_name: String, + + /// Suite name inside JUnit XML. Defaults to `"integration"`. + pub suite_name: String, + + /// Parameter values for [`ParameterizedIntegrationTest`]s. + /// + /// Each parameterised test is expanded once per entry. For + /// image-based testing this is typically a list of container + /// image references. If empty, parameterised tests are skipped. + pub parameters: Vec, +} + +impl Default for TestConfig { + fn default() -> Self { + Self { + report_name: "integration-tests".into(), + suite_name: "integration".into(), + parameters: Vec::new(), + } + } +} + +/// Run all registered tests using default configuration and no +/// parameters. +/// +/// Equivalent to `run_tests_with_config(TestConfig::default())`. +pub fn run_tests() -> ! { + run_tests_with_config(TestConfig::default()) +} + +/// Run all registered tests with the given configuration. +/// +/// This function collects tests from the global distributed slices, +/// expands parameterised variants, runs them through libtest-mimic, +/// optionally writes JUnit XML, and exits the process. +pub fn run_tests_with_config(config: TestConfig) -> ! { + let args = Arguments::from_args(); + let outcomes: Arc>> = Arc::new(Mutex::new(Vec::new())); + + let mut tests: Vec = Vec::new(); + + // Collect plain tests + for t in INTEGRATION_TESTS.iter() { + let f = t.f; + let name = t.name.to_owned(); + let outcomes = Arc::clone(&outcomes); + tests.push(Trial::test(t.name, move || { + let start = Instant::now(); + let result = f(); + let duration = start.elapsed(); + let outcome = TestOutcome { + name, + duration, + result: result.as_ref().map(|_| ()).map_err(|e| format!("{e:?}")), + }; + outcomes + .lock() + .unwrap_or_else(|e| e.into_inner()) + .push(outcome); + result.map_err(|e| format!("{e:?}").into()) + })); + } + + // Expand parameterised tests + for pt in PARAMETERIZED_INTEGRATION_TESTS.iter() { + for param in &config.parameters { + let param = param.clone(); + let suffix = image_to_test_suffix(¶m); + let test_name = format!("{}_{}", pt.name, suffix); + let display_name = test_name.clone(); + let f = pt.f; + let outcomes = Arc::clone(&outcomes); + tests.push(Trial::test(test_name, move || { + let start = Instant::now(); + let result = f(¶m); + let duration = start.elapsed(); + let outcome = TestOutcome { + name: display_name, + duration, + result: result.as_ref().map(|_| ()).map_err(|e| format!("{e:?}")), + }; + outcomes + .lock() + .unwrap_or_else(|e| e.into_inner()) + .push(outcome); + result.map_err(|e| format!("{e:?}").into()) + })); + } + } + + let conclusion = libtest_mimic::run(&args, tests); + + // Write JUnit XML if requested + if let Ok(path) = std::env::var("JUNIT_OUTPUT") { + if let Err(e) = write_junit( + &path, + &config.report_name, + &config.suite_name, + &outcomes.lock().unwrap_or_else(|e| e.into_inner()), + ) { + eprintln!("warning: failed to write JUnit XML to {path}: {e}"); + } + } + + std::process::exit(if conclusion.has_failed() { 101 } else { 0 }); +} diff --git a/crates/itest/src/junit.rs b/crates/itest/src/junit.rs new file mode 100644 index 0000000..2a71722 --- /dev/null +++ b/crates/itest/src/junit.rs @@ -0,0 +1,52 @@ +//! Optional JUnit XML output. +//! +//! When the `JUNIT_OUTPUT` environment variable is set, test outcomes +//! are serialised to JUnit XML after all tests complete. This is +//! useful for CI systems (GitHub Actions, tmt, etc.) that can ingest +//! JUnit results for display. + +use quick_junit::{NonSuccessKind, Report, TestCase, TestCaseStatus, TestSuite}; + +/// Outcome of a single test, captured during execution. +pub(crate) struct TestOutcome { + /// Test name. + pub(crate) name: String, + /// Wall-clock duration. + pub(crate) duration: std::time::Duration, + /// `Ok(())` on success, `Err(message)` on failure. + pub(crate) result: Result<(), String>, +} + +/// Write JUnit XML to `path`. +/// +/// `report_name` is the top-level report identifier (e.g. the binary +/// name). `suite_name` groups the test cases (e.g. `"integration"`). +pub(crate) fn write_junit( + path: &str, + report_name: &str, + suite_name: &str, + outcomes: &[TestOutcome], +) -> Result<(), Box> { + let mut report = Report::new(report_name); + let mut suite = TestSuite::new(suite_name); + + for outcome in outcomes { + let status = match &outcome.result { + Ok(()) => TestCaseStatus::success(), + Err(msg) => { + let mut status = TestCaseStatus::non_success(NonSuccessKind::Failure); + status.set_message(msg.clone()); + status + } + }; + let mut tc = TestCase::new(outcome.name.clone(), status); + tc.set_time(outcome.duration); + suite.add_test_case(tc); + } + + report.add_test_suite(suite); + let xml = report.to_string()?; + std::fs::write(path, xml)?; + eprintln!("JUnit XML written to {path}"); + Ok(()) +} diff --git a/crates/itest/src/lib.rs b/crates/itest/src/lib.rs new file mode 100644 index 0000000..f0888d9 --- /dev/null +++ b/crates/itest/src/lib.rs @@ -0,0 +1,247 @@ +//! Reusable integration test infrastructure for bootc-dev projects. +//! +//! This crate provides a common test harness built on [`libtest_mimic`] with +//! automatic test registration via [`linkme`] distributed slices. It is +//! designed to be shared across repositories such as bcvk, ostree, bootc, and +//! composefs-rs, reducing duplication of test infrastructure code. +//! +//! # Core concepts +//! +//! ## Test registration +//! +//! Tests are registered at link time using the [`integration_test!`] and +//! [`parameterized_integration_test!`] macros. No manual lists in `main()`. +//! +//! ## Privilege tiers +//! +//! Tests that need root can use [`privileged_test!`] or [`booted_test!`]. +//! When run without root these macros automatically re-dispatch the test +//! inside a bcvk VM, so the same binary works both on a developer laptop +//! and inside a tmt / autopkgtest / CI environment. +//! +//! ## Error types +//! +//! Test functions return [`TestResult`], which uses +//! `Box` as the error type. This is compatible +//! with all major error libraries — `anyhow`, `color_eyre`, `eyre`, and +//! plain `std::io::Error` all convert via `?` without any wrapper. +//! +//! ## Harness +//! +//! Call [`run_tests`] (or [`run_tests_with_config`]) from your `main()` to +//! collect tests, expand parameterised variants, run them via libtest-mimic, +//! and optionally write JUnit XML. + +// linkme requires unsafe for distributed slices +#![allow(unsafe_code)] + +mod harness; +mod junit; +mod privilege; + +pub use harness::{run_tests, run_tests_with_config, TestConfig}; +pub use privilege::{require_root, DispatchMode}; + +// Re-export dependencies used by our macros so consumers don't need +// to add them to their own Cargo.toml. +#[doc(hidden)] +pub use linkme; +#[doc(hidden)] +pub use paste; + +/// Error type for integration tests. +/// +/// Compatible with all major error libraries: +/// - `anyhow::Error` converts via `Into` +/// - `eyre::Report` / `color_eyre::Report` converts via `Into` +/// - Any `std::error::Error + Send + Sync + 'static` converts via `?` +pub type TestError = Box; + +/// Result type for integration tests. +pub type TestResult = std::result::Result<(), TestError>; + +/// Signature for a plain integration test function. +pub type TestFn = fn() -> TestResult; + +/// Signature for a parameterised test (receives one string parameter). +pub type ParameterizedTestFn = fn(&str) -> TestResult; + +/// Metadata for a registered integration test. +#[derive(Debug)] +pub struct IntegrationTest { + /// Name of the test. + pub name: &'static str, + /// Test function. + pub f: TestFn, +} + +impl IntegrationTest { + /// Create a new integration test. + pub const fn new(name: &'static str, f: TestFn) -> Self { + Self { name, f } + } +} + +/// Metadata for a parameterised test that is expanded once per parameter value. +#[derive(Debug)] +pub struct ParameterizedIntegrationTest { + /// Base name (will be suffixed with the parameter value). + pub name: &'static str, + /// Test function receiving one string parameter. + pub f: ParameterizedTestFn, +} + +impl ParameterizedIntegrationTest { + /// Create a new parameterised integration test. + pub const fn new(name: &'static str, f: ParameterizedTestFn) -> Self { + Self { name, f } + } +} + +/// Distributed slice collecting all [`IntegrationTest`]s at link time. +/// +/// Used by the [`integration_test!`] macro; not intended for direct use. +#[doc(hidden)] +#[linkme::distributed_slice] +pub static INTEGRATION_TESTS: [IntegrationTest]; + +/// Distributed slice collecting all [`ParameterizedIntegrationTest`]s. +/// +/// Used by the [`parameterized_integration_test!`] macro; not intended +/// for direct use. +#[doc(hidden)] +#[linkme::distributed_slice] +pub static PARAMETERIZED_INTEGRATION_TESTS: [ParameterizedIntegrationTest]; + +/// Register a test function. +/// +/// ```ignore +/// fn my_test() -> itest::TestResult { Ok(()) } +/// itest::integration_test!(my_test); +/// ``` +#[macro_export] +macro_rules! integration_test { + ($fn_name:ident) => { + $crate::paste::paste! { + #[$crate::linkme::distributed_slice($crate::INTEGRATION_TESTS)] + static [<$fn_name:upper>]: $crate::IntegrationTest = + $crate::IntegrationTest::new(stringify!($fn_name), $fn_name); + } + }; +} + +/// Register a parameterised test function. +/// +/// The test will be expanded once per parameter value supplied to the harness +/// (e.g. one per container image). +/// +/// ```ignore +/// fn my_test(image: &str) -> itest::TestResult { Ok(()) } +/// itest::parameterized_integration_test!(my_test); +/// ``` +#[macro_export] +macro_rules! parameterized_integration_test { + ($fn_name:ident) => { + $crate::paste::paste! { + #[$crate::linkme::distributed_slice($crate::PARAMETERIZED_INTEGRATION_TESTS)] + static [<$fn_name:upper>]: $crate::ParameterizedIntegrationTest = + $crate::ParameterizedIntegrationTest::new(stringify!($fn_name), $fn_name); + } + }; +} + +/// Create a test that requires root privileges. +/// +/// When not running as root the test is automatically dispatched inside a +/// bcvk ephemeral VM (fast path, no disk install). +/// +/// The test binary name is taken from the first argument; it must match the +/// installed binary name so that `bcvk ephemeral run-ssh` can invoke it. +/// +/// ```ignore +/// itest::privileged_test!("my-binary", my_test, { +/// // runs as root +/// Ok(()) +/// }); +/// ``` +#[macro_export] +macro_rules! privileged_test { + ($binary:expr, $fn_name:ident, $body:expr) => { + fn $fn_name() -> $crate::TestResult { + if $crate::require_root( + stringify!($fn_name), + $binary, + $crate::DispatchMode::Privileged, + )? + .is_some() + { + return Ok(()); + } + // Inner closure: its return type is inferred from $body, + // allowing any Result<(), E> where E: Into. + let inner = || $body; + inner().map_err(::std::convert::Into::into) + } + $crate::integration_test!($fn_name); + }; +} + +/// Create a test that requires a fully booted (e.g. ostree-deployed) system. +/// +/// When not running as root the test is dispatched via `bcvk libvirt run` +/// which does a full `bootc install to-disk`. +/// +/// ```ignore +/// itest::booted_test!("my-binary", my_test, { +/// // runs inside a booted ostree deployment +/// Ok(()) +/// }); +/// ``` +#[macro_export] +macro_rules! booted_test { + ($binary:expr, $fn_name:ident, $body:expr) => { + fn $fn_name() -> $crate::TestResult { + if $crate::require_root(stringify!($fn_name), $binary, $crate::DispatchMode::Booted)? + .is_some() + { + return Ok(()); + } + let inner = || $body; + inner().map_err(::std::convert::Into::into) + } + $crate::integration_test!($fn_name); + }; +} + +/// Replace non-alphanumeric characters with underscores. +/// +/// Useful for turning container image references into safe test-name suffixes. +pub fn image_to_test_suffix(image: &str) -> String { + image.replace(|c: char| !c.is_alphanumeric(), "_") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn suffix_basic() { + assert_eq!( + image_to_test_suffix("quay.io/fedora/fedora-bootc:42"), + "quay_io_fedora_fedora_bootc_42" + ); + } + + #[test] + fn suffix_digest() { + assert_eq!( + image_to_test_suffix("quay.io/image@sha256:abc123"), + "quay_io_image_sha256_abc123" + ); + } + + #[test] + fn suffix_only_alnum() { + assert_eq!(image_to_test_suffix("simpleimage"), "simpleimage"); + } +} diff --git a/crates/itest/src/privilege.rs b/crates/itest/src/privilege.rs new file mode 100644 index 0000000..7bda68c --- /dev/null +++ b/crates/itest/src/privilege.rs @@ -0,0 +1,106 @@ +//! Privilege detection and VM dispatch. +//! +//! When a test needs root but the process is unprivileged, we +//! re-invoke the test binary inside a bcvk VM. Two modes are +//! supported: +//! +//! * **Privileged** — `bcvk ephemeral run-ssh` (fast, no disk +//! install). +//! * **Booted** — `bcvk libvirt run` + SSH (full disk install via +//! `bootc install to-disk`). + +use crate::TestError; +use xshell::{cmd, Shell}; + +/// How a test should be dispatched when not running as root. +#[derive(Debug, Clone, Copy)] +pub enum DispatchMode { + /// Just needs root — use `bcvk ephemeral run-ssh` (no disk install). + Privileged, + /// Needs a fully deployed system — use `bcvk libvirt run`. + Booted, +} + +/// Check whether we are running as root and, if not, dispatch the +/// test to a bcvk VM. +/// +/// * Returns `Ok(None)` when already root — the caller should run +/// the test body. +/// * Returns `Ok(Some(()))` after successfully dispatching — the +/// caller should return early. +/// +/// # Arguments +/// +/// * `test_name` — the name passed to `--exact` when re-invoking. +/// * `test_binary` — binary name or path invoked inside the VM. +/// * `mode` — [`DispatchMode::Privileged`] or [`DispatchMode::Booted`]. +/// +/// # Environment variables +/// +/// * `BCVK_PATH` — path to the bcvk binary (default: `"bcvk"`). +/// * `ITEST_IMAGE` — container image to boot in the VM (**required** +/// when not root). +/// * `ITEST_IN_VM` — recursion guard: if set we expect to already be +/// root; if not, something is broken. +/// +/// Projects that need different env var names should set `ITEST_IMAGE` +/// from their own project-specific variable in `main()`, or define +/// thin wrapper functions. +pub fn require_root( + test_name: &str, + test_binary: &str, + mode: DispatchMode, +) -> Result, TestError> { + if rustix::process::getuid().is_root() { + return Ok(None); + } + + // Recursion guard + if std::env::var_os("ITEST_IN_VM").is_some() { + return Err("ITEST_IN_VM is set but we are not root — VM setup is broken".into()); + } + + let image = std::env::var("ITEST_IMAGE").map_err(|_| -> TestError { + "not root and ITEST_IMAGE not set; \ + set it to a bootc container image to run privileged tests" + .into() + })?; + + let sh = Shell::new()?; + let bcvk = std::env::var("BCVK_PATH").unwrap_or_else(|_| "bcvk".into()); + + // Pass the recursion guard so the binary knows it's inside a VM + let in_vm_env = "ITEST_IN_VM=1"; + + match mode { + DispatchMode::Booted => { + let vm_name = format!("itest-{}", test_name.replace('_', "-")); + cmd!( + sh, + "{bcvk} libvirt run --name {vm_name} --replace --detach --ssh-wait {image}" + ) + .run()?; + + let result = cmd!( + sh, + "{bcvk} libvirt ssh {vm_name} -- env {in_vm_env} {test_binary} --exact {test_name}" + ) + .run(); + + // Always clean up + if let Err(e) = cmd!(sh, "{bcvk} libvirt rm --stop --force {vm_name}").run() { + eprintln!("warning: failed to clean up VM {vm_name}: {e}"); + } + result?; + } + DispatchMode::Privileged => { + cmd!( + sh, + "{bcvk} ephemeral run-ssh {image} -- env {in_vm_env} {test_binary} --exact {test_name}" + ) + .run()?; + } + } + + Ok(Some(())) +}