Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
208 changes: 208 additions & 0 deletions crates/tests-integration/src/booted.rs
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 crates/tests-integration/src/booted/switch_zstd_chunked.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
// number: 50

Copy link
Copy Markdown
Collaborator

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

// # tmt-begin
...
/// # tmt-end

or so?

// 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:?}");
}
}
}
Loading
Loading