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
11 changes: 11 additions & 0 deletions crates/integration-tests/fixtures/Dockerfile.failing-unit
Original file line number Diff line number Diff line change
@@ -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
55 changes: 50 additions & 5 deletions crates/integration-tests/src/tests/run_ephemeral_ssh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
/// Build the test fixture image `fixtures/Dockerfile.{name}` on top of the
/// primary test image
fn build_fixture_image(name: &str) -> anyhow::Result<String> {
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!(
Expand Down Expand Up @@ -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()?;
Expand Down Expand Up @@ -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.
Expand Down
143 changes: 142 additions & 1 deletion crates/kit/src/ephemeral.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand All @@ -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,
Expand All @@ -39,6 +72,13 @@ pub struct SshOpts {
pub args: Vec<String>,
}

/// 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")]
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<Item = OsString>) -> Result<Vec<OsString>> {
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<OsString> = args.collect();
let mut run_ssh_args: Vec<OsString> = ["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<Vec<ContainerListEntry>> {
use bootc_utils::CommandRunExt;
Expand Down Expand Up @@ -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<OsString> = ["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());
}
}
3 changes: 3 additions & 0 deletions crates/kit/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions docs/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading