From 65136e03e5812cf6b272473756c222e3437ec1bd Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Thu, 16 May 2024 14:51:39 -0400 Subject: [PATCH] Move install tests shell script into Rust A few things going on here: - Rewrite logic from shell script into Rust (using xshell, so it's still convenient to fork commands) - Make the test logic take an externally-built container image instead of using a `-v bootc:/usr/bin/bootc` bind mount - Build the container image using our stock hack/Containerfile in Github Actions instead of building for c9s in GHA - This all hence starts to make the logic reusable outside of Github Actions too; the container build is a known standard thing. Signed-off-by: Colin Walters --- .github/workflows/ci.yml | 98 ++-------------- Cargo.lock | 84 ++++++++++++++ Cargo.toml | 2 +- hack/Containerfile | 2 +- lib/src/docgen.rs | 8 +- tests-integration/Cargo.toml | 21 ++++ tests-integration/README.md | 24 ++++ tests-integration/src/hostpriv.rs | 27 +++++ tests-integration/src/install.rs | 125 +++++++++++++++++++++ tests-integration/src/tests-integration.rs | 32 ++++++ 10 files changed, 331 insertions(+), 92 deletions(-) create mode 100644 tests-integration/Cargo.toml create mode 100644 tests-integration/README.md create mode 100644 tests-integration/src/hostpriv.rs create mode 100644 tests-integration/src/install.rs create mode 100644 tests-integration/src/tests-integration.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a04ff0e..045c73ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,26 +68,6 @@ jobs: with: name: bootc.tar.zst path: target/bootc.tar.zst - build-c9s: - if: ${{ !contains(github.event.pull_request.labels.*.name, 'control/skip-ci') }} - runs-on: ubuntu-latest - container: quay.io/centos/centos:stream9 - steps: - - run: dnf -y install git-core - - uses: actions/checkout@v4 - - name: Install deps - run: ./ci/installdeps.sh - - name: Cache Dependencies - uses: Swatinem/rust-cache@v2 - with: - key: "build-c9s" - - name: Build - run: make test-bin-archive - - name: Upload binary - uses: actions/upload-artifact@v4 - with: - name: bootc-c9s.tar.zst - path: target/bootc.tar.zst cargo-deny: runs-on: ubuntu-latest steps: @@ -127,78 +107,24 @@ jobs: run: sudo tar -C / -xvf bootc.tar.zst - name: Integration tests run: bootc internal-tests run-container-integration - privtest-alongside: + install-tests: if: ${{ !contains(github.event.pull_request.labels.*.name, 'control/skip-ci') }} - name: "Test install-alongside" - needs: [build-c9s] - runs-on: ubuntu-latest + name: "Test install" + # For a not-ancient podman + runs-on: ubuntu-24.04 steps: + - name: Checkout repository + uses: actions/checkout@v4 - name: Ensure host skopeo is disabled run: sudo rm -f /bin/skopeo /usr/bin/skopeo - - name: Download - uses: actions/download-artifact@v4 - with: - name: bootc-c9s.tar.zst - - name: Install - run: tar -xvf bootc.tar.zst - - name: Integration tests - run: | - set -xeuo pipefail - image=quay.io/centos-bootc/centos-bootc-dev:stream9 - echo 'ssh-ed25519 ABC0123 testcase@example.com' > test_authorized_keys - sudo podman run --rm --privileged -v ./test_authorized_keys:/test_authorized_keys --env RUST_LOG=debug -v /:/target -v /var/lib/containers:/var/lib/containers -v ./usr/bin/bootc:/usr/bin/bootc --pid=host --security-opt label=disable \ - ${image} bootc install to-filesystem --acknowledge-destructive \ - --karg=foo=bar --disable-selinux --replace=alongside --root-ssh-authorized-keys=/test_authorized_keys /target - ls -al /boot/loader/ - sudo grep foo=bar /boot/loader/entries/*.conf - grep authorized_keys /ostree/deploy/default/deploy/*/etc/tmpfiles.d/bootc-root-ssh.conf - # TODO fix https://github.com/containers/bootc/pull/137 - sudo chattr -i /ostree/deploy/default/deploy/* - sudo rm /ostree/deploy/default -rf - sudo podman run --rm --privileged --env RUST_LOG=debug -v /:/target -v /var/lib/containers:/var/lib/containers -v ./usr/bin/bootc:/usr/bin/bootc --pid=host --security-opt label=disable \ - ${image} bootc install to-existing-root --acknowledge-destructive - sudo podman run --rm --privileged -v /:/target -v ./usr/bin/bootc:/usr/bin/bootc --pid=host --security-opt label=disable ${image} bootc internal-tests verify-selinux /target/ostree --warn - install-to-existing-root: - if: ${{ !contains(github.event.pull_request.labels.*.name, 'control/skip-ci') }} - name: "Test install-to-existing-root" - needs: [build-c9s] - runs-on: ubuntu-latest - steps: - - name: Download - uses: actions/download-artifact@v4 - with: - name: bootc-c9s.tar.zst - - name: Install - run: tar -xvf bootc.tar.zst - - name: Integration tests - run: | - set -xeuo pipefail - # We should be able to install to-existing-root with no install config, - # so we bind mount an empty directory over /usr/lib/bootc/install. - empty=$(mktemp -d) - image=quay.io/centos-bootc/centos-bootc-dev:stream9 - sudo podman run --rm --privileged --env RUST_LOG=debug -v /:/target -v /var/lib/containers:/var/lib/containers -v ./usr/bin/bootc:/usr/bin/bootc -v ${empty}:/usr/lib/bootc/install --pid=host --security-opt label=disable \ - ${image} bootc install to-existing-root - install-to-loopback: - if: ${{ !contains(github.event.pull_request.labels.*.name, 'control/skip-ci') }} - name: "Test install to-disk --via-loopback" - needs: [build-c9s] - runs-on: ubuntu-latest - steps: - - name: Download - uses: actions/download-artifact@v4 - with: - name: bootc-c9s.tar.zst - - name: Install - run: tar -xvf bootc.tar.zst - name: Integration tests run: | - set -xeuo pipefail - image=quay.io/centos-bootc/centos-bootc-dev:stream9 - tmpdisk=$(mktemp -p /var/tmp) - truncate -s 20G ${tmpdisk} - sudo podman run --rm --privileged --env RUST_LOG=debug -v /dev:/dev -v /:/target -v /var/lib/containers:/var/lib/containers -v ./usr/bin/bootc:/usr/bin/bootc --pid=host --security-opt label=disable \ - -v ${tmpdisk}:/disk ${image} bootc install to-disk --via-loopback /disk + set -xeu + sudo podman build -t localhost/bootc -f hack/Containerfile . + # Nondestructive but privileged tests + cargo run -p tests-integration host-privileged localhost/bootc + # Finally the install-alongside suite + cargo run -p tests-integration install-alongside localhost/bootc docs: if: ${{ contains(github.event.pull_request.labels.*.name, 'documentation') }} runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index 9e81ac5d..b39a279c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -609,6 +609,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "escape8259" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba4f4911e3666fcd7826997b4745c8224295a6f3072f1418c3067b97a67557ee" +dependencies = [ + "rustversion", +] + [[package]] name = "fastrand" version = "2.0.1" @@ -932,6 +941,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + [[package]] name = "hex" version = "0.4.3" @@ -1105,6 +1120,18 @@ dependencies = [ "uuid", ] +[[package]] +name = "libtest-mimic" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc0bda45ed5b3a2904262c1bb91e526127aa70e7ef3758aba2ef93cf896b9b58" +dependencies = [ + "clap", + "escape8259", + "termcolor", + "threadpool", +] + [[package]] name = "libz-sys" version = "1.1.15" @@ -1253,6 +1280,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_cpus" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "number_prefix" version = "0.4.0" @@ -1661,6 +1698,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustversion" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6" + [[package]] name = "ryu" version = "1.0.16" @@ -1937,6 +1980,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "terminal_size" version = "0.3.0" @@ -1947,6 +1999,20 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "tests-integration" +version = "0.1.0" +dependencies = [ + "anyhow", + "camino", + "cap-std-ext", + "clap", + "fn-error-context", + "libtest-mimic", + "tempfile", + "xshell", +] + [[package]] name = "thiserror" version = "1.0.56" @@ -1977,6 +2043,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "threadpool" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" +dependencies = [ + "num_cpus", +] + [[package]] name = "time" version = "0.3.34" @@ -2340,6 +2415,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d4cc384e1e73b93bafa6fb4f1df8c41695c8a91cf9c4c64358067d15a7b6c6b" +dependencies = [ + "windows-sys 0.52.0", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/Cargo.toml b/Cargo.toml index 0c3ab591..371c1e2b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["cli", "lib", "xtask"] +members = ["cli", "lib", "xtask", "tests-integration"] resolver = "2" [profile.dev] diff --git a/hack/Containerfile b/hack/Containerfile index d4b0bee3..728e1691 100644 --- a/hack/Containerfile +++ b/hack/Containerfile @@ -7,7 +7,7 @@ WORKDIR /build RUN mkdir -p /build/target/dev-rootfs # This can hold arbitrary extra content # See https://www.reddit.com/r/rust/comments/126xeyx/exploring_the_problem_of_faster_cargo_docker/ # We aren't using the full recommendations there, just the simple bits. -RUN --mount=type=cache,target=/build/target --mount=type=cache,target=/var/roothome make bin-archive && mkdir -p /out && cp target/bootc.tar.zst /out +RUN --mount=type=cache,target=/build/target --mount=type=cache,target=/var/roothome make test-bin-archive && mkdir -p /out && cp target/bootc.tar.zst /out FROM quay.io/centos-bootc/centos-bootc:stream9 COPY --from=build /out/bootc.tar.zst /tmp diff --git a/lib/src/docgen.rs b/lib/src/docgen.rs index 06ea39b2..a76191e4 100644 --- a/lib/src/docgen.rs +++ b/lib/src/docgen.rs @@ -15,8 +15,7 @@ pub fn generate_manpages(directory: &Utf8Path) -> Result<()> { fn generate_one(directory: &Utf8Path, cmd: Command) -> Result<()> { let version = env!("CARGO_PKG_VERSION"); let name = cmd.get_name(); - let bin_name = cmd.get_bin_name() - .unwrap_or_else(|| name); + let bin_name = cmd.get_bin_name().unwrap_or_else(|| name); let path = directory.join(format!("{name}.8")); println!("Generating {path}..."); @@ -37,12 +36,13 @@ fn generate_one(directory: &Utf8Path, cmd: Command) -> Result<()> { for subcmd in cmd.get_subcommands().filter(|c| !c.is_hide_set()) { let subname = format!("{}-{}", name, subcmd.get_name()); - let bin_name = format!("{} {}", bin_name, subcmd.get_name()); + let bin_name = format!("{} {}", bin_name, subcmd.get_name()); // SAFETY: Latest clap 4 requires names are &'static - this is // not long-running production code, so we just leak the names here. let subname = &*std::boxed::Box::leak(subname.into_boxed_str()); let bin_name = &*std::boxed::Box::leak(bin_name.into_boxed_str()); - let subcmd = subcmd.clone() + let subcmd = subcmd + .clone() .name(subname) .alias(subname) .bin_name(bin_name) diff --git a/tests-integration/Cargo.toml b/tests-integration/Cargo.toml new file mode 100644 index 00000000..f6821ed6 --- /dev/null +++ b/tests-integration/Cargo.toml @@ -0,0 +1,21 @@ +# Our integration tests +[package] +name = "tests-integration" +version = "0.1.0" +license = "MIT OR Apache-2.0" +edition = "2021" +publish = false + +[[bin]] +name = "tests-integration" +path = "src/tests-integration.rs" + +[dependencies] +anyhow = "1.0.82" +camino = "1.1.6" +cap-std-ext = "4" +clap = { version= "4.5.4", features = ["derive","cargo"] } +fn-error-context = "0.2.1" +libtest-mimic = "0.7.3" +tempfile = "3.10.1" +xshell = { version = "0.2.6" } diff --git a/tests-integration/README.md b/tests-integration/README.md new file mode 100644 index 00000000..a6336480 --- /dev/null +++ b/tests-integration/README.md @@ -0,0 +1,24 @@ +# Integration tests crate + +This crate holds integration tests (as distinct from the regular +Rust unit tests run as part of `cargo test`). + +## Building and running + +`cargo run -p tests-integration` +will work. Note that at the current time all test suites target +an externally built bootc-compatible container image. See +how things are set up in e.g. Github Actions, where we first +run a `podman build` with the bootc git sources. + +## Available suites + +### `host-privileged` + +This suite will run the target container image in a way that expects +full privileges, but is *not* destructive. + +### `install-alongside` + +This suite is *DESTRUCTIVE*, executing the bootc `install to-existing-root` +style flow using the host root. Run it in a transient virtual machine. diff --git a/tests-integration/src/hostpriv.rs b/tests-integration/src/hostpriv.rs new file mode 100644 index 00000000..c7cf210f --- /dev/null +++ b/tests-integration/src/hostpriv.rs @@ -0,0 +1,27 @@ +use anyhow::Result; +use fn_error_context::context; +use libtest_mimic::Trial; +use xshell::cmd; + +/// Tests that require real root (e.g. CAP_SYS_ADMIN) to do things like +/// create loopback devices, but are *not* destructive. At the current time +/// these tests are defined to reference a bootc container image. +#[context("Hostpriv tests")] +pub(crate) fn run_hostpriv(image: &str, testargs: libtest_mimic::Arguments) -> Result<()> { + // Just leak the image name so we get a static reference as required by the test framework + let image: &'static str = String::from(image).leak(); + let base_args = super::install::BASE_ARGS; + + let tests = [Trial::test("loopback install", move || { + let sh = &xshell::Shell::new()?; + let size = 10 * 1000 * 1000 * 1000; + let mut tmpdisk = tempfile::NamedTempFile::new_in("/var/tmp")?; + tmpdisk.as_file_mut().set_len(size)?; + let tmpdisk = tmpdisk.into_temp_path(); + let tmpdisk = tmpdisk.to_str().unwrap(); + cmd!(sh, "sudo {base_args...} -v {tmpdisk}:/disk {image} bootc install to-disk --via-loopback --skip-fetch-check /disk").run()?; + Ok(()) + })]; + + libtest_mimic::run(&testargs, tests.into()).exit() +} diff --git a/tests-integration/src/install.rs b/tests-integration/src/install.rs new file mode 100644 index 00000000..0339cd84 --- /dev/null +++ b/tests-integration/src/install.rs @@ -0,0 +1,125 @@ +use std::os::fd::AsRawFd; +use std::path::Path; + +use anyhow::Result; +use cap_std_ext::cap_std; +use cap_std_ext::cap_std::fs::Dir; +use fn_error_context::context; +use libtest_mimic::Trial; +use xshell::{cmd, Shell}; + +pub(crate) const BASE_ARGS: &[&str] = &[ + "podman", + "run", + "--rm", + "--privileged", + "-v", + "/dev:/dev", + "-v", + "/var/lib/containers:/var/lib/containers", + "--pid=host", + "--security-opt", + "label=disable", +]; + +// Clear out and delete any ostree roots +fn reset_root(sh: &Shell) -> Result<()> { + // TODO fix https://github.com/containers/bootc/pull/137 + if !Path::new("/ostree/deploy/default").exists() { + return Ok(()); + } + cmd!( + sh, + "sudo /bin/sh -c 'chattr -i /ostree/deploy/default/deploy/*'" + ) + .run()?; + cmd!(sh, "sudo rm /ostree/deploy/default -rf").run()?; + Ok(()) +} + +fn find_deployment_root() -> Result { + let _stateroot = "default"; + let d = Dir::open_ambient_dir( + "/ostree/deploy/default/deploy", + cap_std::ambient_authority(), + )?; + for child in d.entries()? { + let child = child?; + if !child.file_type()?.is_dir() { + continue; + } + return Ok(child.open_dir()?); + } + anyhow::bail!("Failed to find deployment root") +} + +#[context("Install tests")] +pub(crate) fn run_alongside(image: &str, mut testargs: libtest_mimic::Arguments) -> Result<()> { + // Force all of these tests to be serial because they mutate global state + testargs.test_threads = Some(1); + // Just leak the image name so we get a static reference as required by the test framework + let image: &'static str = String::from(image).leak(); + // Handy defaults + + let target_args = &["-v", "/:/target"]; + // We always need this as we assume we're operating on a local image + let generic_inst_args = ["--skip-fetch-check"]; + + let tests = [ + Trial::test("loopback install", move || { + let sh = &xshell::Shell::new()?; + reset_root(sh)?; + let size = 10 * 1000 * 1000 * 1000; + let mut tmpdisk = tempfile::NamedTempFile::new_in("/var/tmp")?; + tmpdisk.as_file_mut().set_len(size)?; + let tmpdisk = tmpdisk.into_temp_path(); + let tmpdisk = tmpdisk.to_str().unwrap(); + cmd!(sh, "sudo {BASE_ARGS...} -v {tmpdisk}:/disk {image} bootc install to-disk --via-loopback {generic_inst_args...} /disk").run()?; + Ok(()) + }), + Trial::test( + "replace=alongside with ssh keys and a karg, and SELinux disabled", + move || { + let sh = &xshell::Shell::new()?; + reset_root(sh)?; + let tmpd = &sh.create_temp_dir()?; + let tmp_keys = tmpd.path().join("test_authorized_keys"); + let tmp_keys = tmp_keys.to_str().unwrap(); + std::fs::write(&tmp_keys, b"ssh-ed25519 ABC0123 testcase@example.com")?; + cmd!(sh, "sudo {BASE_ARGS...} {target_args...} -v {tmp_keys}:/test_authorized_keys {image} bootc install to-filesystem {generic_inst_args...} --acknowledge-destructive --karg=foo=bar --replace=alongside --root-ssh-authorized-keys=/test_authorized_keys /target").run()?; + + cmd!( + sh, + "sudo /bin/sh -c 'grep foo=bar /boot/loader/entries/*.conf'" + ) + .run()?; + let deployment = &find_deployment_root()?; + let cwd = sh.push_dir(format!("/proc/self/fd/{}", deployment.as_raw_fd())); + cmd!( + sh, + "grep authorized_keys etc/tmpfiles.d/bootc-root-ssh.conf" + ) + .run()?; + drop(cwd); + Ok(()) + }, + ), + Trial::test("Install and verify selinux state", move || { + let sh = &xshell::Shell::new()?; + reset_root(sh)?; + cmd!(sh, "sudo {BASE_ARGS...} {target_args...} {image} bootc install to-existing-root --acknowledge-destructive {generic_inst_args...}").run()?; + cmd!(sh, "sudo podman run --rm --privileged --pid=host {target_args...} {image} bootc internal-tests verify-selinux /target/ostree --warn").run()?; + Ok(()) + }), + Trial::test("without an install config", move || { + let sh = &xshell::Shell::new()?; + reset_root(sh)?; + let empty = sh.create_temp_dir()?; + let empty = empty.path().to_str().unwrap(); + cmd!(sh, "sudo {BASE_ARGS...} {target_args...} -v {empty}:/usr/lib/bootc/install {image} bootc install to-existing-root {generic_inst_args...}").run()?; + Ok(()) + }), + ]; + + libtest_mimic::run(&testargs, tests.into()).exit() +} diff --git a/tests-integration/src/tests-integration.rs b/tests-integration/src/tests-integration.rs new file mode 100644 index 00000000..250f96f0 --- /dev/null +++ b/tests-integration/src/tests-integration.rs @@ -0,0 +1,32 @@ +use clap::Parser; + +mod hostpriv; +mod install; + +#[derive(Debug, Parser)] +#[clap(name = "bootc-integration-tests", version, rename_all = "kebab-case")] +pub(crate) enum Opt { + InstallAlongside { + /// Source container image reference + image: String, + #[clap(flatten)] + testargs: libtest_mimic::Arguments, + }, + HostPrivileged { + image: String, + #[clap(flatten)] + testargs: libtest_mimic::Arguments, + }, +} + +fn main() { + let opt = Opt::parse(); + let r = match opt { + Opt::InstallAlongside { image, testargs } => install::run_alongside(&image, testargs), + Opt::HostPrivileged { image, testargs } => hostpriv::run_hostpriv(&image, testargs), + }; + if let Err(e) = r { + eprintln!("error: {e:?}"); + std::process::exit(1); + } +}