mirror of
https://github.com/vhaudiquet/ecrou.git
synced 2026-11-12 22:22:44 +00:00
feat(rootfs): pull as the explicit freshness operation
prepare was a freshness hybrid: runs of an unpinned OCI tag contacted the registry on every boot, and only ":latest" was checked - :main floated in name only. Docker's split is cleaner, so it is the model now: prepare (and run) trust a present entry and never touch the network on a hit - a cold cache pulls by default, since there is nothing to boot otherwise. RootfsCache::pull is the refresh operation: re-check any unpinned tag against the registry digest, re-resolve floating distro aliases, download only what moved, extract nothing. Digest pins stay immutable to both. The trade, as in docker: a stale :latest boots until someone pulls. Local archives are not pullable - the error says so.
This commit is contained in:
1 parent
408774cbd1
commit
12a417645d
2 files changed
+132
-20
No files matched your search
@@ -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};
|
||||
+131
-19
@@ -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<PreparedRootfs> {
|
||||
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<Pulled> {
|
||||
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<F>(
|
||||
&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");
|
||||
|
||||
Reference in new issue
Block a user