diff --git a/crates/tests-integration/src/booted.rs b/crates/tests-integration/src/booted.rs new file mode 100644 index 0000000000..ec75ce75f8 --- /dev/null +++ b/crates/tests-integration/src/booted.rs @@ -0,0 +1,208 @@ +//! 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, 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. + +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 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}}}) + } + + #[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..7e7b2e0065 --- /dev/null +++ b/crates/tests-integration/src/booted/switch_zstd_chunked.rs @@ -0,0 +1,178 @@ +// 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 +//! +//! +//! 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:?}"); 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