Repository navigation
tests: Port test-switch-zstd-chunked to Rust #2550
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
cgwalters
merged 2 commits into
bootc-dev:main
from
andrewdunndev:tests/port-switch-zstd-chunked
Oct 9, 2026
+490
−135
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,208 @@ | ||
| //! Tests that tmt runs on a booted host, ported from the nushell tests in | ||
| //! tmt/tests/booted; see <https://github.com/bootc-dev/bootc/issues/2547>. | ||
| //! | ||
| //! 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: | ||
| /// <https://testanything.org/tap-version-14-specification.html> | ||
| 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 | ||
| /// <https://tmt.readthedocs.io/en/stable/stories/features.html#reboot-during-test> | ||
| fn reboot_count() -> Result<u32> { | ||
| 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<serde_json::Value> { | ||
| 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<String> { | ||
| 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<String> { | ||
| 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<String> { | ||
| 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::<Vec<_>>(); | ||
| 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 <module name with - for _>`, 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()); | ||
| } | ||
| } |
178 changes: 178 additions & 0 deletions
178
crates/tests-integration/src/booted/switch_zstd_chunked.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| //! <https://github.com/bootc-dev/bootc/issues/2408> | ||
| //! | ||
| //! This test does: | ||
| //! | ||
| //! ```text | ||
| //! podman build <derived from the booted image> | ||
| //! podman push --compression-format zstd:chunked <to an OCI directory> | ||
| //! bootc switch <to that OCI directory> | ||
| //! 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 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:?}"); | ||
| } | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is ok for now but as a followup since we're porting anyways let's require some structured metadata here. I think YAML frontmatter would be appropriate, we could perhaps extract that from rustdoc and avoid parsing rust code on our own?
Or just require
or so?