Skip to content

Commit 3173ba5

Browse files
cgwalters-botcgwalters
authored andcommitted
ephemeral: Add test-basic subcommand
A quick "does this image boot healthy" check is what most CI consumers of bcvk end up writing by hand with run-ssh, so give it a name: `bcvk ephemeral test-basic IMAGE` is shorthand for run-ssh with `systemctl is-system-running --wait`, which also lists the failed units when the system comes up degraded, and exits non-zero unless it reaches "running". Now that bcvk masks bootloader-update.service, stock images reach "running" in an ephemeral VM, so a degraded state really means something is wrong with the image. Rather than duplicating run-ssh's VM lifecycle and SSH handling, it re-executes bcvk as run-ssh with the same arguments, so it can't drift from it. The options are still declared (flattened from `ephemeral run`) so they are validated up front and show up in --help and the man page. Based on #244 by Xiaofeng Wang and the automated follow-up #357, reworked per review there. Closes: #356 Generated-by: AI Signed-off-by: Colin Walters <walters@verbum.org>
1 parent b56c816 commit 3173ba5

7 files changed

Lines changed: 406 additions & 6 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Test fixture: Bootc image with a unit that always fails
2+
# The VM boots fine, but systemd ends up "degraded" instead of "running".
3+
# Used to test that bcvk ephemeral test-basic reports the failure.
4+
5+
ARG BASE_IMAGE=quay.io/centos-bootc/centos-bootc:stream10
6+
7+
FROM ${BASE_IMAGE}
8+
9+
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' \
10+
> /usr/lib/systemd/system/bcvk-test-failing.service && \
11+
systemctl enable bcvk-test-failing.service

‎crates/integration-tests/src/tests/run_ephemeral_ssh.rs‎

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,12 @@ fn wait_for_container_removal(container_name: &str) -> anyhow::Result<()> {
5454
}
5555
}
5656

