From 0ad07c182f0fb89fd9055c0c42219d261eeee86e Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Fri, 30 Oct 2026 15:57:10 +0100 Subject: [PATCH] refactor: cut provision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime runs images; building them belongs to the OCI ecosystem — CI builds, digests, scanning — and a keyed local layer could never offer a digest. Provision also installed packages through TSI, whose per-stream throughput makes a real package install crawl exactly at first use, and the bare-command shape mutated the shared cache entry under the image's own name. 0.1 runs images: run, pull, probe — distro aliases, OCI references with digest pins, local rootfs archives. The one honest idea provision carried stays in SPEC's Later: provision by booting, for locked-down hosts that cannot run any builder. --- SPEC.md | 16 ++- crates/ecrou-cli/src/cli.rs | 39 +---- crates/ecrou-cli/src/main.rs | 140 +----------------- crates/ecrou/src/cache.rs | 266 ++--------------------------------- crates/ecrou/src/extract.rs | 6 +- crates/ecrou/src/lib.rs | 6 +- crates/ecrou/src/pack.rs | 125 ---------------- crates/ecrou/src/rootfs.rs | 231 +++--------------------------- crates/ecrou/src/spec.rs | 147 ------------------- crates/ecrou/src/vm.rs | 26 +--- 10 files changed, 56 insertions(+), 946 deletions(-) delete mode 100644 crates/ecrou/src/pack.rs delete mode 100644 crates/ecrou/src/spec.rs diff --git a/SPEC.md b/SPEC.md index a97500f..dce3577 100644 --- a/SPEC.md +++ b/SPEC.md @@ -185,8 +185,8 @@ they need host setup the budget cannot afford. Port publishing is how a later in-guest agent serves the host (Later). **Memory and CPUs.** Default 1G and all logical CPUs; `-m` overrides. -A per-command machine is provisioned per command, and agent consumers -run several concurrently. +Each command gets its own machine, and agent consumers run several +concurrently. ## Trust model @@ -279,10 +279,14 @@ build is deleted. correct-and-slow v1 posture graduates into this. - **Guest agent, long-lived machines.** Exec without a boot; the shape to grow into when per-command VMs stop being right. -- **Layer eviction.** Provisioned layers are already keyed: a spec - file (base, packages, run) hashed with its base reference, named by - `--layer` for `run --layer`. Nothing prunes the derived entries yet; - eviction (LRU or an explicit prune) keeps them bounded. +- **A build story for hosts that cannot build.** Provision was cut + from 0.1: a runtime that also builds images is two products, and + the OCI ecosystem already builds images — CI, digests, scanning. + What has no answer yet is the locked-down host with no daemon, no + namespaces, no way to run a builder. There, provision by booting — + the guest installs, the host stays confined, the artifact is a + keyed cache entry. Waits on TSI throughput and on someone actually + needing it. - **passt** (`krun_set_passt_fd`) if TSI throughput or UDP listening ever matters. - **An agent in the guest** over port publishing: an http backend diff --git a/crates/ecrou-cli/src/cli.rs b/crates/ecrou-cli/src/cli.rs index 879c17b..5f5a0ed 100644 --- a/crates/ecrou-cli/src/cli.rs +++ b/crates/ecrou-cli/src/cli.rs @@ -29,34 +29,6 @@ pub enum Command { /// away. Persistence crosses a --volume share or it does not /// happen. Run(RunArgs), - /// Bake a provisioned layer over a base image - /// - /// Two shapes. --spec FILE describes the layer in checked-in YAML - /// (base, packages, run); the layer is keyed by the file's - /// content, so the same spec never provisions twice and a changed - /// spec never reuses an old layer, and --layer NAME names it for - /// `run --layer NAME`. `provision IMAGE -- COMMAND` bakes the - /// command into the image's cache entry itself: every later boot - /// of that image carries what the command installed. - Provision { - /// Base image: distro name or OCI reference; not needed when - /// the spec carries a base - image: Option, - /// Provisioning spec file (YAML: base, packages, run); the - /// layer it describes is keyed by the file's content - #[arg(long)] - spec: Option, - /// Name the provisioned layer, so `run --layer NAME` boots it - #[arg(long)] - layer: Option, - /// Ignore the cache: re-download the base and re-provision - #[arg(long)] - no_cache: bool, - /// Provisioning command, baked into the image's cache entry - /// (use with IMAGE, without --spec) - #[arg(last = true)] - command: Vec, - }, /// Fetch or refresh an image's cache entry without booting it /// /// Unpinned OCI tags are re-checked against the registry digest @@ -82,9 +54,8 @@ pub enum Command { #[derive(Args, Debug)] pub struct RunArgs { /// Distro name, OCI image reference, or a local rootfs archive - /// path (./rootfs.tar.gz); not needed with --layer - #[arg(required_unless_present = "layer")] - pub image: Option, + /// path (./rootfs.tar.gz) + pub image: String, /// Keep the machine's stdin open: the host's stdin feeds the /// guest, so shells and prompts read typed input #[arg(short = 'i', long)] @@ -122,10 +93,6 @@ pub struct RunArgs { /// Ignore the cache: download the image fresh #[arg(long)] pub no_cache: bool, - /// Boot a named provisioned layer (see provision --layer) - /// instead of an image - #[arg(long)] - pub layer: Option, } /// Parse one `HOST:GUEST[:ro]` volume specification. The guest path @@ -199,7 +166,7 @@ mod tests { }; assert!(args.interactive); assert!(args.tty); - assert_eq!(args.image.as_deref(), Some("alpine")); + assert_eq!(args.image, "alpine"); } #[test] diff --git a/crates/ecrou-cli/src/main.rs b/crates/ecrou-cli/src/main.rs index cf6daa7..995e1ec 100644 --- a/crates/ecrou-cli/src/main.rs +++ b/crates/ecrou-cli/src/main.rs @@ -20,146 +20,11 @@ fn main() -> std::process::ExitCode { logger::init(cli.verbose); match cli.command { Command::Run(args) => cmd_run(&args, cli.verbose), - Command::Provision { - image, - spec, - layer, - no_cache, - command, - } => cmd_provision(image, spec, layer, no_cache, &command, cli.verbose), Command::Pull { image, no_cache } => cmd_pull(&image, no_cache, cli.verbose), Command::Probe => cmd_probe(), } } -fn cmd_provision( - image: Option, - spec: Option, - layer: Option, - no_cache: bool, - command: &[String], - cli_verbose: bool, -) -> std::process::ExitCode { - use ecrou::cache::RootfsCache; - use ecrou::rootfs::PrepareRequest; - use ecrou::spec::ProvisionSpec; - - // Two shapes: `provision IMAGE -- COMMAND` bakes a one-off - // command; `provision --spec FILE --layer NAME` bakes a checked-in - // spec into a named, content-keyed layer. A spec plus a command - // has no single meaning. - if spec.is_none() == command.is_empty() { - return fail(&anyhow::anyhow!( - "provision needs either a spec file (--spec) or a command: \ - ecrou provision -- " - )); - } - - let level = if cli_verbose { - LogLevel::Verbose - } else { - LogLevel::Normal - }; - let mut options = RunOptions { - 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 dir = match RootfsCache::default_dir() { - Ok(dir) => dir, - Err(err) => return fail(&anyhow::Error::new(err)), - }; - let cache = RootfsCache::new(dir); - - // The spec decides the request: its base (or the positional - // image), its key, and its script — the packages through the - // base's package manager, then the run block. The key hashes the - // raw bytes and the base reference, so the cache entries two - // projects see can never cross. - let mut request = PrepareRequest::new(String::new()); - request.no_cache = no_cache; - let mut script; - match &spec { - Some(path) => { - let bytes = match std::fs::read(path) { - Ok(bytes) => bytes, - Err(err) => return fail(&anyhow::anyhow!("reading {}: {err}", path.display())), - }; - let parsed = match ProvisionSpec::parse(&bytes) { - Ok(parsed) => parsed, - Err(err) => return fail(&err), - }; - let base = match parsed.base.clone().or_else(|| image.clone()) { - Some(base) => base, - None => { - return fail(&anyhow::anyhow!( - "the spec has no base and no image was named: \ - ecrou provision --spec {path}", - path = path.display() - )) - } - }; - script = parsed.script(); - let key = ProvisionSpec::key(&bytes, &base); - request.image = base; - request.layer_key = Some(key); - } - None => { - if layer.is_some() { - return fail(&anyhow::anyhow!( - "--layer names a provisioned spec, not a bare command" - )); - } - request.image = image - .clone() - .expect("clap requires an image without --spec"); - script = command.join(" "); - script.push('\n'); - } - } - options.image = request.image.clone(); - options.command = vec!["/bin/sh".to_owned(), "-c".to_owned(), script]; - - 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) => { - // The pointer lands only after the entry does: a name - // that resolves is a layer that boots. - if let (Some(name), Some(key)) = (&layer, &request.layer_key) { - let path = match cache.resolved_entry_path( - &request.image, - &request.resolved_arch(), - Some(key), - ) { - Ok(path) => path, - Err(err) => return fail(&anyhow::Error::new(err)), - }; - let filename = path - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_default(); - if let Err(err) = cache.write_layer_pointer(name, &filename) { - return fail(&anyhow::Error::new(err)); - } - } - std::process::ExitCode::SUCCESS - } - Err(err) => fail(&anyhow::Error::new(err)), - } -} - fn cmd_run(args: &RunArgs, cli_verbose: bool) -> std::process::ExitCode { // A tty drives the payload's output through the terminal while the // capture share points the same streams at files: one of them has @@ -171,9 +36,7 @@ fn cmd_run(args: &RunArgs, cli_verbose: bool) -> std::process::ExitCode { )); } let mut options = RunOptions { - // Without a layer the image is required by clap; with one the - // field is dead — the layer entry carries the rootfs. - image: args.image.clone().unwrap_or_default(), + image: args.image.clone(), command: args.command.clone(), memory: args.memory.clone(), workdir: args.workdir.clone(), @@ -187,7 +50,6 @@ fn cmd_run(args: &RunArgs, cli_verbose: bool) -> std::process::ExitCode { interactive: args.interactive, tty: args.tty, no_cache: args.no_cache, - layer: args.layer.clone(), capture: args.capture.clone(), ..RunOptions::default() }; diff --git a/crates/ecrou/src/cache.rs b/crates/ecrou/src/cache.rs index b9a8d7b..b91e7d4 100644 --- a/crates/ecrou/src/cache.rs +++ b/crates/ecrou/src/cache.rs @@ -1,53 +1,24 @@ -//! The rootfs tarball cache: entries, freshness, and provisioned layers. +//! The rootfs tarball cache: entries and freshness. //! -//! One directory of content-addressed-ish entries: every downloaded or -//! provisioned rootfs lives under a name derived from its source, an -//! advisory lock keeps concurrent runs from downloading the same image -//! twice, a digest sidecar lets floating tags be re-checked cheaply, -//! and layer pointers name provisioned entries so runs can boot them -//! without re-deriving keys. The lifecycle that drives it — prepare, -//! provision, persist — lives in [`crate::rootfs`]. +//! One directory of content-addressed-ish entries: every downloaded +//! rootfs lives under a name derived from its source, an advisory lock +//! keeps concurrent runs from downloading the same image twice, and a +//! digest sidecar lets floating tags be re-checked cheaply. The +//! lifecycle that drives it — prepare, pull — lives in +//! [`crate::rootfs`]. use std::path::{Path, PathBuf}; use anyhow::{anyhow, Context}; -use crate::distro::{map_arch, resolve_source, Distro, ImageSource}; -use crate::download::{digest_sidecar, download_image}; -use crate::error::Error; +use crate::distro::{map_arch, Distro, ImageSource}; +use crate::download::digest_sidecar; use crate::extract::extract_tarball; use crate::progress::Progress; use crate::registry::fetch_oci_digest; use crate::rootfs::PreparedRootfs; use crate::Result; -/// Sidecar suffix marking a cache entry that holds a provisioned rootfs. -pub(crate) const PROVISIONED_SUFFIX: &str = "provisioned"; - -/// Download `source` into the cache at `cache_path`, and drop any -/// provisioned marker: whatever was provisioned belonged to the entry -/// this download replaces, and a stale marker would present the -/// pristine image as provisioned and skip the next provisioning for -/// good. -pub(crate) fn fill_entry( - source: &ImageSource, - cache_path: &Path, - arch: &str, - progress: &dyn Progress, -) -> Result<()> { - download_image(source, cache_path, arch, progress)?; - 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() - ) - })?; - } - Ok(()) -} - /// Whether an OCI image with a floating tag (":latest") has moved since /// it was cached: fetch the current manifest digest from the registry /// and compare it against the digest stored from the last download. @@ -136,35 +107,6 @@ impl EntryLock { } } -/// A layer name is a file name under the cache's `layers/` directory -/// and a component of entry file names: keep it to a safe charset so a -/// name like `../x` or `a/b` cannot climb out of the cache directory. -fn validate_layer_name(name: &str) -> Result<()> { - let ok = !name.is_empty() - && name != "." - && name != ".." - && name - .chars() - .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.')) - && !name.starts_with('.'); - if ok { - Ok(()) - } else { - Err(Error::Other(anyhow!( - "invalid layer name {name:?}: use letters, digits, '_' or '-', \ - not starting with '.'" - ))) - } -} - -/// Sidecar path for a cache entry: `foo.tar.gz` → `foo.tar.gz.`. -pub(crate) fn sidecar(path: &Path, suffix: &str) -> PathBuf { - let mut s = path.as_os_str().to_owned(); - s.push("."); - s.push(suffix); - PathBuf::from(s) -} - /// The image tarball cache (default location: `~/.cache/ecrou`). #[derive(Debug, Clone)] pub struct RootfsCache { @@ -191,106 +133,10 @@ impl RootfsCache { pub fn tarball_path(&self, source: &ImageSource, arch: &str) -> PathBuf { self.dir.join(generate_cache_filename(source, arch)) } - - /// The cache path for a prepared entry, keyed or not: a layer key - /// is spliced into the filename, so a layer and its base — and two - /// layers of one base — live in separate entries, never overwrite - /// each other, and evict separately. The key is validated here, at - /// the splice point, so no caller can derive a path outside the - /// cache directory. - pub fn entry_path( - &self, - source: &ImageSource, - arch: &str, - layer_key: Option<&str>, - ) -> Result { - match layer_key { - None => Ok(self.tarball_path(source, arch)), - Some(key) => { - validate_layer_name(key)?; - let filename = generate_cache_filename(source, arch); - let stem = filename - .rsplit_once('.') - .map(|(stem, _)| stem) - .unwrap_or(&filename); - Ok(self.dir.join(format!("{stem}.{key}.tar.gz"))) - } - } - } - - /// Extracts a named layer into a fresh scratch directory: the boot - /// side of `run --layer`. A missing pointer or a pruned entry is a - /// named error, not a download — layers are provisioned, never - /// fetched. - pub fn extract_layer(&self, name: &str, progress: &dyn Progress) -> Result { - validate_layer_name(name)?; - let path = self.layer_entry(name)?.ok_or_else(|| { - Error::Other(anyhow::anyhow!( - "layer {name} is not provisioned: run \ - `ecrou provision --spec --layer {name}` first" - )) - })?; - extract_prepared(&path.clone(), path, progress).map_err(Error::Other) - } - - /// The cache path an image resolves to, keyed or not — the same - /// resolution `prepare` does (floating aliases resolved), so a - /// pointer written from this path names an entry that exists. - pub fn resolved_entry_path( - &self, - image: &str, - arch: &str, - layer_key: Option<&str>, - ) -> Result { - let source = resolve_source(image, arch)?; - self.entry_path(&source, arch, layer_key) - } - - /// Writes the layer pointer: `layers/` holds the cache - /// filename the name resolves to. Consumers boot layers by name — - /// `run` should not have to re-derive the key from the spec, only - /// agree with the provisioning call on what the layer is called. - pub fn write_layer_pointer(&self, name: &str, cache_filename: &str) -> Result<()> { - validate_layer_name(name)?; - let dir = self.dir.join("layers"); - std::fs::create_dir_all(&dir) - .with_context(|| format!("Failed to create the layer directory {}", dir.display()))?; - // Atomic write: a concurrent run reading the pointer must - // never see a truncated filename mid-write. - let pointer = dir.join(name); - let partial = crate::utils::partial_path(&pointer); - std::fs::write(&partial, cache_filename) - .with_context(|| format!("Failed to write the layer pointer for {name}"))?; - std::fs::rename(&partial, &pointer) - .with_context(|| format!("Failed to move the layer pointer into place for {name}"))?; - Ok(()) - } - - /// The cache path a layer pointer names, and whether the layer is - /// really there: a pointer is a name, not a promise — the entry it - /// names can be gone (a pruned cache, a base bump) while the - /// pointer survives. - pub fn layer_entry(&self, name: &str) -> Result> { - validate_layer_name(name)?; - let pointer = self.dir.join("layers").join(name); - let filename = match std::fs::read_to_string(&pointer) { - Ok(filename) => filename, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(err) => { - return Err(Error::Other( - anyhow::Error::from(err) - .context(format!("reading the layer pointer for {name}")), - )) - } - }; - let path = self.dir.join(filename.trim()); - Ok((path.exists() && sidecar(&path, PROVISIONED_SUFFIX).exists()).then_some(path)) - } } pub(crate) fn extract_prepared( source: &Path, - cache_path: PathBuf, progress: &dyn Progress, ) -> anyhow::Result { let temp = tempfile::tempdir().context("Failed to create temporary directory")?; @@ -300,10 +146,7 @@ pub(crate) fn extract_prepared( extract_tarball(source, &rootfs, progress)?; provision_resolver(&rootfs); - Ok(PreparedRootfs { - rootfs: temp, - cache_path, - }) + Ok(PreparedRootfs { rootfs: temp }) } /// Give the guest the host's resolver: the engine proxies guest @@ -421,10 +264,8 @@ fn generate_cache_filename(source: &ImageSource, arch: &str) -> String { .and_then(|s| s.to_str()) .unwrap_or("rootfs") .replace(['/', '\\', ':'], "_"); - // Size and mtime key the entry: a provisioning over a - // local archive persists into the cache under this name, - // never over the caller's file, and an edited archive - // lands in a fresh entry. + // Size and mtime key the entry: an edited archive lands + // in a fresh entry name, never a stale one. let meta = std::fs::metadata(path); let len = meta.as_ref().map(|m| m.len()).unwrap_or(0); let mtime = meta @@ -494,14 +335,6 @@ mod tests { ); } - #[test] - fn sidecar_appends_suffix() { - assert_eq!( - sidecar(Path::new("/cache/foo.tar.gz"), PROVISIONED_SUFFIX), - PathBuf::from("/cache/foo.tar.gz.provisioned") - ); - } - /// Build a minimal rootfs tree with a nested executable and a symlink, /// pack it, and extract it back through the public extract_tarball path. @@ -534,79 +367,4 @@ mod tests { let existing = tempfile::NamedTempFile::new().unwrap(); assert!(!oci_freshness_changed(&pinned, existing.path())); } - - #[test] - fn a_layer_key_splices_into_the_entry_name() { - let cache = RootfsCache::new(std::path::PathBuf::from("/cache")); - let source = crate::distro::parse_image_ref("alpine:3.23", "amd64").expect("parses"); - let plain = cache.entry_path(&source, "amd64", None).unwrap(); - let keyed = cache.entry_path(&source, "amd64", Some("abc123")).unwrap(); - let other = cache.entry_path(&source, "amd64", Some("def456")).unwrap(); - eprintln!("plain={plain:?} keyed={keyed:?} other={other:?}"); - assert_ne!(plain, keyed, "the key changes the entry"); - assert_ne!(keyed, other, "two keys are two entries"); - } - - #[test] - fn a_path_derivation_never_escapes_the_cache_directory() { - // The splice point validates: even a consumer that only - // derives paths (never writes) gets the named error instead - // of a path outside the cache directory. - let cache = RootfsCache::new("/cache"); - let err = cache - .resolved_entry_path("alpine:3.23", "amd64", Some("../x")) - .unwrap_err(); - assert!(err.to_string().contains("invalid layer name"), "{err}"); - } - - #[test] - fn layer_names_cannot_escape_the_cache_directory() { - let dir = tempfile::tempdir().unwrap(); - let cache = RootfsCache::new(dir.path().to_path_buf()); - for bad in ["../evil", "a/b", "", ".", "..", ".hidden", "a\0b"] { - assert!( - cache.write_layer_pointer(bad, "x.tar.gz").is_err(), - "{bad:?} must be rejected" - ); - assert!(cache.layer_entry(bad).is_err(), "{bad:?} must be rejected"); - } - for good in ["base", "dev-tools", "ci_2026", "Layer1"] { - assert!( - validate_layer_name(good).is_ok(), - "{good:?} must be accepted" - ); - } - } - - #[test] - fn a_layer_pointer_resolves_only_while_the_entry_exists() { - let dir = tempfile::tempdir().expect("tempdir"); - let cache = RootfsCache::new(dir.path().to_path_buf()); - assert!( - cache.layer_entry("nope").expect("reads").is_none(), - "no pointer, no entry" - ); - cache - .write_layer_pointer("w", "alpine-3.23.7-x86_64.tar.abc.tar.gz") - .expect("writes"); - assert!( - cache.layer_entry("w").expect("reads").is_none(), - "a pointer without its entry resolves to nothing" - ); - std::fs::write( - dir.path().join("alpine-3.23.7-x86_64.tar.abc.tar.gz"), - b"tarball", - ) - .expect("the entry appears"); - std::fs::write( - dir.path() - .join("alpine-3.23.7-x86_64.tar.abc.tar.gz.provisioned"), - b"", - ) - .expect("the marker appears"); - assert!( - cache.layer_entry("w").expect("reads").is_some(), - "pointer + entry + marker resolve" - ); - } } diff --git a/crates/ecrou/src/extract.rs b/crates/ecrou/src/extract.rs index 220866c..a642c98 100644 --- a/crates/ecrou/src/extract.rs +++ b/crates/ecrou/src/extract.rs @@ -129,9 +129,9 @@ fn extract_multi_layer_oci( let manifest_path = temp_dir.path().join("layers.manifest"); if !manifest_path.exists() { - // Not our layer-bundle layout: a provisioned rootfs persisted over - // an OCI cache entry (the dispatch above keys on the "oci-" filename - // prefix). The outer archive is a plain rootfs tarball. + // Not our layer-bundle layout: an "oci-" entry whose outer + // archive is a plain rootfs tarball (the dispatch keys on the + // filename prefix, so a non-bundle entry still lands here). log::debug!("No layers.manifest in bundle; extracting as plain rootfs tarball"); return extract_single_archive(tarball, dest, progress); } diff --git a/crates/ecrou/src/lib.rs b/crates/ecrou/src/lib.rs index 243b6af..2a8f44d 100644 --- a/crates/ecrou/src/lib.rs +++ b/crates/ecrou/src/lib.rs @@ -13,8 +13,8 @@ //! socket, which is what lets ecrou run inside a strict snap's seccomp //! allowlist. See SPEC.md. //! -//! The main entry points are [`rootfs`] (cache-aware rootfs preparation -//! and persistence) and [`vm`] (running a command inside a prepared +//! The main entry points are [`rootfs`] (cache-aware rootfs +//! preparation) and [`vm`] (running a command inside a prepared //! rootfs). Public functions return [`Result`] carrying the [`Error`] //! enum, so consumers can match on host-readiness failures //! ([`Error::KvmUnavailable`], [`Error::ArchNotAvailable`]) versus @@ -38,11 +38,9 @@ pub mod error; pub mod extract; pub mod guest; pub mod krun; -mod pack; pub mod progress; mod registry; pub mod rootfs; -pub mod spec; mod supervise; pub mod utils; pub mod vm; diff --git a/crates/ecrou/src/pack.rs b/crates/ecrou/src/pack.rs deleted file mode 100644 index 1d19817..0000000 --- a/crates/ecrou/src/pack.rs +++ /dev/null @@ -1,125 +0,0 @@ -//! Packing a rootfs tree into the compressed tarball a cache entry -//! stores, so a provisioned tree persists and later boots skip the -//! download. The write lands in a unique partial file and renames -//! atomically; compression follows the destination's extension. - -use anyhow::{anyhow, Context}; -use std::path::Path; - -/// Write a tarball of `rootfs` at `dest`, compressed according to dest's -/// extension (.tar.gz, .tar.xz, .tar.zst, plain .tar), atomically via a -/// unique partial file ([`crate::utils::partial_path`]). -pub(crate) fn pack_rootfs(rootfs: &Path, dest: &Path) -> anyhow::Result<()> { - // Packing the tree into a tarball placed inside it would recurse - // forever: the walker keeps seeing the archive grow. - let rootfs_canonical = rootfs - .canonicalize() - .with_context(|| format!("Failed to resolve rootfs directory {}", rootfs.display()))?; - if let Some(parent) = dest.parent() { - if let Ok(parent_canonical) = parent.canonicalize() { - if parent_canonical.starts_with(&rootfs_canonical) { - return Err(anyhow!( - "Cache entry {} must live outside the rootfs", - dest.display() - )); - } - } - } - - let filename = dest.file_name().and_then(|n| n.to_str()).unwrap_or(""); - let partial = crate::utils::partial_path(dest); - - let file = std::fs::File::create(&partial) - .with_context(|| format!("Failed to create {}", partial.display()))?; - - let result: anyhow::Result<()> = (|| { - if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") { - let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default()); - let encoder = write_rootfs_tar(rootfs, encoder)?; - encoder.finish().context("Failed to finalise gzip stream")?; - } else if filename.ends_with(".tar.xz") || filename.ends_with(".txz") { - let encoder = xz2::write::XzEncoder::new(file, 6); - let encoder = write_rootfs_tar(rootfs, encoder)?; - encoder.finish().context("Failed to finalise xz stream")?; - } else if filename.ends_with(".tar.zst") || filename.ends_with(".tar.zstd") { - let encoder = zstd::stream::write::Encoder::new(file, 0) - .context("Failed to create zstd encoder")?; - let encoder = write_rootfs_tar(rootfs, encoder)?; - encoder.finish().context("Failed to finalise zstd stream")?; - } else { - write_rootfs_tar(rootfs, file)?; - } - Ok(()) - })(); - - if result.is_err() { - // Best-effort cleanup; ignore errors (file may not exist if creation failed). - let _ = std::fs::remove_file(&partial); - return result; - } - - std::fs::rename(&partial, dest) - .with_context(|| format!("Failed to move packed rootfs to {}", dest.display())) -} - -/// Write the rootfs tree into a tar archive streamed to `writer`, returning -/// the writer so the caller can finalise the compression stream. -fn write_rootfs_tar(rootfs: &Path, writer: W) -> anyhow::Result { - let mut builder = tar::Builder::new(writer); - // Store symlinks as symlinks; following them would duplicate the target's - // contents into the archive (and recurse into symlinked directories). - builder.follow_symlinks(false); - - let entries = std::fs::read_dir(rootfs) - .with_context(|| format!("Failed to read {}", rootfs.display()))?; - - for entry in entries { - let entry = - entry.with_context(|| format!("Failed to read entry in {}", rootfs.display()))?; - let path = entry.path(); - let name = entry.file_name(); - - // symlink_metadata: never follow symlinks — a top-level symlink to a - // directory must be stored as a link, not recursed into. - let meta = std::fs::symlink_metadata(&path) - .with_context(|| format!("Failed to stat {}", path.display()))?; - - if meta.is_dir() { - builder - .append_dir_all(&name, &path) - .with_context(|| format!("Failed to archive {}", path.display()))?; - } else { - builder - .append_path_with_name(&path, &name) - .with_context(|| format!("Failed to archive {}", path.display()))?; - } - } - - builder - .into_inner() - .context("Failed to finalise tar archive") -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn pack_failure_leaves_no_partial_file() { - // A nonexistent rootfs must fail cleanly and clean up its - // partial file, whatever unique name it carried. - let cache = tempfile::tempdir().unwrap(); - let dest = cache.path().join("x.tar.gz"); - assert!(pack_rootfs(Path::new("/nonexistent-rootfs"), &dest).is_err()); - assert!(!dest.exists()); - let leftovers: Vec = std::fs::read_dir(cache.path()) - .unwrap() - .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) - .filter(|name| name.contains("partial")) - .collect(); - assert!( - leftovers.is_empty(), - "partial files left behind: {leftovers:?}" - ); - } -} diff --git a/crates/ecrou/src/rootfs.rs b/crates/ecrou/src/rootfs.rs index 7440271..b0ae984 100644 --- a/crates/ecrou/src/rootfs.rs +++ b/crates/ecrou/src/rootfs.rs @@ -1,27 +1,17 @@ //! Cache-aware rootfs lifecycle: resolve an image reference, download -//! through the tarball cache, extract into a scratch directory, and -//! optionally persist a provisioned rootfs back into the cache. +//! through the tarball cache, and extract into a scratch directory for +//! [`crate::vm`] to boot. //! -//! The hot-cell flow is [`RootfsCache::prepare_provisioned`]: provision -//! once (package installs, user setup, …), and every subsequent call — -//! in this or another process sharing the cache — hits the persisted -//! entry and skips both the download and the provisioning step: +//! The run path is [`RootfsCache::prepare`]: a present cache entry is +//! trusted as-is — the registry is never contacted — and the freshness +//! decision belongs to [`RootfsCache::pull`]: //! //! ```no_run //! use ecrou::{NoProgress, PrepareRequest, RootfsCache}; //! //! # fn main() -> anyhow::Result<()> { //! let cache = RootfsCache::new(RootfsCache::default_dir()?); -//! let req = PrepareRequest::new("alpine:3.23"); -//! -//! let rootfs = cache.prepare_provisioned(&req, &NoProgress, |rootfs| { -//! // Provision by editing the tree host-side. In-rootfs commands -//! // arrive with vm::run, which boots the provisioned rootfs. -//! std::fs::write(rootfs.dir().join("etc/motd"), "provisioned by ecrou\n")?; -//! Ok(()) -//! })?; -//! -//! // Amortized runs hit the provisioned entry directly +//! let rootfs = cache.prepare(&PrepareRequest::new("alpine:3.23"), &NoProgress)?; //! let dir = rootfs.dir().to_path_buf(); //! # let _ = dir; Ok(()) //! # } @@ -31,11 +21,9 @@ use std::path::{Path, PathBuf}; use anyhow::{anyhow, Context}; -use crate::cache::{ - extract_prepared, fill_entry, oci_freshness_changed, sidecar, EntryLock, RootfsCache, - PROVISIONED_SUFFIX, -}; +use crate::cache::{extract_prepared, oci_freshness_changed, EntryLock, RootfsCache}; use crate::distro::{resolve_source, ImageSource}; +use crate::download::download_image; use crate::error::Error; use crate::progress::Progress; use crate::Result; @@ -63,14 +51,13 @@ impl RootfsCache { /// pulls by default, because there is nothing to boot otherwise. pub fn prepare(&self, req: &PrepareRequest, progress: &dyn Progress) -> Result { let (source, cache_path, _) = self.ensure_entry(req, progress, false)?; - // A local archive bypasses the cache: the file is the pristine - // source on every run (--no-cache has nothing to say about it), - // and the derived entry name only matters as the place a - // provisioning over it would persist into. + // A local archive bypasses the cache: the file is the source + // on every run (--no-cache has nothing to say about it), and + // the derived entry name only matters for diagnostics. if let ImageSource::LocalTarball { path } = &source { - return extract_prepared(path, cache_path, progress).map_err(Error::Other); + return extract_prepared(path, progress).map_err(Error::Other); } - Ok(extract_prepared(&cache_path.clone(), cache_path, progress)?) + Ok(extract_prepared(&cache_path, progress)?) } /// Fetch or refresh the image's cache entry without extracting it: @@ -137,7 +124,7 @@ impl RootfsCache { format!("Failed to create cache directory {}", self.dir.display()) })?; if req.no_cache { - fill_entry(&source, &cache_path, &arch, progress)?; + download_image(&source, &cache_path, &arch, progress)?; downloaded = true; } else { // One writer per entry: a concurrent run preparing the @@ -151,7 +138,7 @@ impl RootfsCache { let stale = !cache_path.exists() || (oci_digest_changed && oci_freshness_changed(&source, &cache_path)); if stale { - fill_entry(&source, &cache_path, &arch, progress)?; + download_image(&source, &cache_path, &arch, progress)?; downloaded = true; } else { log::debug!( @@ -168,71 +155,7 @@ impl RootfsCache { } } -impl RootfsCache { - /// Prepare a provisioned rootfs with cache amortization: when the cache - /// entry is already marked provisioned it is extracted as-is; otherwise - /// the rootfs is prepared from the pristine image, `provision` runs - /// once, and the result is persisted into the cache so subsequent calls - /// (including from other processes sharing this cache) skip both the - /// download and the provisioning step. - /// - /// A provisioned entry no longer tracks the upstream image: the - /// pristine base is trusted once cached (`pull` is the refresh - /// operation, and a re-pull does not invalidate provisioning). - /// Re-provision by removing the entry and its `.provisioned` - /// sidecar, or by passing `no_cache: true` (which re-downloads, - /// re-provisions and re-persists). - pub fn prepare_provisioned( - &self, - req: &PrepareRequest, - progress: &dyn Progress, - provision: F, - ) -> Result - where - F: FnOnce(&PreparedRootfs) -> anyhow::Result<()>, - { - let arch = req.resolved_arch(); - let source = resolve_source(&req.image, &arch)?; - let cache_path = self.entry_path(&source, &arch, req.layer_key.as_deref())?; - let marker = sidecar(&cache_path, PROVISIONED_SUFFIX); - - if !req.no_cache && cache_path.exists() && marker.exists() { - log::debug!("Using provisioned cache entry: {}", cache_path.display()); - return Ok(extract_prepared(&cache_path.clone(), cache_path, progress)?); - } - - // The miss path prepares the PRISTINE base — a keyed entry - // has no tarball of its own to prepare from, and the base - // must download if missing. The key is this call's business, - // never prepare's. - let mut base_req = req.clone(); - base_req.layer_key = None; - let rootfs = self.prepare(&base_req, progress)?; - provision(&rootfs)?; - if req.layer_key.is_some() { - // The prepared scratch is the BASE (a keyed entry has no - // tarball to prepare from): the provisioned tree lands in - // the keyed entry, not over the base. - rootfs.persist_to(&cache_path).with_context(|| { - format!( - "Failed to persist provisioned layer to {}", - cache_path.display() - ) - })?; - } else { - rootfs.persist().with_context(|| { - format!( - "Failed to persist provisioned rootfs to {}", - cache_path.display() - ) - })?; - } - Ok(rootfs) - } -} - -/// Request for [`RootfsCache::prepare`] / [`RootfsCache::pull`] / -/// [`RootfsCache::prepare_provisioned`]. +/// Request for [`RootfsCache::prepare`] / [`RootfsCache::pull`]. #[derive(Debug, Clone)] pub struct PrepareRequest { /// Image reference: a distro name ("ubuntu", "ubuntu:noble", @@ -246,12 +169,6 @@ pub struct PrepareRequest { /// Ignore the cache and download a fresh image. pub no_cache: bool, - - /// A layer key: when set, the cache entry is the keyed layer over - /// the image rather than the image itself — the entry is never - /// downloaded, only provisioned into existence - /// ([`RootfsCache::prepare_provisioned`]). - pub layer_key: Option, } impl PrepareRequest { @@ -260,7 +177,6 @@ impl PrepareRequest { image: image.into(), arch: String::new(), no_cache: false, - layer_key: None, } } @@ -275,12 +191,11 @@ impl PrepareRequest { } /// A rootfs extracted into a scratch directory, ready for -/// [`crate::vm::run`] to boot. The directory is removed when this value is -/// dropped, unless [`PreparedRootfs::persist`] ran first. +/// [`crate::vm::run`] to boot. The directory is removed when this +/// value is dropped. #[derive(Debug)] pub struct PreparedRootfs { pub(crate) rootfs: TempDir, - pub(crate) cache_path: PathBuf, } impl PreparedRootfs { @@ -288,39 +203,12 @@ impl PreparedRootfs { pub fn dir(&self) -> &Path { self.rootfs.path() } - - /// Persist the current contents of the rootfs into the cache entry it - /// was prepared from, replacing the pristine image tarball, and mark - /// the entry provisioned. Later preparations for the same image and - /// architecture hit this entry directly; note that plain - /// [`RootfsCache::prepare`] also returns the provisioned contents - /// (indistinguishable from a pristine hit) — only - /// [`RootfsCache::prepare_provisioned`] interprets the marker. - pub fn persist(&self) -> Result<()> { - self.persist_to(&self.cache_path) - } - - /// Packs the tree into an arbitrary cache entry — how a provisioned - /// layer lands under its own key while the prepared scratch still - /// carries the base's own cache path. - pub fn persist_to(&self, cache_path: &Path) -> Result<()> { - crate::pack::pack_rootfs(self.dir(), cache_path)?; - std::fs::write(sidecar(cache_path, PROVISIONED_SUFFIX), b"").with_context(|| { - format!( - "Failed to write provisioned marker for {}", - cache_path.display() - ) - })?; - log::debug!("Persisted rootfs to cache: {}", cache_path.display()); - Ok(()) - } } #[cfg(test)] mod tests { use super::*; use crate::progress::NoProgress; - use std::os::unix::fs::PermissionsExt; #[test] fn pull_rejects_local_archives() { @@ -387,83 +275,6 @@ mod tests { assert_eq!(req.resolved_arch(), "arm64"); } - fn roundtrip(dest_name: &str) { - let src = tempfile::tempdir().unwrap(); - std::fs::write(src.path().join("hello.txt"), b"hello").unwrap(); - std::fs::create_dir_all(src.path().join("usr/bin")).unwrap(); - std::fs::write(src.path().join("usr/bin/tool"), b"#!/bin/sh\n").unwrap(); - std::fs::set_permissions( - src.path().join("usr/bin/tool"), - std::fs::Permissions::from_mode(0o755), - ) - .unwrap(); - std::os::unix::fs::symlink("hello.txt", src.path().join("link.txt")).unwrap(); - - let cache = tempfile::tempdir().unwrap(); - let dest = cache.path().join(dest_name); - crate::pack::pack_rootfs(src.path(), &dest).unwrap(); - - let out = tempfile::tempdir().unwrap(); - crate::extract::extract_tarball(&dest, out.path(), &NoProgress).unwrap(); - - assert_eq!( - std::fs::read(out.path().join("hello.txt")).unwrap(), - b"hello" - ); - let mode = std::fs::metadata(out.path().join("usr/bin/tool")) - .unwrap() - .permissions() - .mode(); - assert_eq!(mode & 0o777, 0o755, "executable bit must survive"); - assert_eq!( - std::fs::read_link(out.path().join("link.txt")).unwrap(), - Path::new("hello.txt") - ); - } - - #[test] - fn persist_roundtrip_gzip() { - roundtrip("rootfs.tar.gz"); - } - - #[test] - fn persist_roundtrip_xz() { - roundtrip("rootfs.tar.xz"); - } - - #[test] - fn persist_roundtrip_zstd() { - roundtrip("rootfs.tar.zst"); - } - - #[test] - fn persist_roundtrip_plain_tar() { - roundtrip("rootfs.tar"); - } - - #[test] - fn persist_writes_entry_and_provisioned_marker() { - let rootfs_dir = tempfile::tempdir().unwrap(); - std::fs::write(rootfs_dir.path().join("file.txt"), b"data").unwrap(); - - let cache_dir = tempfile::tempdir().unwrap(); - let cache_path = cache_dir.path().join("ubuntu-noble-amd64.tar.gz"); - - let prepared = PreparedRootfs { - rootfs: rootfs_dir, - cache_path: cache_path.clone(), - }; - prepared.persist().unwrap(); - - assert!(cache_path.exists()); - assert!(sidecar(&cache_path, PROVISIONED_SUFFIX).exists()); - - // The persisted entry extracts back to the original contents - let out = tempfile::tempdir().unwrap(); - crate::extract::extract_tarball(&cache_path, out.path(), &NoProgress).unwrap(); - assert_eq!(std::fs::read(out.path().join("file.txt")).unwrap(), b"data"); - } - #[test] fn a_local_archive_prepares_without_touching_the_cache() { // A rootfs tree, packed next to itself as ./rootfs.tar.gz, the @@ -473,7 +284,11 @@ mod tests { std::fs::create_dir_all(&tree).unwrap(); std::fs::write(tree.join("etc-marker"), b"local").unwrap(); let tarball = work.path().join("rootfs.tar.gz"); - crate::pack::pack_rootfs(&tree, &tarball).unwrap(); + let file = std::fs::File::create(&tarball).unwrap(); + let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default()); + let mut tar = tar::Builder::new(encoder); + tar.append_dir_all(".", &tree).unwrap(); + tar.into_inner().unwrap().finish().unwrap(); let cache_dir = tempfile::tempdir().unwrap(); let cache = RootfsCache::new(cache_dir.path().to_path_buf()); diff --git a/crates/ecrou/src/spec.rs b/crates/ecrou/src/spec.rs deleted file mode 100644 index 37fcd5c..0000000 --- a/crates/ecrou/src/spec.rs +++ /dev/null @@ -1,147 +0,0 @@ -//! Provisioning specs: the checked-in description of what a layer -//! carries beyond its base image. A spec is data, not a command — -//! the artifact under review is a diff in git, and the layer it -//! produces is a pure function of the file's content and its base. - -use serde::Deserialize; -use std::fmt::Write as _; - -/// A parsed provisioning spec file (YAML: base, packages, run). -#[derive(Debug, Clone, PartialEq, Deserialize)] -pub struct ProvisionSpec { - /// The image to provision over. Optional: without it the caller's - /// positional image is the base. - #[serde(default)] - pub base: Option, - - /// Packages installed through whatever package manager the base - /// carries (apt, apk, dnf). - #[serde(default)] - pub packages: Vec, - - /// A shell script run after the packages, for everything a - /// package name cannot say. - #[serde(default)] - pub run: Option, -} - -impl ProvisionSpec { - /// Parses the spec from raw file bytes. Empty and missing optional - /// fields stay optional; an unparseable file is an error — a typo - /// in the spec must not silently provision a bare base. - pub fn parse(bytes: &[u8]) -> anyhow::Result { - let spec: ProvisionSpec = serde_yaml::from_slice(bytes) - .map_err(|err| anyhow::anyhow!("the provisioning spec does not parse: {err}"))?; - if spec.base.is_none() && spec.packages.is_empty() && spec.run.is_none() { - anyhow::bail!("the provisioning spec is empty"); - } - Ok(spec) - } - - /// The layer key: content the cache entries off. Hash of the raw - /// file bytes and the base reference — the bytes, not the parsed - /// form, so the key is stable across ecrou versions; reformatting - /// the file rebuilds the layer, which is harmless and visible. - pub fn key(spec_bytes: &[u8], base_ref: &str) -> String { - use sha2::{Digest, Sha256}; - let mut hasher = Sha256::new(); - hasher.update(spec_bytes); - hasher.update([0]); - hasher.update(base_ref.as_bytes()); - let digest = hasher.finalize(); - digest[..8].iter().fold(String::new(), |mut out, byte| { - let _ = write!(out, "{byte:02x}"); - out - }) - } - - /// The provisioning script: the packages through the base's - /// package manager, then the raw `run` block. Nothing here knows - /// which distro it lands on ahead of time — the machine itself - /// says, at provision time. - pub fn script(&self) -> String { - let mut script = String::from("set -e\n"); - if !self.packages.is_empty() { - let packages = self.packages.join(" "); - let _ = write!( - script, - r#"if command -v apt-get >/dev/null 2>&1; then - export DEBIAN_FRONTEND=noninteractive - apt-get update -qq - apt-get install -y -qq --no-install-recommends {packages} -elif command -v apk >/dev/null 2>&1; then - apk add --no-cache {packages} -elif command -v dnf >/dev/null 2>&1; then - dnf install -y {packages} -else - echo "no known package manager in the base image" >&2 - exit 1 -fi -"# - ); - } - if let Some(run) = &self.run { - script.push_str(run.trim_end()); - script.push('\n'); - } - script - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn a_spec_parses_with_all_parts_optional_but_none_empty() { - let spec = ProvisionSpec::parse(b"packages:\n - jq\n").expect("parses"); - assert_eq!(spec.packages, vec!["jq".to_owned()]); - assert!(spec.base.is_none()); - - let full = ProvisionSpec::parse( - b"base: ghcr.io/example/base:v1\npackages:\n - go\nrun: |\n cargo install just\n", - ) - .expect("parses"); - assert_eq!(full.base.as_deref(), Some("ghcr.io/example/base:v1")); - assert!(full.run.as_deref().unwrap().contains("cargo install")); - - assert!(ProvisionSpec::parse(b"\n").is_err(), "empty is an error"); - assert!( - ProvisionSpec::parse(b"packages: [\n").is_err(), - "unparseable yaml is an error" - ); - } - - #[test] - fn the_key_tracks_the_spec_bytes_and_the_base() { - let a = ProvisionSpec::key(b"packages: [jq]\n", "alpine:3.23"); - assert_eq!(a.len(), 16); - assert_eq!(a, ProvisionSpec::key(b"packages: [jq]\n", "alpine:3.23")); - assert_ne!(a, ProvisionSpec::key(b"packages: [jq]\n", "alpine:3.24")); - assert_ne!(a, ProvisionSpec::key(b"packages: [jq] \n", "alpine:3.23")); - } - - #[test] - fn the_script_speaks_the_base_package_manager_and_then_runs() { - let spec = ProvisionSpec { - base: None, - packages: vec!["jq".into(), "go".into()], - run: Some("go install m/tool@latest\n".into()), - }; - let script = spec.script(); - assert!(script.starts_with("set -e")); - assert!(script.contains("apt-get install -y -qq --no-install-recommends jq go")); - assert!(script.contains("apk add --no-cache jq go")); - assert!(script.ends_with("go install m/tool@latest\n")); - - // packages only: no pm block would try to install nothing - let bare = ProvisionSpec { - packages: vec![], - run: Some("echo hi".into()), - base: None, - }; - let script = bare.script(); - assert!(!script.contains("apt-get")); - assert!(script.contains("echo hi")); - } -} diff --git a/crates/ecrou/src/vm.rs b/crates/ecrou/src/vm.rs index 1940a01..017f71f 100644 --- a/crates/ecrou/src/vm.rs +++ b/crates/ecrou/src/vm.rs @@ -55,11 +55,6 @@ pub struct RunOptions { /// How much of the engine's own logging reaches stderr pub log_level: LogLevel, pub no_cache: bool, - /// When set, boot the named provisioned layer from the cache - /// instead of resolving an image: no download, no resolution — - /// the layer is whatever `ecrou provision --spec … --layer …` - /// persisted under that name. - pub layer: Option, /// When set, the payload's stdout and stderr are captured into /// files in this host directory (`stdout` and `stderr` under it): /// the run adds the directory as an internal read-write share, @@ -101,7 +96,6 @@ impl Default for RunOptions { tty: false, log_level: LogLevel::Normal, no_cache: false, - layer: None, capture: None, } } @@ -109,20 +103,6 @@ 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 { - // A named layer boots straight from its cache entry: no image - // resolution, no download — the layer is whatever the provision - // call persisted under that name. - if let Some(name) = &options.layer { - let cache = RootfsCache::new(RootfsCache::default_dir()?); - let rootfs = cache.extract_layer(name, progress)?; - // Downstream naming (the hostname, diagnostics) rides the - // layer name: the entry is what booted, not an image. - let options = RunOptions { - image: name.clone(), - ..options.clone() - }; - return run_prepared(&rootfs, &options); - } let mut request = PrepareRequest::new(options.image.clone()); request.arch = options.arch.clone(); request.no_cache = options.no_cache; @@ -133,10 +113,8 @@ pub fn run(options: &RunOptions, progress: &dyn Progress) -> Result { 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. +/// Boot one machine over an already-prepared rootfs. This is the +/// boot half of [`run`]: prepare lands the tree, this boots it. pub fn run_prepared(rootfs: &PreparedRootfs, options: &RunOptions) -> Result { if options.image.is_empty() { return Err(Error::Other(anyhow!("no image given")));