diff --git a/crates/integration-tests/fixtures/Dockerfile.failing-unit b/crates/integration-tests/fixtures/Dockerfile.failing-unit new file mode 100644 index 000000000..80f236b18 --- /dev/null +++ b/crates/integration-tests/fixtures/Dockerfile.failing-unit @@ -0,0 +1,11 @@ +# Test fixture: Bootc image with a unit that always fails +# The VM boots fine, but systemd ends up "degraded" instead of "running". +# Used to test that bcvk ephemeral test-basic reports the failure. + +ARG BASE_IMAGE=quay.io/centos-bootc/centos-bootc:stream10 + +FROM ${BASE_IMAGE} + +RUN printf '[Unit]\nDescription=bcvk test unit that always fails\n\n[Service]\nType=oneshot\nExecStart=/bin/false\n\n[Install]\nWantedBy=multi-user.target\n' \ + > /usr/lib/systemd/system/bcvk-test-failing.service && \ + systemctl enable bcvk-test-failing.service diff --git a/crates/integration-tests/src/tests/run_ephemeral_ssh.rs b/crates/integration-tests/src/tests/run_ephemeral_ssh.rs index 9784818f5..e20329c16 100644 --- a/crates/integration-tests/src/tests/run_ephemeral_ssh.rs +++ b/crates/integration-tests/src/tests/run_ephemeral_ssh.rs @@ -54,11 +54,12 @@ fn wait_for_container_removal(container_name: &str) -> anyhow::Result<()> { } } -/// Build a test fixture image with the kernel removed -fn build_broken_image() -> anyhow::Result { +/// Build the test fixture image `fixtures/Dockerfile.{name}` on top of the +/// primary test image +fn build_fixture_image(name: &str) -> anyhow::Result { let sh = shell()?; - let fixture_path = concat!(env!("CARGO_MANIFEST_DIR"), "/fixtures/Dockerfile.no-kernel"); - let image_name = format!("localhost/bcvk-test-no-kernel:{}", std::process::id()); + let fixture_path = format!("{}/fixtures/Dockerfile.{name}", env!("CARGO_MANIFEST_DIR")); + let image_name = format!("localhost/bcvk-test-{name}:{}", std::process::id()); let build_arg = format!("BASE_IMAGE={}", get_test_image()); cmd!( @@ -294,7 +295,7 @@ integration_test!(test_run_tmpfs); fn test_run_ephemeral_ssh_broken_image_cleanup() -> TestResult { // Build a broken test image (bootc image with kernel removed) eprintln!("Building broken test image..."); - let broken_image = build_broken_image()?; + let broken_image = build_fixture_image("no-kernel")?; eprintln!("Built broken image: {}", broken_image); let sh = shell()?; @@ -341,6 +342,50 @@ fn test_run_ephemeral_ssh_broken_image_cleanup() -> TestResult { } integration_test!(test_run_ephemeral_ssh_broken_image_cleanup); +/// Test that `test-basic` passes on a healthy image +fn test_ephemeral_test_basic() -> TestResult { + let sh = shell()?; + let bck = get_bck_command()?; + let image = get_test_image(); + let label = INTEGRATION_TEST_LABEL; + + cmd!(sh, "{bck} ephemeral test-basic --label {label} {image}").run()?; + Ok(()) +} +integration_test!(test_ephemeral_test_basic); + +/// Test that `test-basic` fails on an image that boots degraded, naming the +/// failed unit +fn test_ephemeral_test_basic_degraded() -> TestResult { + const FAILING_UNIT: &str = "bcvk-test-failing.service"; + let image = build_fixture_image("failing-unit")?; + + let sh = shell()?; + let bck = get_bck_command()?; + let label = INTEGRATION_TEST_LABEL; + + let output = cmd!(sh, "{bck} ephemeral test-basic --label {label} {image}") + .ignore_status() + .output()?; + + let _ = cmd!(sh, "podman rmi -f {image}") + .ignore_status() + .quiet() + .run(); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + !output.status.success(), + "test-basic succeeded on a degraded image. Output: {stdout}" + ); + assert!( + stdout.lines().next() == Some("degraded") && stdout.contains(FAILING_UNIT), + "Expected the degraded state and {FAILING_UNIT} in the output. Got: {stdout}" + ); + Ok(()) +} +integration_test!(test_ephemeral_test_basic_degraded); + /// Test ephemeral VM network and DNS /// /// Verifies that ephemeral bootc VMs can access the network and resolve DNS correctly. diff --git a/crates/kit/src/ephemeral.rs b/crates/kit/src/ephemeral.rs index 0c4745cb3..0b5a1dc37 100644 --- a/crates/kit/src/ephemeral.rs +++ b/crates/kit/src/ephemeral.rs @@ -4,10 +4,15 @@ //! Ephemeral VMs are temporary, non-persistent VMs that are useful for testing, development, //! and CI/CD workflows. +use std::ffi::OsString; +use std::os::unix::process::CommandExt; use std::process::Command; use clap::Subcommand; -use color_eyre::{eyre::eyre, Result}; +use color_eyre::{ + eyre::{eyre, Context as _}, + Result, +}; use comfy_table::{presets::UTF8_FULL, Table}; use serde::{Deserialize, Serialize}; @@ -19,6 +24,34 @@ use crate::ssh; /// Label used to identify bcvk ephemeral containers const EPHEMERAL_LABEL: &str = "bcvk.ephemeral=1"; +/// Name of the `test-basic` subcommand, which re-executes itself as `run-ssh` +const TEST_BASIC_CMD: &str = "test-basic"; + +/// How long `test-basic` waits for boot to finish once SSH is up. Without a +/// bound, a start job with no timeout (the default for `Type=oneshot`) would +/// hang it forever. +const TEST_BASIC_TIMEOUT_SECS: u32 = 600; + +/// Exit status of `test-basic` when boot didn't finish in time (that of +/// timeout(1)); it exits 1 when systemd finished in a state other than +/// "running". +const TEST_BASIC_TIMEOUT_EXIT: u8 = 124; + +/// Command run in the guest by `test-basic`: wait for boot to finish, and if +/// systemd didn't reach "running" (e.g. "degraded"), show the failed units, +/// or the pending jobs if it timed out. +fn test_basic_script() -> String { + let secs = TEST_BASIC_TIMEOUT_SECS; + let timeout_rc = TEST_BASIC_TIMEOUT_EXIT; + format!( + "timeout {secs} systemctl is-system-running --wait; rc=$?; \ + if [ $rc -eq {timeout_rc} ]; then \ + echo 'Timed out after {secs}s waiting for boot to finish; pending jobs:' >&2; \ + systemctl list-jobs --no-pager >&2; exit {timeout_rc}; \ + elif [ $rc -ne 0 ]; then systemctl --failed --no-pager; exit 1; fi" + ) +} + /// SSH connection options for accessing running VMs. /// /// Provides secure shell access to VMs running within containers, @@ -39,6 +72,13 @@ pub struct SshOpts { pub args: Vec, } +/// Options for the test-basic subcommand +#[derive(clap::Parser, Debug)] +pub struct TestBasicOpts { + #[command(flatten)] + pub run_opts: run_ephemeral::RunEphemeralOpts, +} + /// Container list entry for ephemeral VMs #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] @@ -136,6 +176,16 @@ pub enum EphemeralCommands { #[clap(name = "run-ssh")] RunSsh(run_ephemeral_ssh::RunEphemeralSshOpts), + /// Boot an ephemeral VM and check that systemd reaches the running state + /// + /// A smoke test for bootc images: this is shorthand for + /// `bcvk ephemeral run-ssh IMAGE -- systemctl is-system-running --wait`, + /// which also lists the failed units when the system comes up degraded. + /// On success it prints just the state, "running". Otherwise it exits 1, + /// or 124 if boot didn't finish within 10 minutes of SSH coming up. + #[clap(name = TEST_BASIC_CMD)] + TestBasic(TestBasicOpts), + /// Connect to running VMs via SSH #[clap(name = "ssh")] Ssh(SshOpts), @@ -163,6 +213,9 @@ impl EphemeralCommands { match self { EphemeralCommands::Run(opts) => run_ephemeral::run(opts), EphemeralCommands::RunSsh(opts) => run_ephemeral_ssh::run_ephemeral_ssh(opts), + // The options were parsed only to validate them (and for --help); + // run-ssh takes the same ones, straight from our argv. + EphemeralCommands::TestBasic(_) => test_basic(), EphemeralCommands::Ssh(opts) => { // Create progress bar if stderr is a terminal let progress_bar = crate::boot_progress::create_boot_progress_bar(); @@ -220,6 +273,35 @@ impl EphemeralCommands { } } +/// Arguments for `bcvk ephemeral run-ssh` equivalent to our own `test-basic` +/// invocation, given its arguments (without argv\[0\]). +fn test_basic_run_ssh_args(args: impl IntoIterator) -> Result> { + let mut args = args.into_iter().skip_while(|arg| arg != TEST_BASIC_CMD); + if args.next().is_none() { + return Err(eyre!("Failed to find {TEST_BASIC_CMD} in arguments")); + } + let opts: Vec = args.collect(); + let mut run_ssh_args: Vec = ["ephemeral", "run-ssh"].map(Into::into).into(); + // The image is the only positional argument, so a `--` the user already + // gave before it also separates the command from it. + let has_separator = opts.iter().any(|arg| arg == "--"); + run_ssh_args.extend(opts); + if !has_separator { + run_ssh_args.push("--".into()); + } + run_ssh_args.extend(["/bin/sh".into(), "-c".into(), test_basic_script().into()]); + Ok(run_ssh_args) +} + +/// Run `test-basic` by re-executing ourselves as `run-ssh`. +fn test_basic() -> Result<()> { + let mut argv = std::env::args_os(); + let arg0 = argv.next().unwrap_or_else(|| "bcvk".into()); + let args = test_basic_run_ssh_args(argv)?; + let err = Command::new("/proc/self/exe").arg0(arg0).args(args).exec(); + Err(err).context("Failed to execute bcvk ephemeral run-ssh") +} + /// List ephemeral VM containers with bcvk.ephemeral=1 label pub(crate) fn list_ephemeral_containers() -> Result> { use bootc_utils::CommandRunExt; @@ -335,3 +417,62 @@ fn remove_all_ephemeral_containers(force: bool) -> Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_basic_args_to_run_ssh() { + let script = test_basic_script(); + let command = ["/bin/sh", "-c", script.as_str()]; + let cases: &[(&[&str], &[&str])] = &[ + ( + &["ephemeral", "test-basic", "quay.io/example/os"], + &["quay.io/example/os", "--"], + ), + ( + &[ + "ephemeral", + "test-basic", + "--memory", + "4G", + "--label", + "k=v", + "img", + ], + &["--memory", "4G", "--label", "k=v", "img", "--"], + ), + ( + &[ + "ephemeral", + "test-basic", + "--memory=8G", + "-K", + "-e", + "A=B", + "img", + ], + &["--memory=8G", "-K", "-e", "A=B", "img", "--"], + ), + // A separator the user already gave is reused + (&["ephemeral", "test-basic", "img", "--"], &["img", "--"]), + ( + &["ephemeral", "test-basic", "--vcpus", "2", "--", "img"], + &["--vcpus", "2", "--", "img"], + ), + ]; + for (input, expected_opts) in cases { + let expected: Vec = ["ephemeral", "run-ssh"] + .iter() + .chain(expected_opts.iter()) + .chain(command.iter()) + .map(Into::into) + .collect(); + let args = test_basic_run_ssh_args(input.iter().map(Into::into)).unwrap(); + assert_eq!(args, expected, "input: {input:?}"); + } + + assert!(test_basic_run_ssh_args(["ephemeral", "run-ssh"].map(Into::into)).is_err()); + } +} diff --git a/crates/kit/src/main.rs b/crates/kit/src/main.rs index a593feddb..8b9911050 100644 --- a/crates/kit/src/main.rs +++ b/crates/kit/src/main.rs @@ -115,6 +115,9 @@ pub enum StubEphemeralCommands { /// Run ephemeral VM and SSH into it #[clap(name = "run-ssh")] RunSsh, + /// Boot an ephemeral VM and check that systemd reaches the running state + #[clap(name = "test-basic")] + TestBasic, /// Connect to running VMs via SSH #[clap(name = "ssh")] Ssh, diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 7b5b317e4..c69ec36f7 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -14,6 +14,7 @@ - [ephemeral run](./man/bcvk-ephemeral-run.md) - [ephemeral ssh](./man/bcvk-ephemeral-ssh.md) - [ephemeral run-ssh](./man/bcvk-ephemeral-run-ssh.md) + - [ephemeral test-basic](./man/bcvk-ephemeral-test-basic.md) - [to-disk](./man/bcvk-to-disk.md) - [images](./man/bcvk-images.md) - [images list](./man/bcvk-images-list.md) diff --git a/docs/src/man/bcvk-ephemeral-test-basic.md b/docs/src/man/bcvk-ephemeral-test-basic.md new file mode 100644 index 000000000..235e28b40 --- /dev/null +++ b/docs/src/man/bcvk-ephemeral-test-basic.md @@ -0,0 +1,195 @@ +# NAME + +bcvk-ephemeral-test-basic - Boot an ephemeral VM and check that systemd reaches the running state + +# SYNOPSIS + +**bcvk ephemeral test-basic** \[*OPTIONS*\] *IMAGE* + +# DESCRIPTION + +Boot **IMAGE** as an ephemeral VM and check that systemd reaches the +"running" state, as a quick smoke test for bootc container images. + +This is shorthand for: + + bcvk ephemeral run-ssh IMAGE -- systemctl is-system-running --wait + +(run through **/bin/sh -c** in the VM, with the handling below). It prints +the final system state, so on success its only output is "running". If the +state is anything else (e.g. "degraded" because a unit failed), it also lists +the failed units. As with **bcvk-ephemeral-run-ssh**(8), the VM is removed +when the check completes. + +# EXIT STATUS + +**0** + +: systemd reached the "running" state + +**1** + +: systemd finished booting in another state, such as "degraded" + +**124** + +: boot did not finish within 10 minutes of SSH becoming available (e.g. a + start job with no timeout); the pending jobs are listed + +Failures of **bcvk-ephemeral-run-ssh**(8) itself, for example when the VM +fails to boot or SSH never becomes available, also exit non-zero (usually 1) +and print an error on stderr. + +# OPTIONS + + +**IMAGE** + + Container image to run as ephemeral VM + + This argument is required. + +**--itype**=*ITYPE* + + Instance type (e.g., u1.nano, u1.small, u1.medium). Overrides vcpus/memory if specified. + +**--memory**=*MEMORY* + + Memory size (e.g. 4G, 2048M, or plain number for MB) + + Default: 4G + +**--vcpus**=*VCPUS* + + Number of vCPUs (overridden by --itype if specified) + +**--console** + + Connect the QEMU console to the container's stdio (visible via podman logs/attach) + +**--debug** + + Enable debug mode (drop to shell instead of running QEMU) + +**--virtio-serial-out**=*NAME:FILE* + + Add virtio-serial device with output to file (format: name:/path/to/file) + +**--execute**=*EXECUTE* + + Execute command inside VM via systemd and capture output + +**-K**, **--ssh-keygen** + + Generate SSH keypair and inject via systemd credentials + +**--virtiofsd**=*VIRTIOFSD_BINARY* + + Path to virtiofsd binary (overrides auto-detection) + +**--output**=*OUTPUT* + + Select how VM output is presented + + Possible values: + - console + - journal + + Default: console + +**--log-dir**=*STREAMS=DIR* + + Write VM log streams to files in DIR + +**-t**, **--tty** + + Allocate a pseudo-TTY for container + +**-i**, **--interactive** + + Keep STDIN open for container + +**-d**, **--detach** + + Run container in background + +**--rm** + + Automatically remove container when it exits + +**--name**=*NAME* + + Assign a name to the container + +**--network**=*NETWORK* + + Configure the network for the container + +**--label**=*LABEL* + + Add metadata to the container in key=value form + +**-e**, **--env**=*ENV* + + Set environment variables in the container (key=value) + +**--debug-entrypoint**=*DEBUG_ENTRYPOINT* + + Do not run the default entrypoint directly, but instead invoke the provided command (e.g. `bash`) + +**--bind**=*HOST_PATH[:NAME]* + + Bind mount host directory (RW) at /run/virtiofs-mnt- + +**--ro-bind**=*HOST_PATH[:NAME]* + + Bind mount host directory (RO) at /run/virtiofs-mnt- + +**--systemd-units**=*SYSTEMD_UNITS_DIR* + + Directory with systemd units to inject (expects system/ subdirectory) + +**--bind-storage-ro** + + Mount host container storage (RO) at /run/virtiofs-mnt-hoststorage + +**--add-swap**=*ADD_SWAP* + + Allocate a swap device of the provided size + +**--mount-disk-file**=*FILE[:NAME]* + + Mount disk file as virtio-blk device at /dev/disk/by-id/virtio- + +**--karg**=*KERNEL_ARGS* + + Additional kernel command line arguments + +**--ignition**=*IGNITION_CONFIG* + + Path to Ignition config file (JSON format) to inject via fw_cfg + + + +# EXAMPLES + +Smoke test a Fedora bootc image: + + bcvk ephemeral test-basic quay.io/fedora/fedora-bootc:42 + +Test a locally built image: + + podman build -t localhost/mybootc . + bcvk ephemeral test-basic localhost/mybootc + +Test with more memory (the default is 4G) and CPUs: + + bcvk ephemeral test-basic --memory 8G --vcpus 4 localhost/mybootc + +# SEE ALSO + +**bcvk-ephemeral**(8), **bcvk-ephemeral-run-ssh**(8) + +# VERSION + + diff --git a/docs/src/man/bcvk-ephemeral.md b/docs/src/man/bcvk-ephemeral.md index d1d27a49d..71775e713 100644 --- a/docs/src/man/bcvk-ephemeral.md +++ b/docs/src/man/bcvk-ephemeral.md @@ -46,6 +46,10 @@ bcvk-ephemeral-run-ssh(8) : Run an ephemeral VM and immediately SSH into it (auto-cleanup on exit) +bcvk-ephemeral-test-basic(8) + +: Boot an ephemeral VM and check that systemd reaches the running state + bcvk-ephemeral-ssh(8) : SSH into a running ephemeral VM