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 ;
79use std:: process:: Command ;
810
911use clap:: Subcommand ;
10- use color_eyre:: { eyre:: eyre, Result } ;
12+ use color_eyre:: {
13+ eyre:: { eyre, Context as _} ,
14+ Result ,
15+ } ;
1116use comfy_table:: { presets:: UTF8_FULL , Table } ;
1217use serde:: { Deserialize , Serialize } ;
1318
@@ -19,6 +24,34 @@ use crate::ssh;
1924/// Label used to identify bcvk ephemeral containers
2025const 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
224306pub ( 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+ }
0 commit comments