diff --git a/SPEC.md b/SPEC.md index b572b6f..76ed9c9 100644 --- a/SPEC.md +++ b/SPEC.md @@ -243,9 +243,12 @@ for narration, `Progress` for long operations. ### Rootfs lifecycle (ported) `RootfsCache::new(dir)` / `default_dir()`, `prepare`, -`prepare_provisioned`, `persist`, `PrepareRequest`, `PreparedRootfs`: -ecr's semantics verbatim, including digest freshness on `:latest` and -the `.provisioned` hot-cell marker. The per-run scratch copy is +`prepare_provisioned`, `pull`, `persist`, `PrepareRequest`, +`PreparedRootfs`, `Pulled`: `prepare` is the boot path — a present +entry is trusted, the registry is never contacted on a hit, a cold +cache downloads. `pull` is the explicit freshness check (unpinned +tags re-verified, moved digests re-downloaded, nothing extracted). +Plus the `.provisioned` hot-cell marker. The per-run scratch copy is `PreparedRootfs` as ecr already builds it. See ecr's SPEC for the reference description; differences are the cache directory and the absent kernel entries only. @@ -296,9 +299,18 @@ composable. A boot failure leaves the files absent. ``` ecrou run [OPTIONS] [-- COMMAND...] ecrou provision [OPTIONS] [-- COMMAND...] +ecrou pull [OPTIONS] ecrou probe ``` +`pull` fetches or refreshes the image's cache entry without booting +it: it re-resolves floating references (distro aliases like +`latest`/`lts`/`edge`, unpinned OCI tags against the registry +digest) and downloads only when the entry is absent or has moved. +It is the freshness operation — see the trust model below for how +`run` and `pull` divide the work. Local archives are rejected: +a file on disk has nothing to fetch. + The image is a distro name (`alpine`, `ubuntu:noble`), an OCI reference (`ghcr.io/org/image:tag`, `ubuntu@sha256:…`, `repo:tag@sha256:…` — the digest wins over a co-present tag, as in @@ -395,11 +407,17 @@ stop: publishes next to them. When the checksum artifact cannot be fetched 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. Once written, an entry is never - re-verified, except OCI `:latest` tags, which are re-checked against - the registry digest on every run. Digest pins, pinned tags and - distro tarballs are assumed immutable; `--no-cache` and the - digest-changed path are the escape hatches. +- 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 + `pull` (the CLI command and the library's `RootfsCache::pull`): + it re-checks unpinned OCI tags 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. - 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-cli/src/cli.rs b/crates/ecrou-cli/src/cli.rs index 77aeb78..2395109 100644 --- a/crates/ecrou-cli/src/cli.rs +++ b/crates/ecrou-cli/src/cli.rs @@ -49,6 +49,19 @@ pub enum Command { #[arg(last = true)] command: Vec, }, + /// Fetch or refresh an image's cache entry without booting it: + /// re-checks unpinned OCI tags against the registry and floating + /// distro aliases against the distro, downloading only when the + /// entry is absent or has moved. `run` never contacts the + /// registry on a cached image — this is the refresh operation. + Pull { + /// Distribution name or OCI image reference + image: String, + /// Ignore the cache: re-download even when the entry is + /// present and current + #[arg(long)] + no_cache: bool, + }, /// Report what a run would find on this host Probe, } diff --git a/crates/ecrou-cli/src/main.rs b/crates/ecrou-cli/src/main.rs index 22aac5e..added28 100644 --- a/crates/ecrou-cli/src/main.rs +++ b/crates/ecrou-cli/src/main.rs @@ -7,6 +7,8 @@ mod logger; mod progress; use clap::Parser; +use ecrou::cache::RootfsCache; +use ecrou::rootfs::PrepareRequest; use ecrou::vm::RunOptions; use ecrou::{LogLevel, NoProgress, Progress}; @@ -25,6 +27,7 @@ fn main() -> std::process::ExitCode { no_cache, command, } => cmd_provision(image, spec, layer, no_cache, &command, cli.verbose), + Command::Pull { image, no_cache } => cmd_pull(&image, no_cache, cli.verbose), Command::Probe => cmd_probe(), } } @@ -220,6 +223,32 @@ fn cmd_run(args: &RunArgs, cli_verbose: bool) -> std::process::ExitCode { } } +/// Pull an image into the cache without booting it. +fn cmd_pull(image: &str, no_cache: bool, _cli_verbose: bool) -> std::process::ExitCode { + let progress: &dyn Progress = &TermProgress::new(); + let dir = match RootfsCache::default_dir() { + Ok(dir) => dir, + Err(err) => return fail(&anyhow::Error::new(err)), + }; + let cache = RootfsCache::new(dir); + + let mut request = PrepareRequest::new(image.to_owned()); + request.no_cache = no_cache; + + match cache.pull(&request, progress) { + Ok(pulled) => { + if pulled.downloaded { + println!("pulled {image}"); + } else { + println!("{image} is up to date"); + } + println!(" {}", pulled.cache_path.display()); + std::process::ExitCode::SUCCESS + } + Err(err) => fail(&anyhow::Error::new(err)), + } +} + fn cmd_probe() -> std::process::ExitCode { use ecrou::krun;