fix(rootfs): boot a cached distro entry for floating aliases

Resolution ran before the cache was consulted, so every run of
alpine:3.23, ubuntu or any floating alias hit the CDN or
changelogs.ubuntu.com, and an offline run of a cached image died in
resolution instead of booting. The boot path now asks the cache
first: an alias boots the newest cached entry of its distro, within
the series or the edge flavor it floats in, and resolves upstream
only on a miss; pull and --no-cache still resolve upstream.

Exactly-named references (Ubuntu YY.MM, known codename, Alpine X.Y.Z)
resolve offline on every path; resolve_source folds into the policy,
which lives with the cache it consults.
This commit is contained in:
vhaudiquet committed 2026-10-30 23:12:50 +01:00
1 parent dfc8cf88e7
commit dd4139ce65
3 files changed
+244 -35

No files matched your search

+14 -8
View File
@@ -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.
+26 -18
View File
@@ -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<ImageSource> {
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<String>
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<String> {
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.
+204 -9
View File
@@ -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<ImageSource> {
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<String> {
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<u64>, 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<u64> = 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<PreparedRootfs> {
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");