From 7eb0176509b7b163b84daf7141edc76cfbcdbee1 Mon Sep 17 00:00:00 2001 From: Andrew Dunn Date: Mon, 5 Oct 2026 16:09:14 -0400 Subject: [PATCH 1/2] tests-integration: Port test-switch-zstd-chunked to Rust The nushell tests keep breaking on subtle behaviour changes between nushell versions, so the plan is to move them to Rust with xshell. This is the first one. It builds an image on the target, pushes it, switches to it and reboots, so it needs pieces many of the others share: TAP output, dispatch on TMT_REBOOT_COUNT, `bootc status --json`, and the UKI Containerfile helper from tap.nu. The tests become subcommands of a new `booted` group in bootc-integration-tests, which the bootc-tests subpackage already puts in the test image. Every failure exits 1, which tmt counts as a test failure: checks return errors rather than panicking (a panic exits 101, which tmt reports as an error), and a failing command no longer passes its own exit code through the way nushell did. Nothing runs this yet; the next commit points tmt at it. Related: https://github.com/bootc-dev/bootc/issues/2547 Assisted-by: AI Signed-off-by: Andrew Dunn --- crates/tests-integration/src/booted.rs | 187 ++++++++++++++++++ .../src/booted/switch_zstd_chunked.rs | 167 ++++++++++++++++ .../src/tests-integration.rs | 5 + 3 files changed, 359 insertions(+) create mode 100644 crates/tests-integration/src/booted.rs create mode 100644 crates/tests-integration/src/booted/switch_zstd_chunked.rs diff --git a/crates/tests-integration/src/booted.rs b/crates/tests-integration/src/booted.rs new file mode 100644 index 0000000000..22bd1749e1 --- /dev/null +++ b/crates/tests-integration/src/booted.rs @@ -0,0 +1,187 @@ +//! Tests that tmt runs on a booted host, ported from the nushell tests in +//! tmt/tests/booted; see . +//! +//! Each module in booted/ is one subcommand. +//! +//! Checks fail with an error rather than a panic, because tmt reports an exit +//! code of 1 as a failure and any other (101 from a panic) as an error. + +use anyhow::{Context, Result}; +use clap::Subcommand; +use xshell::{Shell, cmd}; + +mod switch_zstd_chunked; + +#[derive(Debug, Subcommand)] +#[clap(rename_all = "kebab-case")] +pub(crate) enum Opt { + /// Switch to an image with zstd:chunked compressed layers + SwitchZstdChunked, +} + +pub(crate) fn run(opt: Opt) -> Result<()> { + let sh = &Shell::new()?; + match opt { + Opt::SwitchZstdChunked => switch_zstd_chunked::run(sh), + } +} + +/// Start a "Test anything protocol" stream: +/// +fn tap_begin(description: &str) { + println!("TAP version 14"); + println!("{description}"); +} + +fn tap_ok() { + println!("ok"); +} + +/// How many times tmt has rebooted the host during this test; see +/// +fn reboot_count() -> Result { + match std::env::var("TMT_REBOOT_COUNT") { + Ok(v) => v + .parse() + .with_context(|| format!("Invalid TMT_REBOOT_COUNT {v}")), + Err(std::env::VarError::NotPresent) => Ok(0), + Err(e) => Err(e).context("Reading TMT_REBOOT_COUNT"), + } +} + +fn host_status(sh: &Shell) -> Result { + let st = cmd!(sh, "bootc status --json").read()?; + serde_json::from_str(&st).context("Parsing bootc status") +} + +/// The EROFS format selected by the tmt configuration, so that derived UKI +/// test images use the same default as the source image. +fn selected_erofs_version() -> Result { + let version = match std::env::var("BOOTC_erofs_version") { + Ok(v) => v, + Err(std::env::VarError::NotPresent) => "v1".to_owned(), + Err(e) => return Err(e).context("Reading BOOTC_erofs_version"), + }; + anyhow::ensure!( + matches!(version.as_str(), "v1" | "v2"), + "Unsupported EROFS version: {version}" + ); + Ok(version) +} + +/// If the host boots a composefs UKI, append the stages that rebuild the UKI +/// for the image `containerfile` builds (as its `base` stage). +fn make_uki_containerfile(sh: &Shell, containerfile: &str) -> Result { + let erofs_version = selected_erofs_version()?; + let st = host_status(sh)?; + uki_containerfile(containerfile, &st, &erofs_version) +} + +fn uki_containerfile( + containerfile: &str, + st: &serde_json::Value, + erofs_version: &str, +) -> Result { + let Some(composefs) = st + .pointer("/status/booted/composefs") + .filter(|v| !v.is_null()) + else { + return Ok(containerfile.to_owned()); + }; + let boot_type = composefs + .get("bootType") + .and_then(|v| v.as_str()) + .context("Missing composefs bootType")?; + if !boot_type.eq_ignore_ascii_case("uki") { + return Ok(containerfile.to_owned()); + } + let missing_verity_allowed = composefs + .get("missingVerityAllowed") + .and_then(|v| v.as_bool()) + .context("Missing composefs missingVerityAllowed")?; + let allow_missing_verity = if missing_verity_allowed { + "--allow-missing-verity" + } else { + "" + }; + + // TODO: Handle sealed UKI + let seal_state = "unsealed"; + + let uki_stages = format!( + r#" + FROM base as kernel + RUN <<-EOF + kver=$(bootc container inspect --rootfs / --json | jq -r '.kernel.version') + bootc internals uki extract /boot/EFI/Linux/$kver.efi /boot + EOF + + FROM base as base-final + RUN rm -rf /boot/EFI/Linux/*.efi + + FROM base as sealed-uki + RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \ + --mount=type=bind,from=base-final,src=/,target=/run/target \ + --mount=type=bind,from=kernel,src=/,target=/run/kernel <<-EOF + + kver=$(bootc container inspect --rootfs /run/kernel --json | jq -r '.kernel.version') + + /usr/bin/seal-uki \ + --target /run/target \ + --output /out \ + --secrets /run/secrets {allow_missing_verity} \ + --kernel-dir /run/kernel/boot/${{kver}} \ + --write-dumpfile-to /out/${{kver}}.dump \ + --seal-state {seal_state} \ + --erofs-version {erofs_version} + EOF + + FROM base-final + + # Copy the sealed UKI and finalize the image remove raw kernel, create symlinks + RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \ + --mount=type=bind,from=sealed-uki,src=/,target=/run/sealed-uki \ + --mount=type=bind,from=kernel,src=/,target=/run/kernel \ + /usr/bin/finalize-uki /run/sealed-uki/out $(bootc container inspect --rootfs /run/kernel --json | jq -r '.kernel.version') + "# + ); + let uki_stages = uki_stages.lines().map(str::trim).collect::>(); + Ok(format!("{containerfile}\n{}", uki_stages.join("\n"))) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + fn status(composefs: serde_json::Value) -> serde_json::Value { + json!({"status": {"booted": {"composefs": composefs}}}) + } + + #[test] + fn test_uki_containerfile() { + let base = "FROM localhost/bootc as base\n"; + let unchanged = [ + json!({"status": {"booted": {"ostree": {}}}}), + status(json!(null)), + status(json!({"bootType": "Bls", "missingVerityAllowed": false})), + ]; + for st in unchanged { + assert_eq!(uki_containerfile(base, &st, "v1").unwrap(), base, "{st}"); + } + + let uki = + |allowed: bool| status(json!({"bootType": "Uki", "missingVerityAllowed": allowed})); + let r = uki_containerfile(base, &uki(false), "v2").unwrap(); + assert!(r.starts_with(base)); + assert!(r.contains("\nFROM base as kernel\n")); + assert!(r.contains("\n--secrets /run/secrets \\\n")); + assert!(r.contains("\n--kernel-dir /run/kernel/boot/${kver} \\\n")); + assert!(r.contains("\n--erofs-version v2\nEOF\n")); + let r = uki_containerfile(base, &uki(true), "v1").unwrap(); + assert!(r.contains("\n--secrets /run/secrets --allow-missing-verity \\\n")); + + assert!(uki_containerfile(base, &status(json!({})), "v1").is_err()); + } +} diff --git a/crates/tests-integration/src/booted/switch_zstd_chunked.rs b/crates/tests-integration/src/booted/switch_zstd_chunked.rs new file mode 100644 index 0000000000..924f4f795a --- /dev/null +++ b/crates/tests-integration/src/booted/switch_zstd_chunked.rs @@ -0,0 +1,167 @@ +//! zstd:chunked layers are multi-frame zstd streams with skippable frames +//! holding a table of contents, which a naive zstd decoder truncates; see +//! +//! +//! This test does: +//! +//! ```text +//! podman build +//! podman push --compression-format zstd:chunked +//! bootc switch +//! Verify we boot into the new image +//! ``` +//! +//! An OCI directory is used rather than a registry to avoid a network +//! dependency. The layers still go through the same decompression code +//! as a registry pull. +//! +//! This is composefs-only for now: on ostree it trips over +//! (a /boot automount that +//! has idled out loses the staged deployment). Enable it there too once +//! that is fixed in the base images. + +use anyhow::{Context, Result}; +use oci_spec::image::{ImageManifest, MediaType}; +use xshell::{Shell, cmd}; + +use super::{host_status, make_uki_containerfile, reboot_count, tap_begin, tap_ok}; + +const IMAGE_DIR: &str = "/var/tmp/bootc-zstd-chunked"; +const DATA_DIR: &str = "/usr/share/testing-bootc-zstd-chunked"; +const DERIVED_IMAGE: &str = "localhost/bootc-zstd-chunked"; +/// Annotation that c/image adds to each zstd:chunked layer +const CHUNKED_ANNOTATION: &str = "io.github.containers.zstd-chunked.manifest-checksum"; + +pub(crate) fn run(sh: &Shell) -> Result<()> { + // This code runs on *each* boot. + cmd!(sh, "bootc status").run()?; + let st = host_status(sh)?; + match reboot_count()? { + 0 => initial_build(sh), + 1 => second_boot(sh, &st), + n => anyhow::bail!("Invalid TMT_REBOOT_COUNT {n}"), + } +} + +fn initial_build(sh: &Shell) -> Result<()> { + tap_begin("switch to zstd:chunked image"); + + let td = tempfile::tempdir()?; + let _dir = sh.push_dir(td.path()); + + cmd!(sh, "bootc image copy-to-storage").run()?; + // Layers we already have (i.e. all the base image ones) are skipped when + // pulling, so only the layer added here is actually decompressed. Put a + // number of files in it: zstd:chunked compresses each into its own + // frame(s), so this layer is multi-frame too. The checksums verify + // nothing got silently truncated. + let gen_data = "for i in $(seq 64); do head -c 65536 /dev/urandom > data$i; done && sha256sum data* > SHA256SUMS"; + let containerfile = format!( + "FROM localhost/bootc as base\nRUN mkdir -p {DATA_DIR} && cd {DATA_DIR} && {gen_data}\n" + ); + sh.write_file("Dockerfile", make_uki_containerfile(sh, &containerfile)?)?; + cmd!(sh, "podman build -t {DERIVED_IMAGE} .").run()?; + + sh.remove_path(IMAGE_DIR)?; + let oci_dir = format!("oci:{IMAGE_DIR}"); + cmd!( + sh, + "podman push --compression-format zstd:chunked --force-compression {DERIVED_IMAGE} {oci_dir}" + ) + .run()?; + // Free up space; we only need the OCI directory from here on + cmd!(sh, "podman rmi {DERIVED_IMAGE} localhost/bootc").run()?; + + // Make sure we're actually testing what we think we are + let manifest = cmd!(sh, "skopeo inspect --raw {oci_dir}").read()?; + verify_zstd_chunked(&manifest)?; + + cmd!(sh, "bootc switch --transport oci {IMAGE_DIR}").run()?; + let st = host_status(sh)?; + let transport = st.pointer("/status/staged/image/image/transport"); + let transport = transport.and_then(|v| v.as_str()); + anyhow::ensure!(transport == Some("oci"), "Staged transport {transport:?}"); + cmd!(sh, "tmt-reboot").run()?; + Ok(()) +} + +fn second_boot(sh: &Shell, st: &serde_json::Value) -> Result<()> { + println!("verifying second boot"); + let booted = st + .pointer("/status/booted/image/image") + .context("Missing booted image")?; + let transport = booted.get("transport").and_then(|v| v.as_str()); + anyhow::ensure!(transport == Some("oci"), "Booted transport {transport:?}"); + let image = booted.get("image").and_then(|v| v.as_str()); + anyhow::ensure!(image == Some(IMAGE_DIR), "Booted image {image:?}"); + let _dir = sh.push_dir(DATA_DIR); + cmd!(sh, "sha256sum --check --quiet SHA256SUMS").run()?; + cmd!(sh, "bootc internals fsck").run()?; + tap_ok(); + Ok(()) +} + +fn verify_zstd_chunked(manifest: &str) -> Result<()> { + let manifest: ImageManifest = serde_json::from_str(manifest).context("Parsing manifest")?; + anyhow::ensure!(!manifest.layers().is_empty(), "No layers in manifest"); + for layer in manifest.layers() { + let digest = layer.digest(); + anyhow::ensure!( + layer.media_type() == &MediaType::ImageLayerZstd, + "layer {digest} has media type {}", + layer.media_type() + ); + let chunked = layer + .annotations() + .as_ref() + .is_some_and(|a| a.contains_key(CHUNKED_ANNOTATION)); + anyhow::ensure!(chunked, "layer {digest} is not zstd:chunked"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use serde_json::{Value, json}; + + use super::*; + + const DIGEST: &str = "sha256:0000000000000000000000000000000000000000000000000000000000000000"; + + fn layer(media_type: &str, annotations: Value) -> Value { + json!({ + "mediaType": media_type, + "digest": DIGEST, + "size": 1, + "annotations": annotations, + }) + } + + #[test] + fn test_verify_zstd_chunked() { + let zstd = "application/vnd.oci.image.layer.v1.tar+zstd"; + let gzip = "application/vnd.oci.image.layer.v1.tar+gzip"; + let chunked = || json!({CHUNKED_ANNOTATION: "sha256:1"}); + let cases = [ + (vec![layer(zstd, chunked())], true), + (vec![layer(zstd, chunked()), layer(zstd, json!({}))], false), + (vec![layer(zstd, Value::Null)], false), + (vec![layer(gzip, chunked())], false), + (vec![], false), + ]; + for (layers, ok) in cases { + let manifest = json!({ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": DIGEST, + "size": 1, + }, + "layers": layers, + }); + let r = verify_zstd_chunked(&manifest.to_string()); + assert_eq!(r.is_ok(), ok, "{manifest}: {r:?}"); + } + } +} diff --git a/crates/tests-integration/src/tests-integration.rs b/crates/tests-integration/src/tests-integration.rs index d5c2e2dd68..70798c2569 100644 --- a/crates/tests-integration/src/tests-integration.rs +++ b/crates/tests-integration/src/tests-integration.rs @@ -5,6 +5,7 @@ use cap_std_ext::cap_std::{self, fs::Dir}; use clap::Parser; mod anaconda; +mod booted; mod container; mod hostpriv; mod install; @@ -50,6 +51,9 @@ pub(crate) enum Opt { }, /// Test bootc installation via Anaconda with a local container image AnacondaTest(anaconda::AnacondaTestArgs), + /// Tests which tmt runs on a booted host + #[clap(subcommand)] + Booted(booted::Opt), } fn main() { @@ -65,6 +69,7 @@ fn main() { selinux::verify_selinux_recurse(root, warn) } Opt::AnacondaTest(args) => anaconda::run_anaconda_test(&args), + Opt::Booted(opt) => booted::run(opt), }; if let Err(e) = r { eprintln!("error: {e:?}"); From 456553632b8598e91be063093f26fb57c647bca2 Mon Sep 17 00:00:00 2001 From: Andrew Dunn Date: Mon, 5 Oct 2026 16:09:22 -0400 Subject: [PATCH 2/2] xtask: Run the Rust booted tests from tmt The tmt generator only finds scripts in tmt/tests/booted, so nothing runs the Rust port from the previous commit. Teach it to read the same number/tmt/extra header from `//` comments in crates/tests-integration/src/booted/*.rs, and to generate each test there as `bootc-integration-tests booted `. plan-50 keeps its name and now runs the Rust port, and the nushell version goes. The plan is now disabled outside running_env=image_mode, as test-27 is. Packit, and the gating runs sharing its prepare steps, boot an image built from Containerfile.packit, which doesn't install bootc-tests, and Packit's Copr build doesn't pass `--with tests`. That drops this test's runs on Packit's ostree images, and in gating, until bootc-tests is built there. Related: https://github.com/bootc-dev/bootc/issues/2547 Assisted-by: AI Signed-off-by: Andrew Dunn --- crates/tests-integration/src/booted.rs | 23 +++- .../src/booted/switch_zstd_chunked.rs | 11 ++ crates/xtask/src/tmt.rs | 128 ++++++++++++------ tmt/plans/integration.fmf | 4 + tmt/tests/booted/test-switch-zstd-chunked.nu | 96 ------------- tmt/tests/tests.fmf | 6 +- 6 files changed, 132 insertions(+), 136 deletions(-) delete mode 100644 tmt/tests/booted/test-switch-zstd-chunked.nu diff --git a/crates/tests-integration/src/booted.rs b/crates/tests-integration/src/booted.rs index 22bd1749e1..ec75ce75f8 100644 --- a/crates/tests-integration/src/booted.rs +++ b/crates/tests-integration/src/booted.rs @@ -1,7 +1,10 @@ //! Tests that tmt runs on a booted host, ported from the nushell tests in //! tmt/tests/booted; see . //! -//! Each module in booted/ is one subcommand. +//! Each module in booted/ is one subcommand, and starts with the same +//! `number`, `tmt` and `extra` header (in `//` comments) that `cargo xtask +//! update-generated` reads from the nushell tests to generate tmt/tests and +//! tmt/plans. //! //! Checks fail with an error rather than a panic, because tmt reports an exit //! code of 1 as a failure and any other (101 from a panic) as an error. @@ -151,10 +154,28 @@ fn uki_containerfile( #[cfg(test)] mod tests { + use clap::CommandFactory; use serde_json::json; use super::*; + /// xtask generates each module's tmt test as `bootc-integration-tests + /// booted `, so that subcommand has to exist. + #[test] + fn test_modules_are_subcommands() { + let opt = crate::Opt::command(); + let booted = opt.find_subcommand("booted").unwrap(); + let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/src/booted"); + for entry in std::fs::read_dir(dir).unwrap() { + let name = entry.unwrap().file_name().into_string().unwrap(); + let name = name.strip_suffix(".rs").unwrap().replace('_', "-"); + assert!( + booted.find_subcommand(&name).is_some(), + "No subcommand for {name}" + ); + } + } + fn status(composefs: serde_json::Value) -> serde_json::Value { json!({"status": {"booted": {"composefs": composefs}}}) } diff --git a/crates/tests-integration/src/booted/switch_zstd_chunked.rs b/crates/tests-integration/src/booted/switch_zstd_chunked.rs index 924f4f795a..7e7b2e0065 100644 --- a/crates/tests-integration/src/booted/switch_zstd_chunked.rs +++ b/crates/tests-integration/src/booted/switch_zstd_chunked.rs @@ -1,3 +1,14 @@ +// number: 50 +// tmt: +// summary: Switch to an image with zstd:chunked compressed layers +// duration: 30m +// adjust: +// - when: running_env != image_mode +// enabled: false +// because: only the image_mode test image installs bootc-tests +// extra: +// skip_if_ostree: true +// //! zstd:chunked layers are multi-frame zstd streams with skippable frames //! holding a table of contents, which a naive zstd decoder truncates; see //! diff --git a/crates/xtask/src/tmt.rs b/crates/xtask/src/tmt.rs index ed7e7fea54..dc98a343a2 100644 --- a/crates/xtask/src/tmt.rs +++ b/crates/xtask/src/tmt.rs @@ -30,6 +30,10 @@ const FIELD_FIXME_SKIP_IF_UKI: &str = "fixme_skip_if_uki"; /// Ex. composefs-gc const FIELD_SKIP_IF_OSTREE: &str = "skip_if_ostree"; +// Rust tests are subcommands of the tests-integration binary, one per module +const RUST_TESTS_DIR: &str = "crates/tests-integration/src/booted"; +const RUST_TESTS_COMMAND: &str = "bootc-integration-tests booted"; + // bcvk options const BCVK_OPT_BIND_STORAGE_RO: &str = "--bind-storage-ro"; const ENV_BOOTC_UPGRADE_IMAGE: &str = "BOOTC_upgrade_image"; @@ -947,14 +951,17 @@ pub(crate) fn tmt_provision(sh: &Shell, args: &TmtProvisionArgs) -> Result<()> { Ok(()) } -/// Parse tmt metadata from a test file +/// Parse tmt metadata from a test file whose line comments start with `comment` /// Looks for: /// # number: N /// # extra: /// # try_bind_storage: true /// # tmt: /// # (yaml content) -fn parse_tmt_metadata(content: &str) -> Result> { +fn parse_tmt_metadata(content: &str, comment: &str) -> Result> { + let number_prefix = format!("{comment} number:"); + let extra_marker = format!("{comment} extra:"); + let tmt_marker = format!("{comment} tmt:"); let mut number = None; let mut in_extra_block = false; let mut in_tmt_block = false; @@ -965,7 +972,7 @@ fn parse_tmt_metadata(content: &str) -> Result> { let trimmed = line.trim(); // Look for "# number: N" line - if let Some(rest) = trimmed.strip_prefix("# number:") { + if let Some(rest) = trimmed.strip_prefix(&number_prefix) { number = Some( rest.trim() .parse::() @@ -974,23 +981,23 @@ fn parse_tmt_metadata(content: &str) -> Result> { continue; } - if trimmed == "# extra:" { + if trimmed == extra_marker { in_extra_block = true; in_tmt_block = false; continue; - } else if trimmed == "# tmt:" { + } else if trimmed == tmt_marker { in_tmt_block = true; in_extra_block = false; continue; } else if in_extra_block || in_tmt_block { - // Stop if we hit a line that doesn't start with #, or is just "#" - if !trimmed.starts_with('#') || trimmed == "#" { + // Stop if we hit a line that isn't a comment, or is an empty one + if !trimmed.starts_with(comment) || trimmed == comment { in_extra_block = false; in_tmt_block = false; continue; } - // Remove the leading # and preserve indentation - if let Some(yaml_line) = line.strip_prefix('#') { + // Remove the leading comment marker and preserve indentation + if let Some(yaml_line) = line.strip_prefix(comment) { if in_extra_block { extra_yaml_lines.push(yaml_line); } else { @@ -1133,31 +1140,47 @@ fn generate_integration() -> Result<(String, String)> { // Define tests in order let mut tests = vec![]; - // Scan for test-*.nu, test-*.sh, and test-*.py files in tmt/tests/booted/ + // Scan for test-*.nu, test-*.sh, and test-*.py files in tmt/tests/booted/, + // and for the Rust tests let booted_dir = Utf8Path::new("tmt/tests/booted"); - - for entry in std::fs::read_dir(booted_dir) + let rust_dir = Utf8Path::new(RUST_TESTS_DIR); + let entries = std::fs::read_dir(booted_dir) .with_context(|| format!("Reading directory {}", booted_dir))? - { + .chain( + std::fs::read_dir(rust_dir) + .with_context(|| format!("Reading directory {}", rust_dir))?, + ); + + for entry in entries { let entry = entry?; let path = entry.path(); let Some(filename) = path.file_name().and_then(|n| n.to_str()) else { continue; }; - // Extract stem (filename without "test-" prefix and extension) - let Some(stem) = filename.strip_prefix("test-").and_then(|s| { - s.strip_suffix(".nu") - .or_else(|| s.strip_suffix(".sh")) - .or_else(|| s.strip_suffix(".py")) - }) else { - continue; + // Extract stem (filename without "test-" prefix and extension), or + // for a Rust test its module name, which is its subcommand with _ for - + let is_rust = path.starts_with(rust_dir); + let (stem, comment) = if is_rust { + let Some(module) = filename.strip_suffix(".rs") else { + continue; + }; + (module.replace('_', "-"), "//") + } else { + let Some(stem) = filename.strip_prefix("test-").and_then(|s| { + s.strip_suffix(".nu") + .or_else(|| s.strip_suffix(".sh")) + .or_else(|| s.strip_suffix(".py")) + }) else { + continue; + }; + (stem.to_owned(), "#") }; let content = std::fs::read_to_string(&path).with_context(|| format!("Reading {}", filename))?; - let metadata = parse_tmt_metadata(&content) + let metadata = parse_tmt_metadata(&content, comment) .with_context(|| format!("Parsing tmt metadata from {}", filename))? .with_context(|| format!("Missing tmt metadata in {}", filename))?; @@ -1173,20 +1196,23 @@ fn generate_integration() -> Result<(String, String)> { }) .unwrap_or_else(|| stem.to_string()); - // Derive relative path from booted_dir - let relative_path = path - .strip_prefix("tmt/tests/") - .with_context(|| format!("Failed to get relative path for {}", filename))?; - // Determine test command based on file extension - let test_command = if filename.ends_with(".nu") { - format!("nu {}", relative_path.display()) - } else if filename.ends_with(".sh") { - format!("bash {}", relative_path.display()) - } else if filename.ends_with(".py") { - format!("python3 {}", relative_path.display()) + let test_command = if is_rust { + format!("{RUST_TESTS_COMMAND} {stem}") } else { - anyhow::bail!("Unsupported test file extension: {}", filename); + // Derive relative path from booted_dir + let relative_path = path + .strip_prefix("tmt/tests/") + .with_context(|| format!("Failed to get relative path for {}", filename))?; + if filename.ends_with(".nu") { + format!("nu {}", relative_path.display()) + } else if filename.ends_with(".sh") { + format!("bash {}", relative_path.display()) + } else if filename.ends_with(".py") { + format!("python3 {}", relative_path.display()) + } else { + anyhow::bail!("Unsupported test file extension: {}", filename); + } }; // Check if test wants bind storage @@ -1459,7 +1485,7 @@ mod tests { use tap.nu "#; - let metadata = parse_tmt_metadata(content).unwrap().unwrap(); + let metadata = parse_tmt_metadata(content, "#").unwrap().unwrap(); assert_eq!(metadata.number, 1); // Verify tmt fields are captured @@ -1490,7 +1516,7 @@ use tap.nu use std assert "#; - let metadata = parse_tmt_metadata(content).unwrap().unwrap(); + let metadata = parse_tmt_metadata(content, "#").unwrap().unwrap(); assert_eq!(metadata.number, 27); // Verify adjust section is in tmt @@ -1504,7 +1530,7 @@ use std assert use std assert "#; - let result = parse_tmt_metadata(content).unwrap(); + let result = parse_tmt_metadata(content, "#").unwrap(); assert!(result.is_none()); } @@ -1522,7 +1548,7 @@ use std assert set -eux "#; - let metadata = parse_tmt_metadata(content).unwrap().unwrap(); + let metadata = parse_tmt_metadata(content, "#").unwrap().unwrap(); assert_eq!(metadata.number, 26); let tmt = metadata.tmt.as_mapping().unwrap(); @@ -1545,7 +1571,7 @@ set -eux use std assert "#; - let metadata = parse_tmt_metadata(content).unwrap().unwrap(); + let metadata = parse_tmt_metadata(content, "#").unwrap().unwrap(); assert_eq!(metadata.number, 24); let extra = metadata.extra.as_mapping().unwrap(); @@ -1562,4 +1588,30 @@ use std assert )) ); } + + #[test] + fn test_parse_tmt_metadata_rust() { + let content = r#"// number: 50 +// tmt: +// summary: Switch to an image with zstd:chunked compressed layers +// extra: +// skip_if_ostree: true +// +//! Module documentation +use anyhow::Result; +"#; + + let metadata = parse_tmt_metadata(content, "//").unwrap().unwrap(); + assert_eq!(metadata.number, 50); + + let extra = metadata.extra.as_mapping().unwrap(); + assert_eq!( + extra.get(&serde_yaml::Value::String("skip_if_ostree".to_string())), + Some(&serde_yaml::Value::Bool(true)) + ); + let tmt = metadata.tmt.as_mapping().unwrap(); + assert_eq!(tmt.len(), 1); + + assert!(parse_tmt_metadata(content, "#").unwrap().is_none()); + } } diff --git a/tmt/plans/integration.fmf b/tmt/plans/integration.fmf index 2042e3cdb2..90e02dc28b 100644 --- a/tmt/plans/integration.fmf +++ b/tmt/plans/integration.fmf @@ -338,6 +338,10 @@ execute: how: fmf test: - /tmt/tests/tests/test-50-switch-zstd-chunked + adjust: + - when: running_env != image_mode + enabled: false + because: only the image_mode test image installs bootc-tests extra-skip_if_ostree: true /plan-52-install-repart: diff --git a/tmt/tests/booted/test-switch-zstd-chunked.nu b/tmt/tests/booted/test-switch-zstd-chunked.nu deleted file mode 100644 index 60116d6eec..0000000000 --- a/tmt/tests/booted/test-switch-zstd-chunked.nu +++ /dev/null @@ -1,96 +0,0 @@ -# number: 50 -# tmt: -# summary: Switch to an image with zstd:chunked compressed layers -# duration: 30m -# extra: -# skip_if_ostree: true -# -# zstd:chunked layers are multi-frame zstd streams with skippable frames -# holding a table of contents, which a naive zstd decoder truncates; see -# https://github.com/bootc-dev/bootc/issues/2408 -# -# This test does: -# podman build -# podman push --compression-format zstd:chunked -# bootc switch -# Verify we boot into the new image -# -# An OCI directory is used rather than a registry to avoid a network -# dependency. The layers still go through the same decompression code -# as a registry pull. -# -# This is composefs-only for now: on ostree it trips over -# https://github.com/bootc-dev/bootc/issues/2402 (a /boot automount that -# has idled out loses the staged deployment). Enable it there too once -# that is fixed in the base images. -use std assert -use tap.nu - -const image_dir = "/var/tmp/bootc-zstd-chunked" -const data_dir = "/usr/share/testing-bootc-zstd-chunked" -const zstd_media_type = "application/vnd.oci.image.layer.v1.tar+zstd" -# Annotation that c/image adds to each zstd:chunked layer -const chunked_annotation = "io.github.containers.zstd-chunked.manifest-checksum" - -# This code runs on *each* boot. -bootc status -let st = bootc status --json | from json -let booted = $st.status.booted.image - -def initial_build [] { - tap begin "switch to zstd:chunked image" - - let td = mktemp -d - cd $td - - bootc image copy-to-storage - # Layers we already have (i.e. all the base image ones) are skipped when - # pulling, so only the layer added here is actually decompressed. Put a - # number of files in it: zstd:chunked compresses each into its own - # frame(s), so this layer is multi-frame too. The checksums verify - # nothing got silently truncated. - let gen_data = 'for i in $(seq 64); do head -c 65536 /dev/urandom > data$i; done && sha256sum data* > SHA256SUMS' - ( - tap make_uki_containerfile $" - FROM localhost/bootc as base - RUN mkdir -p ($data_dir) && cd ($data_dir) && ($gen_data) - ") | save Dockerfile - podman build -t localhost/bootc-zstd-chunked . - - rm -rf $image_dir - podman push --compression-format zstd:chunked --force-compression localhost/bootc-zstd-chunked $"oci:($image_dir)" - # Free up space; we only need the OCI directory from here on - podman rmi localhost/bootc-zstd-chunked localhost/bootc - - # Make sure we're actually testing what we think we are - let manifest = skopeo inspect --raw $"oci:($image_dir)" | from json - assert (($manifest.layers | length) > 0) - for layer in $manifest.layers { - assert equal $layer.mediaType $zstd_media_type - assert ($chunked_annotation in ($layer.annotations? | default {} | columns)) $"layer ($layer.digest) is not zstd:chunked" - } - - bootc switch --transport oci $image_dir - let st = bootc status --json | from json - assert equal $st.status.staged.image.image.transport oci - tmt-reboot -} - -def second_boot [] { - print "verifying second boot" - assert equal $booted.image.transport oci - assert equal $booted.image.image $image_dir - cd $data_dir - sha256sum --check --quiet SHA256SUMS - bootc internals fsck - tap ok -} - -def main [] { - # See https://tmt.readthedocs.io/en/stable/stories/features.html#reboot-during-test - match $env.TMT_REBOOT_COUNT? { - null | "0" => initial_build, - "1" => second_boot, - $o => { error make { msg: $"Invalid TMT_REBOOT_COUNT ($o)" } }, - } -} diff --git a/tmt/tests/tests.fmf b/tmt/tests/tests.fmf index c78d42d7d0..28300bfebe 100644 --- a/tmt/tests/tests.fmf +++ b/tmt/tests/tests.fmf @@ -210,7 +210,11 @@ check: /test-50-switch-zstd-chunked: summary: Switch to an image with zstd:chunked compressed layers duration: 30m - test: nu booted/test-switch-zstd-chunked.nu + adjust: + - when: running_env != image_mode + enabled: false + because: only the image_mode test image installs bootc-tests + test: bootc-integration-tests booted switch-zstd-chunked /test-52-install-repart: summary: Test bootc install to-disk with systemd-repart partitioning