From 878ce29b8d0aec43ee44e7947c750f320908cc7f Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Thu, 29 Oct 2026 18:06:50 +0100 Subject: [PATCH] feat(cli): provision layers from a spec file A provision command baked by hand - `ecrou provision alpine -- apk add ...` - cannot be reviewed or shared: the artifact worth keeping is a diff in git, not a shell history line. A spec file is that diff. `ecrou provision --spec image.yaml` parses base, packages and a run block, derives the provisioning script from the base's own package manager (apt, apk, dnf, discovered at provision time on the running machine), and bakes the result into a cache entry keyed by the spec's content hash: the same file never provisions twice, an edited file never reuses the old layer, and the key hashes the raw bytes so the layer is stable across ecrou versions. `--layer NAME` names the entry, and `ecrou run --layer NAME` boots it directly - no image resolution, no download; the layer is whatever the provision call persisted under that name. A keyed entry has no tarball of its own, so the base downloads only on the first provisioning and every later run extracts the finished layer. --- crates/ecrou-cli/src/cli.rs | 27 +++++- crates/ecrou-cli/src/main.rs | 104 +++++++++++++++++--- crates/ecrou/Cargo.toml | 1 + crates/ecrou/src/lib.rs | 1 + crates/ecrou/src/rootfs.rs | 182 ++++++++++++++++++++++++++++++++--- crates/ecrou/src/spec.rs | 150 +++++++++++++++++++++++++++++ crates/ecrou/src/vm.rs | 21 +++- 7 files changed, 455 insertions(+), 31 deletions(-) create mode 100644 crates/ecrou/src/spec.rs diff --git a/crates/ecrou-cli/src/cli.rs b/crates/ecrou-cli/src/cli.rs index 4c33f0d..571003d 100644 --- a/crates/ecrou-cli/src/cli.rs +++ b/crates/ecrou-cli/src/cli.rs @@ -27,9 +27,20 @@ pub enum Command { /// 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 + /// Distribution name or OCI image reference; the spec's `base` + /// when omitted and the spec carries one + image: Option, + /// A provisioning spec file (YAML: base, packages, run) — the + /// layer it describes is keyed by the file's content, so the + /// same spec never provisions twice and a changed spec never + /// reuses an old layer + #[arg(long)] + spec: Option, + /// Name the provisioned layer, so runs can boot it by name + /// (`run --layer NAME`) without re-deriving anything + #[arg(long)] + layer: Option, + /// Ignore the cache: re-download the base and re-provision #[arg(long)] no_cache: bool, /// The provisioning command. An argument vector after `--` is @@ -44,8 +55,10 @@ pub enum Command { #[derive(Args, Debug)] pub struct RunArgs { - /// Distribution name or OCI image reference - pub image: String, + /// Distribution name or OCI image reference; not needed with + /// `--layer`, which boots a provisioned cache entry + #[arg(required_unless_present = "layer")] + pub image: Option, /// 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. @@ -73,6 +86,10 @@ pub struct RunArgs { /// Ignore the cache and download a fresh image #[arg(long)] pub no_cache: bool, + /// Boot a named provisioned layer (see `provision --layer`) + /// instead of resolving an image + #[arg(long)] + pub layer: Option, } /// Parse one `HOST:GUEST[:ro]` share specification. The guest path must diff --git a/crates/ecrou-cli/src/main.rs b/crates/ecrou-cli/src/main.rs index 338066f..268d070 100644 --- a/crates/ecrou-cli/src/main.rs +++ b/crates/ecrou-cli/src/main.rs @@ -20,34 +20,43 @@ fn main() -> std::process::ExitCode { Command::Run(args) => cmd_run(&args, cli.verbose), Command::Provision { image, + spec, + layer, no_cache, command, - } => cmd_provision(&image, no_cache, &command, cli.verbose), + } => cmd_provision(image, spec, layer, no_cache, &command, cli.verbose), Command::Probe => cmd_probe(), } } fn cmd_provision( - image: &str, + image: Option, + spec: Option, + layer: Option, no_cache: bool, command: &[String], cli_verbose: bool, ) -> std::process::ExitCode { use ecrou::rootfs::{PrepareRequest, RootfsCache}; + use ecrou::spec::ProvisionSpec; - if command.is_empty() { + // 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 a command: ecrou provision -- " + "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 { - 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. @@ -56,16 +65,61 @@ fn cmd_provision( 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); + + // 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 { @@ -76,14 +130,37 @@ fn cmd_provision( Ok(()) }); match outcome { - Ok(_rootfs) => std::process::ExitCode::SUCCESS, + 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 { let mut options = RunOptions { - image: args.image.clone(), + // 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(), command: args.command.clone(), memory: args.memory.clone(), workdir: args.workdir.clone(), @@ -95,6 +172,7 @@ fn cmd_run(args: &RunArgs, cli_verbose: bool) -> std::process::ExitCode { LogLevel::Normal }, no_cache: args.no_cache, + layer: args.layer.clone(), capture: args.capture.clone(), ..RunOptions::default() }; diff --git a/crates/ecrou/Cargo.toml b/crates/ecrou/Cargo.toml index 9a52d0a..585b654 100644 --- a/crates/ecrou/Cargo.toml +++ b/crates/ecrou/Cargo.toml @@ -15,6 +15,7 @@ serde_yaml = "0.9" # HTTP downloads reqwest = { version = "0.13", features = ["blocking", "stream"] } + # Tarball extraction tar = "0.4" flate2 = "1" diff --git a/crates/ecrou/src/lib.rs b/crates/ecrou/src/lib.rs index 67e6515..756c2a2 100644 --- a/crates/ecrou/src/lib.rs +++ b/crates/ecrou/src/lib.rs @@ -40,6 +40,7 @@ pub mod guest; pub mod krun; pub mod progress; pub mod rootfs; +pub mod spec; pub mod utils; pub mod vm; diff --git a/crates/ecrou/src/rootfs.rs b/crates/ecrou/src/rootfs.rs index 7414ae4..03dd60d 100644 --- a/crates/ecrou/src/rootfs.rs +++ b/crates/ecrou/src/rootfs.rs @@ -31,6 +31,7 @@ use std::path::{Path, PathBuf}; use anyhow::{anyhow, Context}; +use crate::error::Error; use crate::Result; use tempfile::TempDir; @@ -79,6 +80,87 @@ impl RootfsCache { 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. + pub fn entry_path(&self, source: &ImageSource, arch: &str, layer_key: Option<&str>) -> PathBuf { + match layer_key { + None => self.tarball_path(source, arch), + Some(key) => { + let filename = generate_cache_filename(source, arch); + let stem = filename + .rsplit_once('.') + .map(|(stem, _)| stem) + .unwrap_or(&filename); + 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 { + 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" + )) + })?; + let arch = std::env::consts::ARCH + .replace("x86_64", "amd64") + .replace("aarch64", "arm64"); + extract_prepared(path, arch, 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)?; + Ok(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<()> { + let dir = self.dir.join("layers"); + std::fs::create_dir_all(&dir) + .with_context(|| format!("Failed to create the layer directory {}", dir.display()))?; + std::fs::write(dir.join(name), cache_filename) + .with_context(|| format!("Failed to write the layer pointer 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> { + 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)) + } + /// Resolve `req`, download through the cache when needed, and extract /// into a fresh scratch directory (removed when the returned /// [`PreparedRootfs`] is dropped). Download and extraction progress is @@ -172,7 +254,7 @@ impl RootfsCache { { let arch = req.resolved_arch(); let source = resolve_source(&req.image, &arch)?; - let cache_path = self.tarball_path(&source, &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() { @@ -180,14 +262,32 @@ impl RootfsCache { return Ok(extract_prepared(cache_path, arch, progress)?); } - let rootfs = self.prepare(req, 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)?; - rootfs.persist().with_context(|| { - format!( - "Failed to persist provisioned rootfs to {}", - cache_path.display() - ) - })?; + 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) } } @@ -206,6 +306,12 @@ 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 { @@ -214,6 +320,7 @@ impl PrepareRequest { image: image.into(), arch: String::new(), no_cache: false, + layer_key: None, } } @@ -261,14 +368,21 @@ impl PreparedRootfs { /// (indistinguishable from a pristine hit) — only /// [`RootfsCache::prepare_provisioned`] interprets the marker. pub fn persist(&self) -> Result<()> { - pack_rootfs(self.dir(), &self.cache_path)?; - std::fs::write(sidecar(&self.cache_path, PROVISIONED_SUFFIX), b"").with_context(|| { + 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<()> { + pack_rootfs(self.dir(), cache_path)?; + std::fs::write(sidecar(cache_path, PROVISIONED_SUFFIX), b"").with_context(|| { format!( "Failed to write provisioned marker for {}", - self.cache_path.display() + cache_path.display() ) })?; - log::debug!("Persisted rootfs to cache: {}", self.cache_path.display()); + log::debug!("Persisted rootfs to cache: {}", cache_path.display()); Ok(()) } } @@ -693,4 +807,48 @@ mod tests { assert!(!dest.exists()); assert!(!dest.with_extension("partial").exists()); } + + #[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); + let keyed = cache.entry_path(&source, "amd64", Some("abc123")); + let other = cache.entry_path(&source, "amd64", Some("def456")); + 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_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/spec.rs b/crates/ecrou/src/spec.rs new file mode 100644 index 0000000..20e112f --- /dev/null +++ b/crates/ecrou/src/spec.rs @@ -0,0 +1,150 @@ +//! 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 `.rhema/image.yaml` (or any consumer's equivalent). +#[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/vhaudiquet/rhema-base:v1\npackages:\n - go\nrun: |\n cargo install just\n", + ) + .expect("parses"); + assert_eq!( + full.base.as_deref(), + Some("ghcr.io/vhaudiquet/rhema-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 d51cf33..87eb72a 100644 --- a/crates/ecrou/src/vm.rs +++ b/crates/ecrou/src/vm.rs @@ -45,6 +45,11 @@ 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, @@ -84,6 +89,7 @@ impl Default for RunOptions { hostname: String::new(), log_level: LogLevel::Normal, no_cache: false, + layer: None, capture: None, } } @@ -91,6 +97,20 @@ 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; @@ -109,7 +129,6 @@ pub fn run_prepared(rootfs: &PreparedRootfs, options: &RunOptions) -> Result