diff --git a/SPEC.md b/SPEC.md index 3c211bd..6ef5898 100644 --- a/SPEC.md +++ b/SPEC.md @@ -144,7 +144,11 @@ hostname and workdir ride `krun_set_exec`'s config natively (the init blob applies both); the script's own hostname and cd lines stay as fallbacks. There is no exec (the init must outlive the payload to report it) and no share-less special case: the same contract covers a -bare shell run. Ending the machine is not the script's job either: +bare shell run. The payload itself is an argv vector taken +literally; the one rewrite is a single argument containing whitespace, +which is a quoted shell line ("apk add x && y") and runs through the +image's shell as `sh -c`. Ending the machine is not the script's job +either: when the script exits, the init blob reports the code and the VMM powers the machine off (the libkrunfw kernel has no sysrq). @@ -223,12 +227,13 @@ pub struct RunOptions { pub workdir: String, // guest cwd; empty = / pub env: Vec<(String, String)>, // composed envp; host env never inherited pub hostname: String, // empty = generated ecrou-vm-XXXXXX - pub quiet: bool, + pub log_level: LogLevel, // Quiet | Normal | Verbose } pub fn run(options: &RunOptions, progress: &dyn Progress) -> Result ``` -stdio is inherited; the return value is the guest exit code. A run +Guest output reaches real stdout through the streamed console; the +return value is the guest exit code. A run without working KVM is `Error::KvmUnavailable` with the remediation text. Everything else is `Error::Other` over the anyhow chain, ecr's shape. The v1 API has no captured-output variant and no long-lived diff --git a/crates/ecrou-cli/src/cli.rs b/crates/ecrou-cli/src/cli.rs index 33a1024..21c2c21 100644 --- a/crates/ecrou-cli/src/cli.rs +++ b/crates/ecrou-cli/src/cli.rs @@ -29,7 +29,9 @@ pub enum Command { pub struct RunArgs { /// Distribution name or OCI image reference pub image: String, - /// Command to run in the machine; the image's default shell when omitted + /// Command to run in the machine; the image's default shell when + /// omitted. An argument vector after `--` is taken literally; a + /// single quoted line runs through the image's shell. #[arg(last = true)] pub command: Vec, /// Directory to share into the machine as HOST:GUEST[:ro] (repeatable) diff --git a/crates/ecrou/src/vm.rs b/crates/ecrou/src/vm.rs index 8b49c2f..691dc7f 100644 --- a/crates/ecrou/src/vm.rs +++ b/crates/ecrou/src/vm.rs @@ -362,10 +362,19 @@ fn collect_shares( /// The guest payload: the caller's argv, or the rootfs's default shell. fn payload_command(options: &RunOptions, rootfs_dir: &Path) -> Vec { - if !options.command.is_empty() { - return options.command.clone(); + let shell = utils::detect_shell(rootfs_dir).to_string(); + match options.command.as_slice() { + // No command: the image's shell. + [] => vec![shell], + // One quoted shell line: `ecrou run alpine -- "apk add x && y"`. + // The whole line would otherwise be execvp'd as one program + // name; the consumer clearly asked for a shell line. + [line] if line.contains(char::is_whitespace) => { + vec![shell, "-c".to_string(), line.clone()] + } + // An argv vector, taken literally. + cmd => cmd.to_vec(), } - vec![utils::detect_shell(rootfs_dir).to_string()] } /// The guest environment, composed from scratch: a usable base, the @@ -447,6 +456,64 @@ fn parse_memory_mib(s: &str) -> anyhow::Result { mod tests { use super::*; + /// A scratch with just a shell, for payload_command tests. + fn shell_rootfs() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("bin")).unwrap(); + std::fs::write(dir.path().join("bin/sh"), "").unwrap(); + dir + } + + #[test] + fn payload_defaults_to_the_image_shell() { + let rootfs = shell_rootfs(); + let options = RunOptions::default(); + assert_eq!( + payload_command(&options, rootfs.path()), + vec!["/bin/sh".to_string()] + ); + } + + #[test] + fn payload_argv_is_taken_literally() { + let rootfs = shell_rootfs(); + let options = RunOptions { + command: vec!["uname".to_string(), "-a".to_string()], + ..RunOptions::default() + }; + assert_eq!( + payload_command(&options, rootfs.path()), + vec!["uname".to_string(), "-a".to_string()] + ); + // Even when it looks like a shell invocation: the consumer + // asked for argv. + let options = RunOptions { + command: vec!["sh".to_string(), "-c".to_string(), "exit 7".to_string()], + ..RunOptions::default() + }; + assert_eq!( + payload_command(&options, rootfs.path()), + vec!["sh".to_string(), "-c".to_string(), "exit 7".to_string()] + ); + } + + #[test] + fn payload_single_quoted_line_runs_through_the_shell() { + let rootfs = shell_rootfs(); + let options = RunOptions { + command: vec!["apk add fastfetch && fastfetch".to_string()], + ..RunOptions::default() + }; + assert_eq!( + payload_command(&options, rootfs.path()), + vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "apk add fastfetch && fastfetch".to_string() + ] + ); + } + #[test] fn memory_parses_to_whole_mib() { assert_eq!(parse_memory_mib("1G").unwrap(), 1024);