57-
/// Build a test fixture image with the kernel removed
58-
fn build_broken_image() -> anyhow::Result<String> {
57+
/// Build the test fixture image `fixtures/Dockerfile.{name}` on top of the
58+
/// primary test image
59+
fn build_fixture_image(name: &str) -> anyhow::Result<String> {
5960
let sh = shell()?;
60-
let fixture_path = concat!(env!("CARGO_MANIFEST_DIR"), "/fixtures/Dockerfile.no-kernel");
61-
let image_name = format!("localhost/bcvk-test-no-kernel:{}", std::process::id());
61+
let fixture_path = format!("{}/fixtures/Dockerfile.{name}", env!("CARGO_MANIFEST_DIR"));
62+
let image_name = format!("localhost/bcvk-test-{name}:{}", std::process::id());
6263
let build_arg = format!("BASE_IMAGE={}", get_test_image());
6364

6465
cmd!(
@@ -294,7 +295,7 @@ integration_test!(test_run_tmpfs);
294295
fn test_run_ephemeral_ssh_broken_image_cleanup() -> TestResult {
295296
// Build a broken test image (bootc image with kernel removed)
296297
eprintln!("Building broken test image...");
297-
let broken_image = build_broken_image()?;
298+
let broken_image = build_fixture_image("no-kernel")?;
298299
eprintln!("Built broken image: {}", broken_image);
299300

300301
let sh = shell()?;
@@ -341,6 +342,50 @@ fn test_run_ephemeral_ssh_broken_image_cleanup() -> TestResult {
341342
}
342343
integration_test!(test_run_ephemeral_ssh_broken_image_cleanup);
343344

345+
/// Test that `test-basic` passes on a healthy image
346+
fn test_ephemeral_test_basic() -> TestResult {
347+
let sh = shell()?;
348+
let bck = get_bck_command()?;
349+
let image = get_test_image();
350+
let label = INTEGRATION_TEST_LABEL;
351+
352+
cmd!(sh, "{bck} ephemeral test-basic --label {label} {image}").run()?;
353+
Ok(())
354+
}
355+
integration_test!(test_ephemeral_test_basic);
356+
357+
/// Test that `test-basic` fails on an image that boots degraded, naming the
358+
/// failed unit
359+
fn test_ephemeral_test_basic_degraded() -> TestResult {
360+
const FAILING_UNIT: &str = "bcvk-test-failing.service";
361+
let image = build_fixture_image("failing-unit")?;
362+
363+
let sh = shell()?;
364+
let bck = get_bck_command()?;
365+
let label = INTEGRATION_TEST_LABEL;
366+
367+
let output = cmd!(sh, "{bck} ephemeral test-basic --label {label} {image}")
368+
.ignore_status()
369+
.output()?;
370+
371+
let _ = cmd!(sh, "podman rmi -f {image}")
372+
.ignore_status()
373+
.quiet()
374+
.run();
375+
376+
let stdout = String::from_utf8_lossy(&output.stdout);
377+
assert!(
378+
!output.status.success(),
379+
"test-basic succeeded on a degraded image. Output: {stdout}"
380+
);
381+
assert!(
382+
stdout.lines().next() == Some("degraded") && stdout.contains(FAILING_UNIT),
383+
"Expected the degraded state and {FAILING_UNIT} in the output. Got: {stdout}"
384+
);
385+
Ok(())
386+
}
387+
integration_test!(test_ephemeral_test_basic_degraded);
388+
344389
/// Test ephemeral VM network and DNS
345390
///
346391
/// Verifies that ephemeral bootc VMs can access the network and resolve DNS correctly.

‎crates/kit/src/ephemeral.rs‎

Lines changed: 142 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,15 @@
44
//! Ephemeral VMs are temporary, non-persistent VMs that are useful for testing, development,
55
//! and CI/CD workflows.
66
7+
use std::ffi::OsString;
8+
use std::os::unix::process::CommandExt;
79
use std::process::Command;
810

911
use clap::Subcommand;
10-
use color_eyre::{eyre::eyre, Result};
12+
use color_eyre::{
13+
eyre::{eyre, Context as _},
14+
Result,
15+
};
1116
use comfy_table::{presets::UTF8_FULL, Table};
1217
use serde::{Deserialize, Serialize};
1318

@@ -19,6 +24,34 @@ use crate::ssh;
1924
/// Label used to identify bcvk ephemeral containers
2025
const EPHEMERAL_LABEL: &str = "bcvk.ephemeral=1";
2126

27+
/// Name of the `test-basic` subcommand, which re-executes itself as `run-ssh`
28+
const TEST_BASIC_CMD: &str = "test-basic";
29+
30+
/// How long `test-basic` waits for boot to finish once SSH is up. Without a
31+
/// bound, a start job with no timeout (the default for `Type=oneshot`) would
32+
/// hang it forever.
33+
const TEST_BASIC_TIMEOUT_SECS: u32 = 600;
34+
35+
/// Exit status of `test-basic` when boot didn't finish in time (that of
36+
/// timeout(1)); it exits 1 when systemd finished in a state other than
37+
/// "running".
38+
const TEST_BASIC_TIMEOUT_EXIT: u8 = 124;
39+
40+
/// Command run in the guest by `test-basic`: wait for boot to finish, and if
41+
/// systemd didn't reach "running" (e.g. "degraded"), show the failed units,
42+
/// or the pending jobs if it timed out.
43+
fn test_basic_script() -> String {
44+
let secs = TEST_BASIC_TIMEOUT_SECS;
45+
let timeout_rc = TEST_BASIC_TIMEOUT_EXIT;
46+
format!(
47+
"timeout {secs} systemctl is-system-running --wait; rc=$?; \
48+
if [ $rc -eq {timeout_rc} ]; then \
49+
echo 'Timed out after {secs}s waiting for boot to finish; pending jobs:' >&2; \
50+
systemctl list-jobs --no-pager >&2; exit {timeout_rc}; \
51+
elif [ $rc -ne 0 ]; then systemctl --failed --no-pager; exit 1; fi"
52+
)
53+
}
54+
2255
/// SSH connection options for accessing running VMs.
2356
///
2457
/// Provides secure shell access to VMs running within containers,
@@ -39,6 +72,13 @@ pub struct SshOpts {
3972
pub args: Vec<String>,
4073
}
4174

75+
/// Options for the test-basic subcommand
76+
#[derive(clap::Parser, Debug)]
77+
pub struct TestBasicOpts {
78+
#[command(flatten)]
79+
pub run_opts: run_ephemeral::RunEphemeralOpts,
80+
}
81+
4282
/// Container list entry for ephemeral VMs
4383
#[derive(Debug, Serialize, Deserialize)]
4484
#[serde(rename_all = "PascalCase")]
@@ -136,6 +176,16 @@ pub enum EphemeralCommands {
136176
#[clap(name = "run-ssh")]
137177
RunSsh(run_ephemeral_ssh::RunEphemeralSshOpts),
138178

179+
/// Boot an ephemeral VM and check that systemd reaches the running state
180+
///
181+
/// A smoke test for bootc images: this is shorthand for
182+
/// `bcvk ephemeral run-ssh IMAGE -- systemctl is-system-running --wait`,
183+
/// which also lists the failed units when the system comes up degraded.
184+
/// On success it prints just the state, "running". Otherwise it exits 1,
185+
/// or 124 if boot didn't finish within 10 minutes of SSH coming up.
186+
#[clap(name = TEST_BASIC_CMD)]
187+
TestBasic(TestBasicOpts),
188+
139189
/// Connect to running VMs via SSH
140190
#[clap(name = "ssh")]
141191
Ssh(SshOpts),
@@ -163,6 +213,9 @@ impl EphemeralCommands {
163213
match self {
164214
EphemeralCommands::Run(opts) => run_ephemeral::run(opts),
165215
EphemeralCommands::RunSsh(opts) => run_ephemeral_ssh::run_ephemeral_ssh(opts),
216+
// The options were parsed only to validate them (and for --help);
217+
// run-ssh takes the same ones, straight from our argv.
218+
EphemeralCommands::TestBasic(_) => test_basic(),
166219
EphemeralCommands::Ssh(opts) => {
167220
// Create progress bar if stderr is a terminal
168221
let progress_bar = crate::boot_progress::create_boot_progress_bar();
@@ -220,6 +273,35 @@ impl EphemeralCommands {
220273
}
221274
}
222275

276+
/// Arguments for `bcvk ephemeral run-ssh` equivalent to our own `test-basic`
277+
/// invocation, given its arguments (without argv\[0\]).
278+
fn test_basic_run_ssh_args(args: impl IntoIterator<Item = OsString>) -> Result<Vec<OsString>> {
279+
let mut args = args.into_iter().skip_while(|arg| arg != TEST_BASIC_CMD);
280+
if args.next().is_none() {
281+
return Err(eyre!("Failed to find {TEST_BASIC_CMD} in arguments"));
282+
}
283+
let opts: Vec<OsString> = args.collect();
284+
let mut run_ssh_args: Vec<OsString> = ["ephemeral", "run-ssh"].map(Into::into).into();
285+
// The image is the only positional argument, so a `--` the user already
286+
// gave before it also separates the command from it.
287+
let has_separator = opts.iter().any(|arg| arg == "--");
288+
run_ssh_args.extend(opts);
289+
if !has_separator {
290+
run_ssh_args.push("--".into());
291+
}
292+
run_ssh_args.extend(["/bin/sh".into(), "-c".into(), test_basic_script().into()]);
293+
Ok(run_ssh_args)
294+
}
295+
296+
/// Run `test-basic` by re-executing ourselves as `run-ssh`.
297+
fn test_basic() -> Result<()> {
298+
let mut argv = std::env::args_os();
299+
let arg0 = argv.next().unwrap_or_else(|| "bcvk".into());
300+
let args = test_basic_run_ssh_args(argv)?;
301+
let err = Command::new("/proc/self/exe").arg0(arg0).args(args).exec();
302+
Err(err).context("Failed to execute bcvk ephemeral run-ssh")
303+
}
304+
223305
/// List ephemeral VM containers with bcvk.ephemeral=1 label
224306
pub(crate) fn list_ephemeral_containers() -> Result<Vec<ContainerListEntry>> {
225307
use bootc_utils::CommandRunExt;
@@ -335,3 +417,62 @@ fn remove_all_ephemeral_containers(force: bool) -> Result<()> {
335417

336418
Ok(())
337419
}
420+
421+
#[cfg(test)]
422+
mod tests {
423+
use super::*;
424+
425+
#[test]
426+
fn test_basic_args_to_run_ssh() {
427+
let script = test_basic_script();
428+
let command = ["/bin/sh", "-c", script.as_str()];
429+
let cases: &[(&[&str], &[&str])] = &[
430+
(
431+
&["ephemeral", "test-basic", "quay.io/example/os"],
432+
&["quay.io/example/os", "--"],
433+
),
434+
(
435+
&[
436+
"ephemeral",
437+
"test-basic",
438+
"--memory",
439+
"4G",
440+
"--label",
441+
"k=v",
442+
"img",
443+
],
444+
&["--memory", "4G", "--label", "k=v", "img", "--"],
445+
),
446+
(
447+
&[
448+
"ephemeral",
449+
"test-basic",
450+
"--memory=8G",
451+
"-K",
452+
"-e",
453+
"A=B",
454+
"img",
455+
],
456+
&["--memory=8G", "-K", "-e", "A=B", "img", "--"],
457+
),
458+
// A separator the user already gave is reused
459+
(&["ephemeral", "test-basic", "img", "--"], &["img", "--"]),
460+
(
461+
&["ephemeral", "test-basic", "--vcpus", "2", "--", "img"],
462+
&["--vcpus", "2", "--", "img"],
463+
),
464+
];
465+
for (input, expected_opts) in cases {
466+
let expected: Vec<OsString> = ["ephemeral", "run-ssh"]
467+
.iter()
468+
.chain(expected_opts.iter())
469+
.chain(command.iter())
470+
.map(Into::into)
471+
.collect();
472+
let args = test_basic_run_ssh_args(input.iter().map(Into::into)).unwrap();
473+
assert_eq!(args, expected, "input: {input:?}");
474+
}
475+
476+
assert!(test_basic_run_ssh_args(["ephemeral", "run-ssh"].map(Into::into)).is_err());
477+
}
478+
}

‎crates/kit/src/main.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,9 @@ pub enum StubEphemeralCommands {
115115
/// Run ephemeral VM and SSH into it
116116
#[clap(name = "run-ssh")]
117117
RunSsh,
118+
/// Boot an ephemeral VM and check that systemd reaches the running state
119+
#[clap(name = "test-basic")]
120+
TestBasic,
118121
/// Connect to running VMs via SSH
119122
#[clap(name = "ssh")]
120123
Ssh,

‎docs/src/SUMMARY.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
- [ephemeral run](./man/bcvk-ephemeral-run.md)
1515
- [ephemeral ssh](./man/bcvk-ephemeral-ssh.md)
1616
- [ephemeral run-ssh](./man/bcvk-ephemeral-run-ssh.md)
17+
- [ephemeral test-basic](./man/bcvk-ephemeral-test-basic.md)
1718
- [to-disk](./man/bcvk-to-disk.md)
1819
- [images](./man/bcvk-images.md)
1920
- [images list](./man/bcvk-images-list.md)

0 commit comments

Comments
 (0)