diff --git a/Cargo.lock b/Cargo.lock index 1c7a4dab..5f63bf88 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -954,6 +954,12 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "indoc" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" + [[package]] name = "instant" version = "0.1.12" @@ -1922,7 +1928,9 @@ dependencies = [ "cap-std-ext", "clap", "fn-error-context", + "indoc", "libtest-mimic", + "oci-spec", "rustix", "serde", "serde_json", diff --git a/Makefile b/Makefile index 3d5c2028..65389672 100644 --- a/Makefile +++ b/Makefile @@ -32,8 +32,8 @@ bin-archive: all test-bin-archive: all $(MAKE) install-with-tests DESTDIR=tmp-install && $(TAR_REPRODUCIBLE) --zstd -C tmp-install -cf target/bootc.tar.zst . && rm tmp-install -rf -install-kola-tests: - install -D -t $(DESTDIR)$(prefix)/lib/coreos-assembler/tests/kola/bootc tests/kolainst/* +test-tmt: + cargo xtask test-tmt validate: cargo fmt diff --git a/plans/integration.fmf b/plans/integration.fmf new file mode 100644 index 00000000..17cca1d8 --- /dev/null +++ b/plans/integration.fmf @@ -0,0 +1,11 @@ +# This tmt test just demonstrates local tmt usage. +# We'll hopefully expand it to do more interesting things in the +# future and unify with the other test plans. +provision: + how: virtual + # Generated by `cargo xtask ` + image: file://./target/testbootc-cloud.qcow2 +summary: Basic smoke test +execute: + how: tmt + script: bootc status diff --git a/tests-integration/Cargo.toml b/tests-integration/Cargo.toml index 95807497..7540931a 100644 --- a/tests-integration/Cargo.toml +++ b/tests-integration/Cargo.toml @@ -16,7 +16,9 @@ camino = "1.1.6" cap-std-ext = "4" clap = { version= "4.5.4", features = ["derive","cargo"] } fn-error-context = "0.2.1" +indoc = "2.0.5" libtest-mimic = "0.7.3" +oci-spec = "0.6.5" rustix = { "version" = "0.38.34", features = ["thread", "fs", "system", "process"] } serde = { features = ["derive"], version = "1.0.199" } serde_json = "1.0.116" diff --git a/tests-integration/src/runvm.rs b/tests-integration/src/runvm.rs new file mode 100644 index 00000000..deb9686a --- /dev/null +++ b/tests-integration/src/runvm.rs @@ -0,0 +1,165 @@ +use anyhow::{Context, Result}; +use camino::{Utf8Path, Utf8PathBuf}; +use clap::Subcommand; +use fn_error_context::context; +use xshell::{cmd, Shell}; + +const BUILDER_ANNOTATION: &str = "bootc.diskimage-builder"; +const TEST_IMAGE: &str = "localhost/bootc"; +const TESTVMDIR: &str = "testvm"; +const DISK_CACHE: &str = "disk.qcow2"; +const IMAGEID_XATTR: &str = "user.bootc.container-image-digest"; + +#[derive(Debug, Subcommand)] +#[clap(rename_all = "kebab-case")] +pub(crate) enum Opt { + PrepareTmt { + #[clap(long)] + /// The container image to spawn, otherwise one will be built + testimage: Option, + }, + CreateQcow2 { + /// Input container image + container: String, + /// Write disk to this path + disk: Utf8PathBuf, + }, +} + +struct TestContext { + sh: xshell::Shell, + targetdir: Utf8PathBuf, +} + +fn image_digest(sh: &Shell, cimage: &str) -> Result { + let key = "{{ .Digest }}"; + let r = cmd!(sh, "podman inspect --type image --format {key} {cimage}").read()?; + Ok(r) +} + +fn builder_from_image(sh: &Shell, cimage: &str) -> Result { + let mut inspect: serde_json::Value = + serde_json::from_str(&cmd!(sh, "podman inspect --type image {cimage}").read()?)?; + let inspect = inspect + .as_array_mut() + .and_then(|v| v.pop()) + .ok_or_else(|| anyhow::anyhow!("Failed to parse inspect output"))?; + let config = inspect + .get("Config") + .ok_or_else(|| anyhow::anyhow!("Missing config"))?; + let config: oci_spec::image::Config = + serde_json::from_value(config.clone()).context("Parsing config")?; + let builder = config + .labels() + .as_ref() + .and_then(|l| l.get(BUILDER_ANNOTATION)) + .ok_or_else(|| anyhow::anyhow!("Missing {BUILDER_ANNOTATION}"))?; + Ok(builder.to_owned()) +} + +#[context("Running bootc-image-builder")] +fn run_bib(sh: &Shell, cimage: &str, tmpdir: &Utf8Path, diskpath: &Utf8Path) -> Result<()> { + let diskpath: Utf8PathBuf = sh.current_dir().join(diskpath).try_into()?; + let digest = image_digest(sh, cimage)?; + println!("{cimage} digest={digest}"); + if diskpath.try_exists()? { + let mut buf = [0u8; 2048]; + if rustix::fs::getxattr(diskpath.as_std_path(), IMAGEID_XATTR, &mut buf) + .context("Reading xattr") + .is_ok() + { + let buf = String::from_utf8_lossy(&buf); + if &*buf == digest.as_str() { + println!("Existing disk {diskpath} matches container digest {digest}"); + return Ok(()); + } else { + println!("Cache miss; previous digest={buf}"); + } + } + } + let builder = if let Ok(b) = std::env::var("BOOTC_BUILDER") { + b + } else { + builder_from_image(sh, cimage)? + }; + let _g = sh.push_dir(tmpdir); + let bibwork = "bib-work"; + sh.remove_path(bibwork)?; + sh.create_dir(bibwork)?; + let _g = sh.push_dir(bibwork); + let pwd = sh.current_dir(); + cmd!(sh, "podman run --rm --privileged -v /var/lib/containers/storage:/var/lib/containers/storage --security-opt label=type:unconfined_t -v {pwd}:/output {builder} --type qcow2 --local {cimage}").run()?; + let tmp_disk: Utf8PathBuf = sh + .current_dir() + .join("qcow2/disk.qcow2") + .try_into() + .unwrap(); + rustix::fs::setxattr( + tmp_disk.as_std_path(), + IMAGEID_XATTR, + digest.as_bytes(), + rustix::fs::XattrFlags::empty(), + ) + .context("Setting xattr")?; + cmd!(sh, "mv -Tf {tmp_disk} {diskpath}").run()?; + cmd!(sh, "rm -rf {bibwork}").run()?; + Ok(()) +} + +/// Given the input container image reference, create a disk +/// image in the target directory. +#[context("Creating disk")] +fn create_disk(ctx: &TestContext, cimage: &str) -> Result { + let sh = &ctx.sh; + let targetdir = ctx.targetdir.as_path(); + let _targetdir_guard = sh.push_dir(targetdir); + sh.create_dir(TESTVMDIR)?; + let output_disk: Utf8PathBuf = sh + .current_dir() + .join(TESTVMDIR) + .join(DISK_CACHE) + .try_into() + .unwrap(); + + let bibwork = "bib-work"; + sh.remove_path(bibwork)?; + sh.create_dir(bibwork)?; + + run_bib(sh, cimage, bibwork.into(), &output_disk)?; + + Ok(output_disk) +} + +pub(crate) fn run(opt: Opt) -> Result<()> { + let ctx = &{ + let sh = xshell::Shell::new()?; + let mut targetdir: Utf8PathBuf = cmd!(sh, "git rev-parse --show-toplevel").read()?.into(); + targetdir.push("target"); + TestContext { targetdir, sh } + }; + match opt { + Opt::PrepareTmt { mut testimage } => { + let testimage = if let Some(i) = testimage.take() { + i + } else { + cmd!( + &ctx.sh, + "podman build --build-arg=variant=tmt -t {TEST_IMAGE} -f hack/Containerfile ." + ) + .run()?; + TEST_IMAGE.to_string() + }; + + let disk = create_disk(ctx, &testimage)?; + println!("Created: {disk}"); + Ok(()) + } + Opt::CreateQcow2 { container, disk } => { + let g = ctx.sh.push_dir(&ctx.targetdir); + ctx.sh.remove_path("tmp")?; + ctx.sh.create_dir("tmp")?; + drop(g); + run_bib(&ctx.sh, &container, "tmp".into(), &disk) + } + } +} diff --git a/tests-integration/src/tests-integration.rs b/tests-integration/src/tests-integration.rs index 377c5fea..dd43d835 100644 --- a/tests-integration/src/tests-integration.rs +++ b/tests-integration/src/tests-integration.rs @@ -10,6 +10,7 @@ use clap::Parser; mod container; mod hostpriv; mod install; +mod runvm; mod selinux; #[derive(Debug, Parser)] @@ -32,6 +33,8 @@ pub(crate) enum Opt { #[clap(flatten)] testargs: libtest_mimic::Arguments, }, + #[clap(subcommand)] + RunVM(runvm::Opt), /// Extra helper utility to verify SELinux label presence #[clap(name = "verify-selinux")] VerifySELinux { @@ -48,6 +51,7 @@ fn main() { Opt::InstallAlongside { image, testargs } => install::run_alongside(&image, testargs), Opt::HostPrivileged { image, testargs } => hostpriv::run_hostpriv(&image, testargs), Opt::Container { testargs } => container::run(testargs), + Opt::RunVM(opts) => runvm::run(opts), Opt::VerifySELinux { rootfs, warn } => { let root = &Dir::open_ambient_dir(&rootfs, cap_std::ambient_authority()).unwrap(); let mut path = PathBuf::from("."); diff --git a/tests/integration/README.md b/tests/integration/README.md index 1b61ac2f..27079817 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -14,7 +14,7 @@ Integration test includes two scenarios, `RPM build` and `bootc install/upgrade` podman run --rm --privileged -v ./:/workdir:z -e TEST_OS=$TEST_OS -e ARCH=$ARCH -e RHEL_REGISTRY_URL=$RHEL_REGISTRY_URL -e DOWNLOAD_NODE=$DOWNLOAD_NODE --workdir /workdir quay.io/fedora/fedora:40 ./tests/integration/mockbuild.sh ``` -#### Run Integartion Test +#### Run Integration Test Run on a shared test infrastructure using the [`testing farm`](https://docs.testing-farm.io/Testing%20Farm/0.1/cli.html) tool. For example, running on AWS. diff --git a/xtask/src/xtask.rs b/xtask/src/xtask.rs index b2283ec7..79d1dcc4 100644 --- a/xtask/src/xtask.rs +++ b/xtask/src/xtask.rs @@ -23,6 +23,7 @@ const TASKS: &[(&str, fn(&Shell) -> Result<()>)] = &[ ("package", package), ("package-srpm", package_srpm), ("custom-lints", custom_lints), + ("test-tmt", test_tmt), ]; fn try_main() -> Result<()> { @@ -142,6 +143,13 @@ fn man2markdown(sh: &Shell) -> Result<()> { Ok(()) } +#[context("test-integration")] +fn test_tmt(sh: &Shell) -> Result<()> { + cmd!(sh, "cargo run -p tests-integration run-vm prepare-tmt").run()?; + cmd!(sh, "tmt run plans -n integration").run()?; + Ok(()) +} + /// Return a string formatted version of the git commit timestamp, up to the minute /// but not second because, well, we're not going to build more than once a second. #[context("Finding git timestamp")]