diff --git a/SPEC.md b/SPEC.md index 5b9c8a3..3e6c48c 100644 --- a/SPEC.md +++ b/SPEC.md @@ -269,6 +269,7 @@ composable. A boot failure leaves the files absent. ``` ecrou run [OPTIONS] [-- COMMAND...] +ecrou provision [OPTIONS] [-- COMMAND...] ecrou probe ``` @@ -283,6 +284,17 @@ ecrou probe | `--no-cache` | false | Fresh download, ignore the cache | | `-v, --verbose` | false | Diagnostics; raises the libkrun log level | +`ecrou provision IMAGE -- COMMAND` bakes a command into the image's +cache entry: the command runs in a machine over a freshly prepared +rootfs and the result is persisted, so every later boot of that image +carries what the command installed — packages, users, whatever a +one-shot boot cannot keep. A marked entry is reused as-is (the +command is skipped), `--no-cache` re-downloads and re-provisions, and +a failing command leaves the entry unprovisioned for the next attempt. +The marker dies with the entry: a digest refresh drops it, so a moved +upstream image is provisioned again, never silently served pristine +under an old marker. + `ecrou probe` reports what a run would find: KVM working (open, ioctl version, nested-virt state), `libkrun.so.1` loadable, the cache directory writable. It is the confinement diagnostic and the support diff --git a/crates/ecrou-cli/src/cli.rs b/crates/ecrou-cli/src/cli.rs index 232fabd..4c33f0d 100644 --- a/crates/ecrou-cli/src/cli.rs +++ b/crates/ecrou-cli/src/cli.rs @@ -21,6 +21,23 @@ pub struct Cli { pub enum Command { /// Run a command in a fresh microVM from an image Run(RunArgs), + /// Bake a command into an image's cache entry: the command runs in + /// a machine over a freshly prepared rootfs, and the result is + /// persisted, so every later boot of the image carries what the + /// command installed. Cached entries are reused as-is; --no-cache + /// re-downloads and re-provisions. + Provision { + /// Distribution name or OCI image reference + image: String, + /// Ignore the cache and download a fresh image + #[arg(long)] + no_cache: bool, + /// The provisioning command. An argument vector after `--` is + /// taken literally; a single quoted line runs through the + /// image's shell. + #[arg(last = true)] + command: Vec, + }, /// Report what a run would find on this host Probe, } diff --git a/crates/ecrou-cli/src/main.rs b/crates/ecrou-cli/src/main.rs index 2aeed23..338066f 100644 --- a/crates/ecrou-cli/src/main.rs +++ b/crates/ecrou-cli/src/main.rs @@ -18,10 +18,69 @@ fn main() -> std::process::ExitCode { logger::init(cli.verbose); match cli.command { Command::Run(args) => cmd_run(&args, cli.verbose), + Command::Provision { + image, + no_cache, + command, + } => cmd_provision(&image, no_cache, &command, cli.verbose), Command::Probe => cmd_probe(), } } +fn cmd_provision( + image: &str, + no_cache: bool, + command: &[String], + cli_verbose: bool, +) -> std::process::ExitCode { + use ecrou::rootfs::{PrepareRequest, RootfsCache}; + + if command.is_empty() { + return fail(&anyhow::anyhow!( + "provision needs a command: ecrou provision -- " + )); + } + let level = if cli_verbose { + LogLevel::Verbose + } else { + LogLevel::Normal + }; + let mut options = RunOptions { + image: image.to_owned(), + command: command.to_owned(), + log_level: level, + // The machine exists to change the rootfs, not to cross a + // share: the workspace would ride the persist into the cache. + ..RunOptions::default() + }; + options.workdir = "/".to_owned(); + + let progress: &dyn Progress = &TermProgress::new(); + let request = PrepareRequest { + image: image.to_owned(), + arch: String::new(), + no_cache, + }; + let dir = match RootfsCache::default_dir() { + Ok(dir) => dir, + Err(err) => return fail(&anyhow::Error::new(err)), + }; + let cache = RootfsCache::new(dir); + let outcome = cache.prepare_provisioned(&request, progress, |rootfs| { + let code = ecrou::vm::run_prepared(rootfs, &options)?; + if code != 0 { + anyhow::bail!( + "the provisioning command exited {code}; the cache entry was left unprovisioned" + ); + } + Ok(()) + }); + match outcome { + Ok(_rootfs) => std::process::ExitCode::SUCCESS, + Err(err) => fail(&anyhow::Error::new(err)), + } +} + fn cmd_run(args: &RunArgs, cli_verbose: bool) -> std::process::ExitCode { let mut options = RunOptions { image: args.image.clone(), diff --git a/crates/ecrou/src/rootfs.rs b/crates/ecrou/src/rootfs.rs index 22102c6..7414ae4 100644 --- a/crates/ecrou/src/rootfs.rs +++ b/crates/ecrou/src/rootfs.rs @@ -129,6 +129,19 @@ impl RootfsCache { format!("Failed to create cache directory {}", self.dir.display()) })?; download_image(&source, &cache_path, &arch, progress)?; + // Whatever was provisioned belonged to the entry this just + // replaced. A stale marker here would present the pristine + // image as provisioned and skip the next provisioning for + // good. + let marker = sidecar(&cache_path, PROVISIONED_SUFFIX); + if marker.exists() { + std::fs::remove_file(&marker).with_context(|| { + format!( + "Failed to drop the stale provisioned marker {}", + marker.display() + ) + })?; + } } else { log::debug!("Using cached tarball: {}", cache_path.display()); } diff --git a/crates/ecrou/src/vm.rs b/crates/ecrou/src/vm.rs index c3284ac..d51cf33 100644 --- a/crates/ecrou/src/vm.rs +++ b/crates/ecrou/src/vm.rs @@ -91,6 +91,21 @@ impl Default for RunOptions { /// Run one machine to completion and return the payload's exit code. pub fn run(options: &RunOptions, progress: &dyn Progress) -> Result { + let mut request = PrepareRequest::new(options.image.clone()); + request.arch = options.arch.clone(); + request.no_cache = options.no_cache; + let cache = RootfsCache::new(RootfsCache::default_dir()?); + let rootfs = cache + .prepare(&request, progress) + .context("preparing the rootfs")?; + run_prepared(&rootfs, options) +} + +/// Boot one machine over an already-prepared rootfs. This is the whole +/// boot half of [`run`], and the engine the provisioner drives: it +/// wants a machine over the extracted tree it is about to persist, +/// before that tree becomes the cache entry every later boot sees. +pub fn run_prepared(rootfs: &PreparedRootfs, options: &RunOptions) -> Result { if options.image.is_empty() { return Err(Error::Other(anyhow!("no image given"))); } @@ -101,14 +116,6 @@ pub fn run(options: &RunOptions, progress: &dyn Progress) -> Result { let ram_mib = parse_memory_mib(&options.memory).map_err(Error::Other)?; let vcpus = vcpus(); - let mut request = PrepareRequest::new(options.image.clone()); - request.arch = options.arch.clone(); - request.no_cache = options.no_cache; - let cache = RootfsCache::new(RootfsCache::default_dir()?); - let rootfs = cache - .prepare(&request, progress) - .context("preparing the rootfs")?; - let hostname = if options.hostname.is_empty() { format!("ecrou-vm-{}", utils::random_suffix(HOSTNAME_SUFFIX_LEN)) } else { @@ -129,7 +136,7 @@ pub fn run(options: &RunOptions, progress: &dyn Progress) -> Result { }) .transpose() .map_err(Error::Other)?; - let share_pairs = collect_shares(&rootfs, options, capture_share).map_err(Error::Other)?; + let share_pairs = collect_shares(rootfs, options, capture_share).map_err(Error::Other)?; let shares = share_pairs .iter() .map(|(_, gs)| gs.clone())