feat(cli): add ecrou pull

Fetch or refresh an image's cache entry without booting it. The
command is the CLI face of the run/pull split: runs never contact a
registry on a cached image, pull does - reporting the entry path
and whether anything transferred. --no-cache forces a redownload.
SPEC documents the command and the run/pull trust model.
This commit is contained in:
vhaudiquet committed 2026-10-30 01:04:24 +01:00
1 parent 12a417645d
commit b4dafccae7
3 files changed
+68 -8

No files matched your search

+26 -8
View File
@@ -243,9 +243,12 @@ for narration, `Progress` for long operations.
### Rootfs lifecycle (ported) ### Rootfs lifecycle (ported)
`RootfsCache::new(dir)` / `default_dir()`, `prepare`, `RootfsCache::new(dir)` / `default_dir()`, `prepare`,
`prepare_provisioned`, `persist`, `PrepareRequest`, `PreparedRootfs`: `prepare_provisioned`, `pull`, `persist`, `PrepareRequest`,
ecr's semantics verbatim, including digest freshness on `:latest` and `PreparedRootfs`, `Pulled`: `prepare` is the boot path — a present
the `.provisioned` hot-cell marker. The per-run scratch copy is 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 `PreparedRootfs` as ecr already builds it. See ecr's SPEC for the
reference description; differences are the cache directory and the reference description; differences are the cache directory and the
absent kernel entries only. absent kernel entries only.
@@ -296,9 +299,18 @@ composable. A boot failure leaves the files absent.
``` ```
ecrou run [OPTIONS] <IMAGE> [-- COMMAND...] ecrou run [OPTIONS] <IMAGE> [-- COMMAND...]
ecrou provision [OPTIONS] <IMAGE> [-- COMMAND...] ecrou provision [OPTIONS] <IMAGE> [-- COMMAND...]
ecrou pull [OPTIONS] <IMAGE>
ecrou probe 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 The image is a distro name (`alpine`, `ubuntu:noble`), an OCI
reference (`ghcr.io/org/image:tag`, `ubuntu@sha256:…`, reference (`ghcr.io/org/image:tag`, `ubuntu@sha256:…`,
`repo:tag@sha256:…` — the digest wins over a co-present tag, as in `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 publishes next to them. When the checksum artifact cannot be fetched
the download proceeds unverified with a warning instead of the download proceeds unverified with a warning instead of
hard-failing. That is a deliberate trade, not an oversight. hard-failing. That is a deliberate trade, not an oversight.
- The cache trusts its own entries. Once written, an entry is never - The cache trusts its own entries, and `run` never contacts a
re-verified, except OCI `:latest` tags, which are re-checked against registry on a cache hit: a present entry is booted as-is, the way
the registry digest on every run. Digest pins, pinned tags and `docker run` uses the local image. The freshness operation is
distro tarballs are assumed immutable; `--no-cache` and the `pull` (the CLI command and the library's `RootfsCache::pull`):
digest-changed path are the escape hatches. 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 - Concurrent runs of the same image are safe, not efficient: each
writes its own partial file and the last atomic rename wins, so two writes its own partial file and the last atomic rename wins, so two
racers download twice rather than corrupt anything. racers download twice rather than corrupt anything.
+13
View File
@@ -49,6 +49,19 @@ pub enum Command {
#[arg(last = true)] #[arg(last = true)]
command: Vec<String>, command: Vec<String>,
}, },
/// 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 /// Report what a run would find on this host
Probe, Probe,
} }
+29
View File
@@ -7,6 +7,8 @@ mod logger;
mod progress; mod progress;
use clap::Parser; use clap::Parser;
use ecrou::cache::RootfsCache;
use ecrou::rootfs::PrepareRequest;
use ecrou::vm::RunOptions; use ecrou::vm::RunOptions;
use ecrou::{LogLevel, NoProgress, Progress}; use ecrou::{LogLevel, NoProgress, Progress};
@@ -25,6 +27,7 @@ fn main() -> std::process::ExitCode {
no_cache, no_cache,
command, command,
} => cmd_provision(image, spec, layer, no_cache, &command, cli.verbose), } => 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(), 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 { fn cmd_probe() -> std::process::ExitCode {
use ecrou::krun; use ecrou::krun;