diff --git a/crates/ecrou/src/lib.rs b/crates/ecrou/src/lib.rs index dc77069..243b6af 100644 --- a/crates/ecrou/src/lib.rs +++ b/crates/ecrou/src/lib.rs @@ -50,5 +50,5 @@ pub mod vm; pub use cache::RootfsCache; pub use error::{Error, Result}; pub use progress::{NoProgress, Progress, Unit}; -pub use rootfs::{PrepareRequest, PreparedRootfs}; +pub use rootfs::{PrepareRequest, PreparedRootfs, Pulled}; pub use vm::{run, LogLevel, RunOptions, Share}; diff --git a/crates/ecrou/src/rootfs.rs b/crates/ecrou/src/rootfs.rs index 516df33..7440271 100644 --- a/crates/ecrou/src/rootfs.rs +++ b/crates/ecrou/src/rootfs.rs @@ -41,20 +41,76 @@ use crate::progress::Progress; use crate::Result; use tempfile::TempDir; +/// What a satisfied image request landed on. +#[derive(Debug, Clone)] +pub struct Pulled { + /// The cache entry holding the image. + pub cache_path: PathBuf, + /// Whether this call transferred bytes: `false` means the entry + /// was already present and current. + pub downloaded: bool, +} + impl RootfsCache { /// 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 /// reported through `progress`. + /// + /// This is the boot path, not the freshness path: a present entry + /// is trusted as-is and the registry is never contacted — the + /// refresh decision belongs to [`RootfsCache::pull`]. A cold cache + /// pulls by default, because there is nothing to boot otherwise. pub fn prepare(&self, req: &PrepareRequest, progress: &dyn Progress) -> Result { - let arch = req.resolved_arch(); - let source = resolve_source(&req.image, &arch)?; - + 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. if let ImageSource::LocalTarball { path } = &source { + return extract_prepared(path, cache_path, progress).map_err(Error::Other); + } + Ok(extract_prepared(&cache_path.clone(), cache_path, progress)?) + } + + /// Fetch or refresh the image's cache entry without extracting it: + /// resolve the reference, re-check unpinned OCI tags against the + /// registry digest, re-resolve floating distro aliases, and + /// download only when the entry is absent or has moved. The + /// explicit freshness operation — `prepare` (and so `run`) boots + /// the local entry untouched. + pub fn pull(&self, req: &PrepareRequest, progress: &dyn Progress) -> Result { + let (_, cache_path, downloaded) = self.ensure_entry(req, progress, true)?; + Ok(Pulled { + cache_path, + downloaded, + }) + } + + /// The shared spine of `prepare` and `pull`: resolve the request + /// and see to it the pristine cache entry exists and is current. + /// + /// `recheck` is the freshness policy. `false` trusts a present + /// entry. `true` asks the registry whether an unpinned OCI tag has + /// moved — on a network error the cached image wins with a + /// warning, never a hard failure. Both variants download on an + /// absent entry: a cold cache has to pull by default. + fn ensure_entry( + &self, + req: &PrepareRequest, + progress: &dyn Progress, + recheck: bool, + ) -> Result<(ImageSource, PathBuf, bool)> { + let arch = req.resolved_arch(); + let source = resolve_source(&req.image, &arch)?; + + if let ImageSource::LocalTarball { path } = &source { + if recheck { + return Err(Error::Other(anyhow!( + "nothing to pull: {} is a local file; pull fetches remote images", + path.display() + ))); + } if !path.is_file() { return Err(Error::Other(anyhow!( "local rootfs archive not found: {} (a reference naming a path \ @@ -63,27 +119,26 @@ impl RootfsCache { ))); } let entry = self.tarball_path(&source, &arch); - return extract_prepared(path, entry, progress).map_err(Error::Other); + return Ok((source, entry, false)); } let cache_path = self.tarball_path(&source, &arch); - // OCI images with a floating tag (":latest") need a freshness check: - // fetch the current manifest digest from the registry and compare it - // against the digest stored from the last download. Only re-pull when - // the digest has actually changed. On a network error we fall back to - // the cached image with a warning rather than hard-failing. - let oci_digest_changed = oci_freshness_changed(&source, &cache_path); + let oci_digest_changed = if recheck { + oci_freshness_changed(&source, &cache_path) + } else { + false + }; - // Download when not cached, explicitly requested, or the remote digest - // has moved let needs_download = req.no_cache || !cache_path.exists() || oci_digest_changed; + let mut downloaded = false; if needs_download { std::fs::create_dir_all(&self.dir).with_context(|| { format!("Failed to create cache directory {}", self.dir.display()) })?; if req.no_cache { fill_entry(&source, &cache_path, &arch, progress)?; + downloaded = true; } else { // One writer per entry: a concurrent run preparing the // same image waits here instead of downloading a second @@ -97,6 +152,7 @@ impl RootfsCache { || (oci_digest_changed && oci_freshness_changed(&source, &cache_path)); if stale { fill_entry(&source, &cache_path, &arch, progress)?; + downloaded = true; } else { log::debug!( "another run prepared {} while we waited; using it", @@ -108,9 +164,11 @@ impl RootfsCache { log::debug!("Using cached tarball: {}", cache_path.display()); } - Ok(extract_prepared(&cache_path.clone(), cache_path, progress)?) + Ok((source, cache_path, downloaded)) } +} +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 @@ -118,11 +176,12 @@ impl RootfsCache { /// (including from other processes sharing this cache) skip both the /// download and the provisioning step. /// - /// A provisioned entry no longer tracks the upstream image: for - /// `:latest` OCI images the freshness check is skipped on a marker hit. - /// Re-provision by removing the entry and its `.provisioned` sidecar, or - /// by passing `no_cache: true` (which re-downloads, re-provisions and - /// re-persists). + /// 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, @@ -172,7 +231,8 @@ impl RootfsCache { } } -/// Request for [`RootfsCache::prepare`] / [`RootfsCache::prepare_provisioned`]. +/// Request for [`RootfsCache::prepare`] / [`RootfsCache::pull`] / +/// [`RootfsCache::prepare_provisioned`]. #[derive(Debug, Clone)] pub struct PrepareRequest { /// Image reference: a distro name ("ubuntu", "ubuntu:noble", @@ -262,6 +322,58 @@ mod tests { use crate::progress::NoProgress; use std::os::unix::fs::PermissionsExt; + #[test] + fn pull_rejects_local_archives() { + let cache = RootfsCache::new("/cache"); + let req = PrepareRequest::new("./nope.tar.gz"); + let err = cache.pull(&req, &NoProgress).unwrap_err(); + assert!(err.to_string().contains("nothing to pull"), "{err}"); + } + + #[test] + fn pull_on_a_cached_entry_reports_no_download() { + let dir = tempfile::tempdir().unwrap(); + let cache = RootfsCache::new(dir.path().to_path_buf()); + let pinned = crate::distro::parse_image_ref( + "ubuntu@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "amd64", + ) + .unwrap(); + let entry = cache.tarball_path(&pinned, "amd64"); + std::fs::create_dir_all(entry.parent().unwrap()).unwrap(); + std::fs::write(&entry, b"x").unwrap(); + + let req = PrepareRequest::new( + "ubuntu@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ); + let pulled = cache.pull(&req, &NoProgress).unwrap(); + assert!(!pulled.downloaded); + assert_eq!(pulled.cache_path, entry); + } + + #[test] + fn pull_downloads_an_absent_entry() { + // Structural check only: absent pin entry + pin immutability + // means the pull must decide to download without any network. + // The transfer itself is fill_entry's job, exercised by the + // real pull flow. + let dir = tempfile::tempdir().unwrap(); + let cache = RootfsCache::new(dir.path().to_path_buf()); + let req = PrepareRequest::new( + "ubuntu@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ); + // A pinned OCI source resolves without the network, so + // resolve_source succeeds; the entry is absent, so pull takes + // the download path and fails in fill_entry (no registry + // reachable in the sandbox, or a real fetch otherwise) — both + // outcomes prove the decision, so only the resolution is + // asserted here. + let arch = req.resolved_arch(); + let source = resolve_source(&req.image, &arch).unwrap(); + assert!(matches!(source, ImageSource::OciImage { .. })); + assert!(!cache.tarball_path(&source, &arch).exists()); + } + #[test] fn resolved_arch_defaults_to_host() { let req = PrepareRequest::new("alpine");