feat(rootfs): cache-aware rootfs preparation with persist hook
Add ecr::rootfs with the full rootfs lifecycle behind the library: - RootfsCache::prepare resolves an image reference, downloads through the tarball cache (with the OCI :latest digest freshness check) and extracts into a scratch directory tracked by PreparedRootfs. - PreparedRootfs::persist packs the current rootfs back into its cache entry (compressed to match the entry's extension, symlinks and permissions preserved) and marks it with a .provisioned sidecar. - RootfsCache::prepare_provisioned composes both into the hot-cell flow: provision once, and every later call sharing the cache skips the download and the provisioning step. extract: an "oci-" cache entry without a layers.manifest is a persisted provisioned rootfs; extract it as a plain archive. The CLI now drives prepare and drops its inline cache/orchestration code and the dirs/tempfile dependencies. The binfmt check moves ahead of the download so foreign-arch runs fail before pulling an image.
This commit is contained in:
@@ -21,8 +21,3 @@ clap = { version = "4", features = ["derive", "env"] }
|
||||
|
||||
# Error handling
|
||||
anyhow = "1"
|
||||
|
||||
# Interim: used by the CLI's inline cache/extraction orchestration until it
|
||||
# moves behind the library's rootfs API
|
||||
dirs = "6"
|
||||
tempfile = "3"
|
||||
|
||||
+16
-146
@@ -8,14 +8,9 @@ use clap::Parser;
|
||||
use cli::Args;
|
||||
use ecr::chroot;
|
||||
use ecr::config::Config;
|
||||
use ecr::distro::{
|
||||
map_arch, parse_image_ref, resolve_distro_url, resolve_distro_version, Distro, ImageSource,
|
||||
};
|
||||
use ecr::download::{digest_sidecar, download_image, fetch_oci_digest};
|
||||
use ecr::exec::ExecOptions;
|
||||
use ecr::extract::extract_tarball;
|
||||
use ecr::mount::BindTarget;
|
||||
use ecr::{kernel, qemu, qemu_vm, utils, veprintln, verbose};
|
||||
use ecr::{kernel, qemu, qemu_vm, utils, veprintln, verbose, PrepareRequest, RootfsCache};
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args = Args::parse();
|
||||
@@ -27,91 +22,23 @@ fn main() -> Result<()> {
|
||||
let config = Config::load()?;
|
||||
|
||||
// Get architecture
|
||||
let host_arch = get_host_arch();
|
||||
let host_arch = utils::get_host_arch().debian_name().to_string();
|
||||
let arch = args.arch.clone().unwrap_or_else(|| host_arch.clone());
|
||||
|
||||
// Parse image reference
|
||||
let image_source = parse_image_ref(&args.distro, &arch)?;
|
||||
|
||||
// For DirectTarball, resolve floating aliases ("latest", "lts") to a concrete
|
||||
// version string *before* computing the cache key. This ensures we cache as
|
||||
// e.g. "ubuntu-noble-amd64" rather than "ubuntu-latest-amd64", so a future
|
||||
// release automatically gets its own cache entry.
|
||||
let image_source = match image_source {
|
||||
ImageSource::DirectTarball { distro, version } => {
|
||||
let resolved = resolve_distro_version(&distro, version.as_deref(), &arch)?;
|
||||
ImageSource::DirectTarball {
|
||||
distro,
|
||||
version: Some(resolved),
|
||||
}
|
||||
}
|
||||
other => other,
|
||||
};
|
||||
|
||||
// Determine cache directory and filename
|
||||
let cache_dir = dirs::cache_dir()
|
||||
.expect("Could not determine cache directory")
|
||||
.join("ecr");
|
||||
let cache_filename = generate_cache_filename(&image_source, &arch);
|
||||
let cache_path = cache_dir.join(&cache_filename);
|
||||
|
||||
// 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 = if cache_path.exists() {
|
||||
if let ImageSource::OciImage {
|
||||
registry,
|
||||
repository,
|
||||
tag,
|
||||
..
|
||||
} = &image_source
|
||||
{
|
||||
if tag == "latest" {
|
||||
match fetch_oci_digest(registry, repository, tag) {
|
||||
Ok(current) => {
|
||||
let stored = std::fs::read_to_string(digest_sidecar(&cache_path)).ok();
|
||||
stored.as_deref() != Some(current.trim())
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"Warning: could not check image freshness ({}); using cache",
|
||||
e
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
false // pinned tags are assumed immutable
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false // cache absent — download triggered by !cache_path.exists() below
|
||||
};
|
||||
|
||||
// Download if not cached, --no-cache, or the remote digest has moved
|
||||
if args.no_cache || !cache_path.exists() || oci_digest_changed {
|
||||
std::fs::create_dir_all(&cache_dir)?;
|
||||
download_image(&image_source, &cache_path, &arch)?;
|
||||
} else {
|
||||
veprintln!("Using cached tarball: {}", cache_path.display());
|
||||
}
|
||||
|
||||
// Check QEMU if foreign architecture (for namespace mode)
|
||||
// For VM mode, we don't need binfmt_misc since we're using system emulation
|
||||
// Check QEMU binfmt early for foreign architectures (namespace mode only;
|
||||
// VM mode uses system emulation). Failing here avoids the download.
|
||||
if args.kernel.is_none() && arch != host_arch {
|
||||
qemu::check_binfmt(&arch)?;
|
||||
}
|
||||
|
||||
// Create temp directory for extraction
|
||||
let temp_dir = tempfile::tempdir()?;
|
||||
let rootfs = temp_dir.path().to_path_buf();
|
||||
|
||||
veprintln!("Extracting to: {}", rootfs.display());
|
||||
extract_tarball(&cache_path, &rootfs)?;
|
||||
// Resolve, download (through the cache) and extract the image
|
||||
let cache = RootfsCache::new(RootfsCache::default_dir()?);
|
||||
let request = PrepareRequest {
|
||||
image: args.distro.clone(),
|
||||
arch: args.arch.clone().unwrap_or_default(),
|
||||
no_cache: args.no_cache,
|
||||
};
|
||||
let rootfs = cache.prepare(&request)?;
|
||||
|
||||
// Branch based on --kernel flag
|
||||
// Option<Option<PathBuf>> (require_equals: the value must use =PATH syntax
|
||||
@@ -136,7 +63,7 @@ fn main() -> Result<()> {
|
||||
}
|
||||
None => {
|
||||
veprintln!("QEMU mode: downloading default kernel...");
|
||||
kernel::get_default_kernel(&cache_dir, &arch)?
|
||||
kernel::get_default_kernel(cache.dir(), &arch)?
|
||||
}
|
||||
};
|
||||
|
||||
@@ -148,7 +75,7 @@ fn main() -> Result<()> {
|
||||
|
||||
let result = qemu_vm::launch_qemu(qemu_vm::QemuConfig {
|
||||
kernel_path,
|
||||
rootfs_path: rootfs,
|
||||
rootfs_path: rootfs.dir().to_path_buf(),
|
||||
memory: args.memory.clone(),
|
||||
arch: arch.clone(),
|
||||
command,
|
||||
@@ -162,10 +89,10 @@ fn main() -> Result<()> {
|
||||
result
|
||||
} else {
|
||||
// Namespace/chroot mode
|
||||
let exit_code = namespace_mode(&args, &rootfs, &config, &arch)?;
|
||||
let exit_code = namespace_mode(&args, rootfs.dir(), &config, &arch)?;
|
||||
// Propagate the command's exit code, cleaning up the extracted rootfs
|
||||
// first: process::exit does not run destructors.
|
||||
drop(temp_dir);
|
||||
drop(rootfs);
|
||||
if exit_code != 0 {
|
||||
std::process::exit(exit_code);
|
||||
}
|
||||
@@ -249,60 +176,3 @@ fn cli_mount_target(source: &Path, parent: &str) -> Result<PathBuf> {
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid bind path: {}", source.display()))?;
|
||||
Ok(Path::new("/").join(parent).join(basename))
|
||||
}
|
||||
|
||||
/// Generate a cache filename based on the image source
|
||||
fn generate_cache_filename(source: &ImageSource, arch: &str) -> String {
|
||||
match source {
|
||||
ImageSource::DirectTarball { distro, version } => {
|
||||
let distro_name = match distro {
|
||||
Distro::Ubuntu => "ubuntu",
|
||||
Distro::Alpine => "alpine",
|
||||
};
|
||||
let distro_arch = map_arch(*distro, arch);
|
||||
// Get extension from URL
|
||||
let url = resolve_distro_url(distro, version.as_deref(), arch).unwrap_or_default();
|
||||
let ext = get_tarball_extension(&url);
|
||||
format!(
|
||||
"{}-{}-{}.{}",
|
||||
distro_name,
|
||||
version.as_deref().unwrap_or("latest"),
|
||||
distro_arch,
|
||||
ext
|
||||
)
|
||||
}
|
||||
ImageSource::OciImage {
|
||||
registry,
|
||||
repository,
|
||||
tag,
|
||||
architecture,
|
||||
} => {
|
||||
// Sanitize for filename
|
||||
let safe_registry = registry.replace(['.', ':'], "_");
|
||||
let safe_repo = repository.replace(['/', ':'], "_");
|
||||
format!(
|
||||
"oci-{}-{}-{}-{}.tar.gz",
|
||||
safe_registry, safe_repo, tag, architecture
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_tarball_extension(url: &str) -> &str {
|
||||
// Extract extension from URL (e.g., .tar.gz, .tar.xz, .tar.zst)
|
||||
if url.ends_with(".tar.zst") {
|
||||
"tar.zst"
|
||||
} else if url.ends_with(".tar.xz") {
|
||||
"tar.xz"
|
||||
} else if url.ends_with(".tar.gz") {
|
||||
"tar.gz"
|
||||
} else if url.ends_with(".tar.bz2") {
|
||||
"tar.bz2"
|
||||
} else {
|
||||
"tar.gz" // default
|
||||
}
|
||||
}
|
||||
|
||||
fn get_host_arch() -> String {
|
||||
// Use the consolidated architecture detection from utils
|
||||
utils::get_host_arch().debian_name().to_string()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user