diff --git a/SPEC.md b/SPEC.md index a7a0f76..c2eb5ce 100644 --- a/SPEC.md +++ b/SPEC.md @@ -220,16 +220,22 @@ stop: the download proceeds unverified with a warning instead of hard-failing. That is a deliberate trade, not an oversight. - The cache trusts its own entries, and `run` never contacts a - registry on a cache hit: a present entry is booted as-is, the way - `docker run` uses the local image. The freshness operation is + registry or a distro CDN on a cache hit: a present entry is booted + as-is, the way `docker run` uses the local image. A reference that + names one entry exactly — an Ubuntu YY.MM, a known codename, a full + Alpine X.Y.Z — resolves offline; a floating alias (`latest`, `lts`, + `edge`, an Alpine `X.Y` series) boots the newest cached entry for + the distro, matching the series or the edge flavor it floats in, + and needs the network only on a miss. The freshness operation is `pull` (the CLI command and the library's `RootfsCache::pull`): - it re-checks unpinned OCI tags against the registry digest and + it re-checks every unpinned OCI tag against the registry digest and re-resolves floating distro aliases, downloading only what moved — - `docker pull` semantics. Digest pins, pinned tags and numbered - distro tarballs are immutable to both; on a network error `pull` - keeps the cached image with a warning. `--no-cache` is the - force-redownload escape hatch. The trade: a stale `:latest` boots - until someone pulls, exactly as in docker. + `docker pull` semantics. Digest pins and numbered distro tarballs + are immutable to both; on a network error `pull` keeps the cached + image with a warning. `--no-cache` is the force-redownload escape + hatch, and it resolves upstream like a pull. The trade: a stale + `:latest` or a stale alias boots until someone pulls, exactly as in + docker. - Concurrent runs of the same image are safe, not efficient: each writes its own partial file and the last atomic rename wins, so two racers download twice rather than corrupt anything. diff --git a/crates/ecrou/src/distro.rs b/crates/ecrou/src/distro.rs index d8a3249..8ccff06 100644 --- a/crates/ecrou/src/distro.rs +++ b/crates/ecrou/src/distro.rs @@ -47,24 +47,6 @@ impl Distro { } } -/// Parse an image reference and resolve floating version aliases ("latest", -/// "lts", "edge", …) to a concrete version, so the cache key is stable -/// (e.g. "ubuntu-noble-amd64", not "ubuntu-latest-amd64") and a future -/// release automatically gets its own cache entry. -pub(crate) fn resolve_source(image: &str, arch: &str) -> anyhow::Result { - let source = parse_image_ref(image, arch)?; - match source { - ImageSource::DirectTarball { distro, version } => { - let resolved = resolve_distro_version(&distro, version.as_deref(), arch)?; - Ok(ImageSource::DirectTarball { - distro, - version: Some(resolved), - }) - } - other => Ok(other), - } -} - /// Parse image reference and return the appropriate source /// Supports: /// - Simple distro names: ubuntu, debian, alpine @@ -460,6 +442,32 @@ fn fetch_alpine_minor_version(minor: &str, arch: &str) -> anyhow::Result fetch_alpine_version_from_branch(&format!("v{}", minor), arch) } +/// The version a reference resolves to without touching the network, +/// when it names one entry exactly: an Ubuntu YY.MM number, a codename +/// this build's table knows, or a full Alpine X.Y.Z. Floating +/// references — "latest", "lts", "edge", an Alpine "X.Y" series — +/// return None and need the network or the cache to answer. +pub(crate) fn offline_resolved_version(distro: &Distro, version: Option<&str>) -> Option { + let v = version.unwrap_or("latest"); + match distro { + Distro::Ubuntu => { + if is_ubuntu_version_number(v) { + Some(normalise_ubuntu_number(v)) + } else { + UBUNTU_CODENAMES + .iter() + .find(|(name, _)| *name == v.to_lowercase()) + .map(|(_, number)| number.to_string()) + } + } + Distro::Alpine => { + // "3.23.1" is one tarball; "3.23" floats to the current + // patch release, and "latest"/"edge" float entirely. + (v.matches('.').count() == 2).then(|| v.to_string()) + } + } +} + /// Resolve the canonical version string for a distro, performing a network lookup /// only when the requested version is a floating alias (e.g. "latest", "lts"). /// The returned string is suitable for use as a stable cache key. diff --git a/crates/ecrou/src/rootfs.rs b/crates/ecrou/src/rootfs.rs index 540863f..2d4023b 100644 --- a/crates/ecrou/src/rootfs.rs +++ b/crates/ecrou/src/rootfs.rs @@ -9,8 +9,10 @@ //! will, which a virtiofs share owned by the host user never allowed. //! //! 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`]: +//! trusted as-is — the registry and the distro CDNs are never +//! contacted on a hit; a floating alias boots the newest cached entry +//! for the distro — and the freshness decision belongs to +//! [`RootfsCache::pull`]: //! //! ```no_run //! use ecrou::{NoProgress, PrepareRequest, RootfsCache}; @@ -28,7 +30,10 @@ use std::path::{Path, PathBuf}; use anyhow::{anyhow, Context}; use crate::cache::{oci_freshness_changed, EntryLock, RootfsCache}; -use crate::distro::{resolve_source, ImageSource}; +use crate::distro::{ + map_arch, offline_resolved_version, parse_image_ref, resolve_distro_version, Distro, + ImageSource, +}; use crate::download::download_image; use crate::error::Error; use crate::progress::Progress; @@ -45,6 +50,123 @@ pub struct Pulled { pub downloaded: bool, } +/// Resolution with the cache in the loop. A reference that names one +/// entry exactly — an Ubuntu YY.MM number or known codename, a full +/// Alpine X.Y.Z — resolves offline on every path. A floating +/// reference ("latest", "lts", "edge", an Alpine "X.Y" series) needs +/// the network or the cache to answer: `pull` and `--no-cache` +/// resolve upstream, because freshness is the point, while +/// `prepare`/`run` boot the newest cached entry for the distro and +/// resolve only when nothing cached matches — `docker run` uses the +/// local image, and a stale alias boots until someone pulls. +fn resolve_for_request( + req: &PrepareRequest, + cache_dir: &Path, + recheck: bool, +) -> Result { + let arch = req.resolved_arch(); + let parsed = parse_image_ref(&req.image, &arch)?; + let ImageSource::DirectTarball { distro, version } = parsed else { + return Ok(parsed); // OCI references and local files never resolve aliases + }; + + if let Some(resolved) = offline_resolved_version(&distro, version.as_deref()) { + return Ok(ImageSource::DirectTarball { + distro, + version: Some(resolved), + }); + } + + // Floating reference: `pull` and `--no-cache` ask upstream. + if recheck || req.no_cache { + return resolve_distro_version(&distro, version.as_deref(), &arch).map(|resolved| { + ImageSource::DirectTarball { + distro, + version: Some(resolved), + } + }); + } + + // The boot path consults the cache first: no network on a hit. + let alias = version.as_deref().unwrap_or("latest"); + if let Some(resolved) = newest_cached_version(cache_dir, &distro, &arch, alias) { + log::debug!( + "floating reference {alias:?} boots the newest cached entry ({resolved}); \ + `pull` re-resolves it" + ); + return Ok(ImageSource::DirectTarball { + distro, + version: Some(resolved), + }); + } + + // Nothing cached: resolution has to answer, and a network error + // is then the honest failure — there is nothing local to boot. + resolve_distro_version(&distro, version.as_deref(), &arch) + .map(|resolved| ImageSource::DirectTarball { + distro, + version: Some(resolved), + }) + .map_err(|e| { + Error::Other(anyhow::Error::from(e).context( + "no cached entry for this floating reference; resolving it needs the network", + )) + }) +} + +/// The newest cached entry a floating distro alias can boot, read +/// straight from entry names (`{distro}-{version}-{arch}.tar.gz`). +/// An Alpine series alias ("3.23") floats only within its series; +/// "edge" floats among date-stamped versions ("20250401"); every +/// other alias floats across the distro's dotted releases. +fn newest_cached_version( + cache_dir: &Path, + distro: &Distro, + arch: &str, + alias: &str, +) -> Option { + let distro_name = match distro { + Distro::Ubuntu => "ubuntu", + Distro::Alpine => "alpine", + }; + let series = alias.matches('.').count() == 1; + let prefix = format!("{distro_name}-"); + let suffix = format!("-{}.tar.gz", map_arch(*distro, arch)); + let mut best: Option<(Vec, String)> = None; + for entry in std::fs::read_dir(cache_dir).ok()?.flatten() { + let name = entry.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + let Some(version) = name + .strip_prefix(&prefix) + .and_then(|n| n.strip_suffix(&suffix)) + else { + continue; + }; + // A version is dots and digits only. That shape, the distro + // prefix and the arch suffix together keep sidecars and + // foreign entries out. + let dotted = version.contains('.'); + if version.is_empty() + || dotted == (alias == "edge") + || !version.chars().all(|c| c.is_ascii_digit() || c == '.') + || (series && !version.starts_with(&format!("{alias}."))) + { + continue; + } + let rank: Vec = version.split('.').filter_map(|p| p.parse().ok()).collect(); + let better = match &best { + Some((top, _)) => rank > *top, + None => true, + }; + if better { + best = Some((rank, version.to_string())); + } + } + best.map(|(_, version)| version) +} + impl RootfsCache { /// Resolve `req`, download through the cache when needed, and copy /// the entry image into a fresh scratch directory (removed when the @@ -52,9 +174,14 @@ impl RootfsCache { /// 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. + /// is trusted as-is and neither a registry nor a distro CDN is + /// contacted. A reference that names one entry exactly — an Ubuntu + /// YY.MM, a known codename, a full Alpine X.Y.Z — resolves + /// offline; a floating alias ("latest", "lts", "edge", an Alpine + /// "X.Y" series) boots the newest cached entry for the distro. + /// 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 (source, cache_path, _) = self.ensure_entry(req, progress, false)?; let scratch = tempfile::tempdir().context("Failed to create temporary directory")?; @@ -142,7 +269,7 @@ impl RootfsCache { recheck: bool, ) -> Result<(ImageSource, PathBuf, bool)> { let arch = req.resolved_arch(); - let source = resolve_source(&req.image, &arch)?; + let source = resolve_for_request(req, &self.dir, recheck)?; if let ImageSource::LocalTarball { path } = &source { if recheck { @@ -351,17 +478,85 @@ mod tests { "ubuntu@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", ); // A pinned OCI source resolves without the network, so - // resolve_source succeeds; the entry is absent, so pull takes + // parse_image_ref 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(); + let source = parse_image_ref(&req.image, &arch).unwrap(); assert!(matches!(source, ImageSource::OciImage { .. })); assert!(!cache.tarball_path(&source, &arch).exists()); } + fn touch_entry(cache: &RootfsCache, name: &str) { + let path = cache.dir().join(name); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, b"placeholder").unwrap(); + } + + #[test] + fn a_floating_alias_boots_the_newest_cached_entry_without_the_network() { + let dir = tempfile::tempdir().unwrap(); + let cache = RootfsCache::new(dir.path().to_path_buf()); + touch_entry(&cache, "alpine-3.23.0-x86_64.tar.gz"); + touch_entry(&cache, "alpine-3.22.9-x86_64.tar.gz"); + + let req = PrepareRequest::new("alpine:3.23"); + let (source, path, downloaded) = cache.ensure_entry(&req, &NoProgress, false).unwrap(); + assert!(!downloaded); + assert_eq!(path, dir.path().join("alpine-3.23.0-x86_64.tar.gz")); + let ImageSource::DirectTarball { version, .. } = source else { + panic!("expected a direct tarball"); + }; + assert_eq!(version.as_deref(), Some("3.23.0")); + } + + #[test] + fn a_series_alias_floats_only_within_its_series() { + let dir = tempfile::tempdir().unwrap(); + let cache = RootfsCache::new(dir.path().to_path_buf()); + touch_entry(&cache, "alpine-3.20.3-x86_64.tar.gz"); + touch_entry(&cache, "alpine-3.23.0-x86_64.tar.gz"); + + let req = PrepareRequest::new("alpine:3.23"); + let (_, path, _) = cache.ensure_entry(&req, &NoProgress, false).unwrap(); + assert_eq!(path, dir.path().join("alpine-3.23.0-x86_64.tar.gz")); + } + + #[test] + fn an_edge_alias_floats_among_date_stamped_entries_only() { + let dir = tempfile::tempdir().unwrap(); + let cache = RootfsCache::new(dir.path().to_path_buf()); + touch_entry(&cache, "alpine-20250401-x86_64.tar.gz"); + touch_entry(&cache, "alpine-3.23.0-x86_64.tar.gz"); + + let req = PrepareRequest::new("alpine:edge"); + let (_, path, _) = cache.ensure_entry(&req, &NoProgress, false).unwrap(); + assert_eq!(path, dir.path().join("alpine-20250401-x86_64.tar.gz")); + + // Plain "alpine" floats among releases; the edge date is + // invisible to it. + let req = PrepareRequest::new("alpine"); + let (_, path, _) = cache.ensure_entry(&req, &NoProgress, false).unwrap(); + assert_eq!(path, dir.path().join("alpine-3.23.0-x86_64.tar.gz")); + } + + #[test] + fn a_floating_ubuntu_alias_picks_the_newest_cached_release() { + let dir = tempfile::tempdir().unwrap(); + let cache = RootfsCache::new(dir.path().to_path_buf()); + touch_entry(&cache, "ubuntu-24.04-amd64.tar.gz"); + touch_entry(&cache, "ubuntu-26.04-amd64.tar.gz"); + + let req = PrepareRequest::new("ubuntu:lts"); + let (source, _, _) = cache.ensure_entry(&req, &NoProgress, false).unwrap(); + let ImageSource::DirectTarball { version, .. } = source else { + panic!("expected a direct tarball"); + }; + assert_eq!(version.as_deref(), Some("26.04")); + } + #[test] fn resolved_arch_defaults_to_host() { let req = PrepareRequest::new("alpine");