mirror of
https://github.com/vhaudiquet/ecrou.git
synced 2026-11-12 22:22:44 +00:00
refactor(rootfs): split the entry cache into cache.rs
The cache stopped being an implementation detail of the rootfs lifecycle: entry naming, the advisory lock, digest freshness, provisioned-layer pointers, and extraction-to-scratch are a store in their own right. cache.rs now owns RootfsCache's paths, pointers and extraction products; rootfs.rs keeps the lifecycle API (PrepareRequest, PreparedRootfs, prepare, prepare_provisioned). resolve_source moves next to its inputs in distro. Pure movement - naming still derives its extension from the URL, next commit changes that.
This commit is contained in:
1 parent
52d734063f
commit
479dfb79d0
6 files changed
+657
-627
No files matched your search
@@ -37,7 +37,8 @@ fn cmd_provision(
|
||||
command: &[String],
|
||||
cli_verbose: bool,
|
||||
) -> std::process::ExitCode {
|
||||
use ecrou::rootfs::{PrepareRequest, RootfsCache};
|
||||
use ecrou::cache::RootfsCache;
|
||||
use ecrou::rootfs::PrepareRequest;
|
||||
use ecrou::spec::ProvisionSpec;
|
||||
|
||||
// Two shapes: `provision IMAGE -- COMMAND` bakes a one-off
|
||||
@@ -251,7 +252,7 @@ fn cmd_probe() -> std::process::ExitCode {
|
||||
}
|
||||
|
||||
print!("cache: ");
|
||||
match ecrou::rootfs::RootfsCache::default_dir() {
|
||||
match ecrou::cache::RootfsCache::default_dir() {
|
||||
Ok(dir) => {
|
||||
// The run flow creates the cache on demand; probe mirrors it.
|
||||
if let Err(err) = std::fs::create_dir_all(&dir) {
|
||||
|
||||
@@ -0,0 +1,623 @@
|
||||
//! The rootfs tarball cache: entries, freshness, and provisioned layers.
|
||||
//!
|
||||
//! One directory of content-addressed-ish entries: every downloaded or
|
||||
//! provisioned rootfs lives under a name derived from its source, an
|
||||
//! advisory lock keeps concurrent runs from downloading the same image
|
||||
//! twice, a digest sidecar lets floating tags be re-checked cheaply,
|
||||
//! and layer pointers name provisioned entries so runs can boot them
|
||||
//! without re-deriving keys. The lifecycle that drives it — prepare,
|
||||
//! provision, persist — lives in [`crate::rootfs`].
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{anyhow, Context};
|
||||
|
||||
use crate::distro::{map_arch, resolve_distro_url, resolve_source, Distro, ImageSource};
|
||||
use crate::download::{digest_sidecar, download_image};
|
||||
use crate::error::Error;
|
||||
use crate::extract::extract_tarball;
|
||||
use crate::progress::Progress;
|
||||
use crate::registry::fetch_oci_digest;
|
||||
use crate::rootfs::PreparedRootfs;
|
||||
use crate::Result;
|
||||
|
||||
/// Sidecar suffix marking a cache entry that holds a provisioned rootfs.
|
||||
pub(crate) const PROVISIONED_SUFFIX: &str = "provisioned";
|
||||
|
||||
/// Download `source` into the cache at `cache_path`, and drop any
|
||||
/// provisioned marker: whatever was provisioned belonged to the entry
|
||||
/// this download replaces, and a stale marker would present the
|
||||
/// pristine image as provisioned and skip the next provisioning for
|
||||
/// good.
|
||||
pub(crate) fn fill_entry(
|
||||
source: &ImageSource,
|
||||
cache_path: &Path,
|
||||
arch: &str,
|
||||
progress: &dyn Progress,
|
||||
) -> Result<()> {
|
||||
download_image(source, cache_path, arch, progress)?;
|
||||
let marker = sidecar(cache_path, PROVISIONED_SUFFIX);
|
||||
if marker.exists() {
|
||||
std::fs::remove_file(&marker).with_context(|| {
|
||||
format!(
|
||||
"Failed to drop the stale provisioned marker {}",
|
||||
marker.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether an OCI image with a floating tag (":latest") has moved since
|
||||
/// it was cached: fetch the current manifest digest from the registry
|
||||
/// and compare it against the digest stored from the last download.
|
||||
/// Pinned tags are assumed immutable; a network error keeps the cache
|
||||
/// rather than hard-failing; anything else is simply current.
|
||||
pub(crate) fn oci_freshness_changed(source: &ImageSource, cache_path: &Path) -> bool {
|
||||
if !cache_path.exists() {
|
||||
return false; // cache absent — the missing-file check triggers the download
|
||||
}
|
||||
let ImageSource::OciImage {
|
||||
registry,
|
||||
repository,
|
||||
tag,
|
||||
digest,
|
||||
..
|
||||
} = source
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
// A digest pin is immutable by definition; so is any non-latest tag.
|
||||
if digest.is_some() || tag != "latest" {
|
||||
return false;
|
||||
}
|
||||
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) => {
|
||||
log::warn!("could not check image freshness ({}); using cache", e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An exclusive advisory lock on one cache entry, held while a run
|
||||
/// downloads it: a second run preparing the same image blocks here and
|
||||
/// then reuses the winner's entry instead of downloading a duplicate
|
||||
/// the atomic rename would throw away. The lock file is a permanent
|
||||
/// zero-byte sidecar; flock releases when the holder exits, crashed or
|
||||
/// not.
|
||||
pub(crate) struct EntryLock {
|
||||
_file: std::fs::File,
|
||||
}
|
||||
|
||||
impl EntryLock {
|
||||
/// Block until the entry is free. `no_cache` callers skip the
|
||||
/// lock entirely: an explicit fresh download never waits.
|
||||
pub(crate) fn acquire(entry: &Path) -> anyhow::Result<Self> {
|
||||
use std::os::fd::AsRawFd;
|
||||
|
||||
let lock_path = entry.with_extension("lock");
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.write(true)
|
||||
.read(true)
|
||||
.open(&lock_path)
|
||||
.with_context(|| format!("Failed to open the entry lock {}", lock_path.display()))?;
|
||||
|
||||
let held = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
|
||||
if held != 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
if err.kind() != std::io::ErrorKind::WouldBlock {
|
||||
return Err(
|
||||
anyhow::Error::from(err).context(format!("locking {}", lock_path.display()))
|
||||
);
|
||||
}
|
||||
log::warn!(
|
||||
"waiting for another ecrou run to finish preparing {}",
|
||||
entry.display()
|
||||
);
|
||||
let held = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) };
|
||||
if held != 0 {
|
||||
return Err(anyhow::Error::from(std::io::Error::last_os_error())
|
||||
.context(format!("locking {}", lock_path.display())));
|
||||
}
|
||||
}
|
||||
Ok(EntryLock { _file: file })
|
||||
}
|
||||
}
|
||||
|
||||
/// A layer name is a file name under the cache's `layers/` directory
|
||||
/// and a component of entry file names: keep it to a safe charset so a
|
||||
/// name like `../x` or `a/b` cannot climb out of the cache directory.
|
||||
fn validate_layer_name(name: &str) -> Result<()> {
|
||||
let ok = !name.is_empty()
|
||||
&& name != "."
|
||||
&& name != ".."
|
||||
&& name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
|
||||
&& !name.starts_with('.');
|
||||
if ok {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::Other(anyhow!(
|
||||
"invalid layer name {name:?}: use letters, digits, '_' or '-', \
|
||||
not starting with '.'"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Sidecar path for a cache entry: `foo.tar.gz` → `foo.tar.gz.<suffix>`.
|
||||
pub(crate) fn sidecar(path: &Path, suffix: &str) -> PathBuf {
|
||||
let mut s = path.as_os_str().to_owned();
|
||||
s.push(".");
|
||||
s.push(suffix);
|
||||
PathBuf::from(s)
|
||||
}
|
||||
|
||||
/// The image tarball cache (default location: `~/.cache/ecrou`).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RootfsCache {
|
||||
pub(crate) dir: PathBuf,
|
||||
}
|
||||
|
||||
impl RootfsCache {
|
||||
pub fn new(dir: impl Into<PathBuf>) -> Self {
|
||||
RootfsCache { dir: dir.into() }
|
||||
}
|
||||
|
||||
/// Default cache directory: `~/.cache/ecrou` (or the platform equivalent).
|
||||
pub fn default_dir() -> Result<PathBuf> {
|
||||
dirs::cache_dir()
|
||||
.map(|p| p.join("ecrou"))
|
||||
.ok_or_else(|| anyhow!("Could not determine cache directory").into())
|
||||
}
|
||||
|
||||
pub fn dir(&self) -> &Path {
|
||||
&self.dir
|
||||
}
|
||||
|
||||
/// Cache entry (tarball path) for an image source and architecture.
|
||||
pub fn tarball_path(&self, source: &ImageSource, arch: &str) -> PathBuf {
|
||||
self.dir.join(generate_cache_filename(source, arch))
|
||||
}
|
||||
|
||||
/// The cache path for a prepared entry, keyed or not: a layer key
|
||||
/// is spliced into the filename, so a layer and its base — and two
|
||||
/// layers of one base — live in separate entries, never overwrite
|
||||
/// each other, and evict separately. The key is validated here, at
|
||||
/// the splice point, so no caller can derive a path outside the
|
||||
/// cache directory.
|
||||
pub fn entry_path(
|
||||
&self,
|
||||
source: &ImageSource,
|
||||
arch: &str,
|
||||
layer_key: Option<&str>,
|
||||
) -> Result<PathBuf> {
|
||||
match layer_key {
|
||||
None => Ok(self.tarball_path(source, arch)),
|
||||
Some(key) => {
|
||||
validate_layer_name(key)?;
|
||||
let filename = generate_cache_filename(source, arch);
|
||||
let stem = filename
|
||||
.rsplit_once('.')
|
||||
.map(|(stem, _)| stem)
|
||||
.unwrap_or(&filename);
|
||||
Ok(self.dir.join(format!("{stem}.{key}.tar.gz")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts a named layer into a fresh scratch directory: the boot
|
||||
/// side of `run --layer`. A missing pointer or a pruned entry is a
|
||||
/// named error, not a download — layers are provisioned, never
|
||||
/// fetched.
|
||||
pub fn extract_layer(&self, name: &str, progress: &dyn Progress) -> Result<PreparedRootfs> {
|
||||
validate_layer_name(name)?;
|
||||
let path = self.layer_entry(name)?.ok_or_else(|| {
|
||||
Error::Other(anyhow::anyhow!(
|
||||
"layer {name} is not provisioned: run \
|
||||
`ecrou provision --spec <spec> --layer {name}` first"
|
||||
))
|
||||
})?;
|
||||
extract_prepared(&path.clone(), path, progress).map_err(Error::Other)
|
||||
}
|
||||
|
||||
/// The cache path an image resolves to, keyed or not — the same
|
||||
/// resolution `prepare` does (floating aliases resolved), so a
|
||||
/// pointer written from this path names an entry that exists.
|
||||
pub fn resolved_entry_path(
|
||||
&self,
|
||||
image: &str,
|
||||
arch: &str,
|
||||
layer_key: Option<&str>,
|
||||
) -> Result<PathBuf> {
|
||||
let source = resolve_source(image, arch)?;
|
||||
self.entry_path(&source, arch, layer_key)
|
||||
}
|
||||
|
||||
/// Writes the layer pointer: `layers/<name>` holds the cache
|
||||
/// filename the name resolves to. Consumers boot layers by name —
|
||||
/// `run` should not have to re-derive the key from the spec, only
|
||||
/// agree with the provisioning call on what the layer is called.
|
||||
pub fn write_layer_pointer(&self, name: &str, cache_filename: &str) -> Result<()> {
|
||||
validate_layer_name(name)?;
|
||||
let dir = self.dir.join("layers");
|
||||
std::fs::create_dir_all(&dir)
|
||||
.with_context(|| format!("Failed to create the layer directory {}", dir.display()))?;
|
||||
// Atomic write: a concurrent run reading the pointer must
|
||||
// never see a truncated filename mid-write.
|
||||
let pointer = dir.join(name);
|
||||
let partial = crate::utils::partial_path(&pointer);
|
||||
std::fs::write(&partial, cache_filename)
|
||||
.with_context(|| format!("Failed to write the layer pointer for {name}"))?;
|
||||
std::fs::rename(&partial, &pointer)
|
||||
.with_context(|| format!("Failed to move the layer pointer into place for {name}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The cache path a layer pointer names, and whether the layer is
|
||||
/// really there: a pointer is a name, not a promise — the entry it
|
||||
/// names can be gone (a pruned cache, a base bump) while the
|
||||
/// pointer survives.
|
||||
pub fn layer_entry(&self, name: &str) -> Result<Option<PathBuf>> {
|
||||
validate_layer_name(name)?;
|
||||
let pointer = self.dir.join("layers").join(name);
|
||||
let filename = match std::fs::read_to_string(&pointer) {
|
||||
Ok(filename) => filename,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(err) => {
|
||||
return Err(Error::Other(
|
||||
anyhow::Error::from(err)
|
||||
.context(format!("reading the layer pointer for {name}")),
|
||||
))
|
||||
}
|
||||
};
|
||||
let path = self.dir.join(filename.trim());
|
||||
Ok((path.exists() && sidecar(&path, PROVISIONED_SUFFIX).exists()).then_some(path))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn extract_prepared(
|
||||
source: &Path,
|
||||
cache_path: PathBuf,
|
||||
progress: &dyn Progress,
|
||||
) -> anyhow::Result<PreparedRootfs> {
|
||||
let temp = tempfile::tempdir().context("Failed to create temporary directory")?;
|
||||
let rootfs = temp.path().to_path_buf();
|
||||
|
||||
log::debug!("Extracting to: {}", rootfs.display());
|
||||
extract_tarball(source, &rootfs, progress)?;
|
||||
provision_resolver(&rootfs);
|
||||
|
||||
Ok(PreparedRootfs {
|
||||
rootfs: temp,
|
||||
cache_path,
|
||||
})
|
||||
}
|
||||
|
||||
/// Give the guest the host's resolver: the engine proxies guest
|
||||
/// sockets to the host's stack, so a nameserver line that works on the
|
||||
/// host works in the machine, loopback stub included. Without it the
|
||||
/// guest has whatever the image shipped, which is usually nothing.
|
||||
/// Best effort: a rootfs with its own resolver config keeps it.
|
||||
fn provision_resolver(rootfs: &Path) {
|
||||
// The engine proxies guest sockets to the host's stack, so any
|
||||
// host-reachable nameserver works in the machine - but loopback
|
||||
// addresses do not cross: 127.0.0.53 stays inside the guest, where
|
||||
// nothing listens. So the source of truth is systemd-resolved's
|
||||
// uplink config when present, and loopback nameservers are dropped
|
||||
// from whatever is copied. Best effort: failures leave the
|
||||
// image's file.
|
||||
let host = match std::fs::read_to_string("/run/systemd/resolve/resolv.conf")
|
||||
.or_else(|_| std::fs::read_to_string("/etc/resolv.conf"))
|
||||
{
|
||||
Ok(s) if !s.trim().is_empty() => drop_loopback_nameservers(&s),
|
||||
Ok(_) => {
|
||||
log::debug!("host resolver config is empty; leaving the image's in place");
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!(
|
||||
"no host resolver config ({}); leaving the image's in place",
|
||||
e
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if !host.contains("nameserver ") {
|
||||
log::debug!("host resolver config has no usable nameserver; leaving the image's");
|
||||
return;
|
||||
}
|
||||
let target = rootfs.join("etc/resolv.conf");
|
||||
if let Err(e) = std::fs::remove_file(&target) {
|
||||
if e.kind() != std::io::ErrorKind::NotFound {
|
||||
log::debug!("could not clear {}: {}", target.display(), e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Some(parent) = target.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
match std::fs::write(&target, host) {
|
||||
Ok(()) => log::debug!("provisioned a resolver into {}", target.display()),
|
||||
Err(e) => log::debug!("could not write {}: {}", target.display(), e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lines naming a loopback nameserver go: the guest's loopback is its
|
||||
/// own, and the stub resolvers live on the host's.
|
||||
fn drop_loopback_nameservers(config: &str) -> String {
|
||||
config
|
||||
.lines()
|
||||
.filter(|line| {
|
||||
let rest = match line.trim_start().strip_prefix("nameserver ") {
|
||||
Some(r) => r.trim(),
|
||||
None => return true,
|
||||
};
|
||||
!rest.starts_with("127.")
|
||||
})
|
||||
.fold(String::new(), |mut acc, line| {
|
||||
acc.push_str(line);
|
||||
acc.push('\n');
|
||||
acc
|
||||
})
|
||||
}
|
||||
|
||||
/// 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);
|
||||
// The extension comes from the URL. `version` is the
|
||||
// resolved value (resolve_source ran first), so this
|
||||
// derivation is offline: numbers and patch-resolved
|
||||
// strings pass through without lookups. A resolution
|
||||
// failure here degrades to the plain .tar.gz name rather
|
||||
// than blocking every cache-path computation.
|
||||
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,
|
||||
digest,
|
||||
architecture,
|
||||
} => {
|
||||
// Sanitize for filename. A digest pin replaces the tag in
|
||||
// the name: two references differing only by pin must not
|
||||
// share an entry.
|
||||
let safe_registry = registry.replace(['.', ':'], "_");
|
||||
let safe_repo = repository.replace(['/', ':'], "_");
|
||||
let reference = digest.as_deref().unwrap_or(tag).replace(':', "_");
|
||||
format!(
|
||||
"oci-{}-{}-{}-{}.tar.gz",
|
||||
safe_registry, safe_repo, reference, architecture
|
||||
)
|
||||
}
|
||||
ImageSource::LocalTarball { path } => {
|
||||
let stem = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("rootfs")
|
||||
.replace(['/', '\\', ':'], "_");
|
||||
// Size and mtime key the entry: a provisioning over a
|
||||
// local archive persists into the cache under this name,
|
||||
// never over the caller's file, and an edited archive
|
||||
// lands in a fresh entry.
|
||||
let meta = std::fs::metadata(path);
|
||||
let len = meta.as_ref().map(|m| m.len()).unwrap_or(0);
|
||||
let mtime = meta
|
||||
.ok()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
format!("local-{}-{:x}-{:x}.tar.gz", stem, len, mtime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn loopback_nameservers_dropped() {
|
||||
let config = "# comment\nnameserver 127.0.0.53\noptions edns0\nnameserver 192.168.1.1\nnameserver 127.0.0.1\n";
|
||||
assert_eq!(
|
||||
drop_loopback_nameservers(config),
|
||||
"# comment\noptions edns0\nnameserver 192.168.1.1\n"
|
||||
);
|
||||
// A search line whose address-ish token is untouched.
|
||||
let config = "search 127.example\nnameserver 10.0.0.1\n";
|
||||
assert_eq!(
|
||||
drop_loopback_nameservers(config),
|
||||
"search 127.example\nnameserver 10.0.0.1\n"
|
||||
);
|
||||
}
|
||||
|
||||
fn direct_tarball(distro: Distro, version: &str) -> ImageSource {
|
||||
ImageSource::DirectTarball {
|
||||
distro,
|
||||
version: Some(version.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_filename_direct_tarball() {
|
||||
// Explicit version, already resolved to a number: no network lookups
|
||||
let cache = RootfsCache::new("/cache");
|
||||
assert_eq!(
|
||||
cache.tarball_path(&direct_tarball(Distro::Ubuntu, "24.04"), "amd64"),
|
||||
PathBuf::from("/cache/ubuntu-24.04-amd64.tar.gz")
|
||||
);
|
||||
assert_eq!(
|
||||
cache.tarball_path(&direct_tarball(Distro::Alpine, "3.23.1"), "amd64"),
|
||||
PathBuf::from("/cache/alpine-3.23.1-x86_64.tar.gz")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_filename_oci_sanitized() {
|
||||
let cache = RootfsCache::new("/cache");
|
||||
let source = ImageSource::OciImage {
|
||||
registry: "docker.io".to_string(),
|
||||
repository: "library/ubuntu".to_string(),
|
||||
tag: "latest".to_string(),
|
||||
digest: None,
|
||||
architecture: "amd64".to_string(),
|
||||
};
|
||||
assert_eq!(
|
||||
cache.tarball_path(&source, "amd64"),
|
||||
PathBuf::from("/cache/oci-docker_io-library_ubuntu-latest-amd64.tar.gz")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidecar_appends_suffix() {
|
||||
assert_eq!(
|
||||
sidecar(Path::new("/cache/foo.tar.gz"), PROVISIONED_SUFFIX),
|
||||
PathBuf::from("/cache/foo.tar.gz.provisioned")
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a minimal rootfs tree with a nested executable and a symlink,
|
||||
/// pack it, and extract it back through the public extract_tarball path.
|
||||
|
||||
#[test]
|
||||
fn a_digest_pin_names_its_own_cache_entry() {
|
||||
let cache = RootfsCache::new(std::path::PathBuf::from("/cache"));
|
||||
let tagged = crate::distro::parse_image_ref("ubuntu:24.04", "amd64").unwrap();
|
||||
let pinned = crate::distro::parse_image_ref(
|
||||
"ubuntu:24.04@sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
|
||||
"amd64",
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(
|
||||
cache.tarball_path(&tagged, "amd64"),
|
||||
cache.tarball_path(&pinned, "amd64"),
|
||||
"a pin must not share the tag's entry"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_digest_pin_is_never_checked_for_freshness() {
|
||||
// Even with a :latest tag riding along, the pin wins: this
|
||||
// must return without any registry contact (it would hang the
|
||||
// test suite offline if it tried).
|
||||
let pinned = crate::distro::parse_image_ref(
|
||||
"ubuntu:latest@sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
|
||||
"amd64",
|
||||
)
|
||||
.unwrap();
|
||||
let existing = tempfile::NamedTempFile::new().unwrap();
|
||||
assert!(!oci_freshness_changed(&pinned, existing.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_key_splices_into_the_entry_name() {
|
||||
let cache = RootfsCache::new(std::path::PathBuf::from("/cache"));
|
||||
let source = crate::distro::parse_image_ref("alpine:3.23", "amd64").expect("parses");
|
||||
let plain = cache.entry_path(&source, "amd64", None).unwrap();
|
||||
let keyed = cache.entry_path(&source, "amd64", Some("abc123")).unwrap();
|
||||
let other = cache.entry_path(&source, "amd64", Some("def456")).unwrap();
|
||||
eprintln!("plain={plain:?} keyed={keyed:?} other={other:?}");
|
||||
assert_ne!(plain, keyed, "the key changes the entry");
|
||||
assert_ne!(keyed, other, "two keys are two entries");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_derivation_never_escapes_the_cache_directory() {
|
||||
// The splice point validates: even a consumer that only
|
||||
// derives paths (never writes) gets the named error instead
|
||||
// of a path outside the cache directory.
|
||||
let cache = RootfsCache::new("/cache");
|
||||
let err = cache
|
||||
.resolved_entry_path("alpine:3.23", "amd64", Some("../x"))
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("invalid layer name"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_names_cannot_escape_the_cache_directory() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cache = RootfsCache::new(dir.path().to_path_buf());
|
||||
for bad in ["../evil", "a/b", "", ".", "..", ".hidden", "a\0b"] {
|
||||
assert!(
|
||||
cache.write_layer_pointer(bad, "x.tar.gz").is_err(),
|
||||
"{bad:?} must be rejected"
|
||||
);
|
||||
assert!(cache.layer_entry(bad).is_err(), "{bad:?} must be rejected");
|
||||
}
|
||||
for good in ["base", "dev-tools", "ci_2026", "Layer1"] {
|
||||
assert!(
|
||||
validate_layer_name(good).is_ok(),
|
||||
"{good:?} must be accepted"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_pointer_resolves_only_while_the_entry_exists() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let cache = RootfsCache::new(dir.path().to_path_buf());
|
||||
assert!(
|
||||
cache.layer_entry("nope").expect("reads").is_none(),
|
||||
"no pointer, no entry"
|
||||
);
|
||||
cache
|
||||
.write_layer_pointer("w", "alpine-3.23.7-x86_64.tar.abc.tar.gz")
|
||||
.expect("writes");
|
||||
assert!(
|
||||
cache.layer_entry("w").expect("reads").is_none(),
|
||||
"a pointer without its entry resolves to nothing"
|
||||
);
|
||||
std::fs::write(
|
||||
dir.path().join("alpine-3.23.7-x86_64.tar.abc.tar.gz"),
|
||||
b"tarball",
|
||||
)
|
||||
.expect("the entry appears");
|
||||
std::fs::write(
|
||||
dir.path()
|
||||
.join("alpine-3.23.7-x86_64.tar.abc.tar.gz.provisioned"),
|
||||
b"",
|
||||
)
|
||||
.expect("the marker appears");
|
||||
assert!(
|
||||
cache.layer_entry("w").expect("reads").is_some(),
|
||||
"pointer + entry + marker resolve"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,24 @@ 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
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
//! embeds the library exactly the way the CLI does - a [`RootfsCache`]
|
||||
//! to prepare a rootfs, then [`run`] to boot it.
|
||||
|
||||
pub mod cache;
|
||||
pub mod distro;
|
||||
pub mod download;
|
||||
pub mod error;
|
||||
@@ -46,7 +47,8 @@ mod supervise;
|
||||
pub mod utils;
|
||||
pub mod vm;
|
||||
|
||||
pub use cache::RootfsCache;
|
||||
pub use error::{Error, Result};
|
||||
pub use progress::{NoProgress, Progress, Unit};
|
||||
pub use rootfs::{PrepareRequest, PreparedRootfs, RootfsCache};
|
||||
pub use rootfs::{PrepareRequest, PreparedRootfs};
|
||||
pub use vm::{run, LogLevel, RunOptions, Share};
|
||||
+8
-623
@@ -31,278 +31,17 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{anyhow, Context};
|
||||
|
||||
use crate::cache::{
|
||||
extract_prepared, fill_entry, oci_freshness_changed, sidecar, EntryLock, RootfsCache,
|
||||
PROVISIONED_SUFFIX,
|
||||
};
|
||||
use crate::distro::{resolve_source, ImageSource};
|
||||
use crate::error::Error;
|
||||
use crate::progress::Progress;
|
||||
use crate::Result;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::distro::{
|
||||
map_arch, parse_image_ref, resolve_distro_url, resolve_distro_version, Distro, ImageSource,
|
||||
};
|
||||
use crate::download::{digest_sidecar, download_image};
|
||||
use crate::extract::extract_tarball;
|
||||
use crate::progress::Progress;
|
||||
use crate::registry::fetch_oci_digest;
|
||||
|
||||
/// Sidecar suffix marking a cache entry that holds a provisioned rootfs.
|
||||
const PROVISIONED_SUFFIX: &str = "provisioned";
|
||||
|
||||
/// Download `source` into the cache at `cache_path`, and drop any
|
||||
/// provisioned marker: whatever was provisioned belonged to the entry
|
||||
/// this download replaces, and a stale marker would present the
|
||||
/// pristine image as provisioned and skip the next provisioning for
|
||||
/// good.
|
||||
fn fill_entry(
|
||||
source: &ImageSource,
|
||||
cache_path: &Path,
|
||||
arch: &str,
|
||||
progress: &dyn Progress,
|
||||
) -> Result<()> {
|
||||
download_image(source, cache_path, arch, progress)?;
|
||||
let marker = sidecar(cache_path, PROVISIONED_SUFFIX);
|
||||
if marker.exists() {
|
||||
std::fs::remove_file(&marker).with_context(|| {
|
||||
format!(
|
||||
"Failed to drop the stale provisioned marker {}",
|
||||
marker.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether an OCI image with a floating tag (":latest") has moved since
|
||||
/// it was cached: fetch the current manifest digest from the registry
|
||||
/// and compare it against the digest stored from the last download.
|
||||
/// Pinned tags are assumed immutable; a network error keeps the cache
|
||||
/// rather than hard-failing; anything else is simply current.
|
||||
fn oci_freshness_changed(source: &ImageSource, cache_path: &Path) -> bool {
|
||||
if !cache_path.exists() {
|
||||
return false; // cache absent — the missing-file check triggers the download
|
||||
}
|
||||
let ImageSource::OciImage {
|
||||
registry,
|
||||
repository,
|
||||
tag,
|
||||
digest,
|
||||
..
|
||||
} = source
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
// A digest pin is immutable by definition; so is any non-latest tag.
|
||||
if digest.is_some() || tag != "latest" {
|
||||
return false;
|
||||
}
|
||||
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) => {
|
||||
log::warn!("could not check image freshness ({}); using cache", e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An exclusive advisory lock on one cache entry, held while a run
|
||||
/// downloads it: a second run preparing the same image blocks here and
|
||||
/// then reuses the winner's entry instead of downloading a duplicate
|
||||
/// the atomic rename would throw away. The lock file is a permanent
|
||||
/// zero-byte sidecar; flock releases when the holder exits, crashed or
|
||||
/// not.
|
||||
struct EntryLock {
|
||||
_file: std::fs::File,
|
||||
}
|
||||
|
||||
impl EntryLock {
|
||||
/// Block until the entry is free. `no_cache` callers skip the
|
||||
/// lock entirely: an explicit fresh download never waits.
|
||||
fn acquire(entry: &Path) -> anyhow::Result<Self> {
|
||||
use std::os::fd::AsRawFd;
|
||||
|
||||
let lock_path = entry.with_extension("lock");
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.write(true)
|
||||
.read(true)
|
||||
.open(&lock_path)
|
||||
.with_context(|| format!("Failed to open the entry lock {}", lock_path.display()))?;
|
||||
|
||||
let held = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
|
||||
if held != 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
if err.kind() != std::io::ErrorKind::WouldBlock {
|
||||
return Err(
|
||||
anyhow::Error::from(err).context(format!("locking {}", lock_path.display()))
|
||||
);
|
||||
}
|
||||
log::warn!(
|
||||
"waiting for another ecrou run to finish preparing {}",
|
||||
entry.display()
|
||||
);
|
||||
let held = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) };
|
||||
if held != 0 {
|
||||
return Err(anyhow::Error::from(std::io::Error::last_os_error())
|
||||
.context(format!("locking {}", lock_path.display())));
|
||||
}
|
||||
}
|
||||
Ok(EntryLock { _file: file })
|
||||
}
|
||||
}
|
||||
|
||||
/// A layer name is a file name under the cache's `layers/` directory
|
||||
/// and a component of entry file names: keep it to a safe charset so a
|
||||
/// name like `../x` or `a/b` cannot climb out of the cache directory.
|
||||
fn validate_layer_name(name: &str) -> Result<()> {
|
||||
let ok = !name.is_empty()
|
||||
&& name != "."
|
||||
&& name != ".."
|
||||
&& name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
|
||||
&& !name.starts_with('.');
|
||||
if ok {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::Other(anyhow!(
|
||||
"invalid layer name {name:?}: use letters, digits, '_' or '-', \
|
||||
not starting with '.'"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Sidecar path for a cache entry: `foo.tar.gz` → `foo.tar.gz.<suffix>`.
|
||||
fn sidecar(path: &Path, suffix: &str) -> PathBuf {
|
||||
let mut s = path.as_os_str().to_owned();
|
||||
s.push(".");
|
||||
s.push(suffix);
|
||||
PathBuf::from(s)
|
||||
}
|
||||
|
||||
/// The image tarball cache (default location: `~/.cache/ecrou`).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RootfsCache {
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
impl RootfsCache {
|
||||
pub fn new(dir: impl Into<PathBuf>) -> Self {
|
||||
RootfsCache { dir: dir.into() }
|
||||
}
|
||||
|
||||
/// Default cache directory: `~/.cache/ecrou` (or the platform equivalent).
|
||||
pub fn default_dir() -> Result<PathBuf> {
|
||||
dirs::cache_dir()
|
||||
.map(|p| p.join("ecrou"))
|
||||
.ok_or_else(|| anyhow!("Could not determine cache directory").into())
|
||||
}
|
||||
|
||||
pub fn dir(&self) -> &Path {
|
||||
&self.dir
|
||||
}
|
||||
|
||||
/// Cache entry (tarball path) for an image source and architecture.
|
||||
pub fn tarball_path(&self, source: &ImageSource, arch: &str) -> PathBuf {
|
||||
self.dir.join(generate_cache_filename(source, arch))
|
||||
}
|
||||
|
||||
/// The cache path for a prepared entry, keyed or not: a layer key
|
||||
/// is spliced into the filename, so a layer and its base — and two
|
||||
/// layers of one base — live in separate entries, never overwrite
|
||||
/// each other, and evict separately. The key is validated here, at
|
||||
/// the splice point, so no caller can derive a path outside the
|
||||
/// cache directory.
|
||||
pub fn entry_path(
|
||||
&self,
|
||||
source: &ImageSource,
|
||||
arch: &str,
|
||||
layer_key: Option<&str>,
|
||||
) -> Result<PathBuf> {
|
||||
match layer_key {
|
||||
None => Ok(self.tarball_path(source, arch)),
|
||||
Some(key) => {
|
||||
validate_layer_name(key)?;
|
||||
let filename = generate_cache_filename(source, arch);
|
||||
let stem = filename
|
||||
.rsplit_once('.')
|
||||
.map(|(stem, _)| stem)
|
||||
.unwrap_or(&filename);
|
||||
Ok(self.dir.join(format!("{stem}.{key}.tar.gz")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts a named layer into a fresh scratch directory: the boot
|
||||
/// side of `run --layer`. A missing pointer or a pruned entry is a
|
||||
/// named error, not a download — layers are provisioned, never
|
||||
/// fetched.
|
||||
pub fn extract_layer(&self, name: &str, progress: &dyn Progress) -> Result<PreparedRootfs> {
|
||||
validate_layer_name(name)?;
|
||||
let path = self.layer_entry(name)?.ok_or_else(|| {
|
||||
Error::Other(anyhow::anyhow!(
|
||||
"layer {name} is not provisioned: run \
|
||||
`ecrou provision --spec <spec> --layer {name}` first"
|
||||
))
|
||||
})?;
|
||||
extract_prepared(&path.clone(), path, progress).map_err(Error::Other)
|
||||
}
|
||||
|
||||
/// The cache path an image resolves to, keyed or not — the same
|
||||
/// resolution `prepare` does (floating aliases resolved), so a
|
||||
/// pointer written from this path names an entry that exists.
|
||||
pub fn resolved_entry_path(
|
||||
&self,
|
||||
image: &str,
|
||||
arch: &str,
|
||||
layer_key: Option<&str>,
|
||||
) -> Result<PathBuf> {
|
||||
let source = resolve_source(image, arch)?;
|
||||
self.entry_path(&source, arch, layer_key)
|
||||
}
|
||||
|
||||
/// Writes the layer pointer: `layers/<name>` holds the cache
|
||||
/// filename the name resolves to. Consumers boot layers by name —
|
||||
/// `run` should not have to re-derive the key from the spec, only
|
||||
/// agree with the provisioning call on what the layer is called.
|
||||
pub fn write_layer_pointer(&self, name: &str, cache_filename: &str) -> Result<()> {
|
||||
validate_layer_name(name)?;
|
||||
let dir = self.dir.join("layers");
|
||||
std::fs::create_dir_all(&dir)
|
||||
.with_context(|| format!("Failed to create the layer directory {}", dir.display()))?;
|
||||
// Atomic write: a concurrent run reading the pointer must
|
||||
// never see a truncated filename mid-write.
|
||||
let pointer = dir.join(name);
|
||||
let partial = crate::utils::partial_path(&pointer);
|
||||
std::fs::write(&partial, cache_filename)
|
||||
.with_context(|| format!("Failed to write the layer pointer for {name}"))?;
|
||||
std::fs::rename(&partial, &pointer)
|
||||
.with_context(|| format!("Failed to move the layer pointer into place for {name}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The cache path a layer pointer names, and whether the layer is
|
||||
/// really there: a pointer is a name, not a promise — the entry it
|
||||
/// names can be gone (a pruned cache, a base bump) while the
|
||||
/// pointer survives.
|
||||
pub fn layer_entry(&self, name: &str) -> Result<Option<PathBuf>> {
|
||||
validate_layer_name(name)?;
|
||||
let pointer = self.dir.join("layers").join(name);
|
||||
let filename = match std::fs::read_to_string(&pointer) {
|
||||
Ok(filename) => filename,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(err) => {
|
||||
return Err(Error::Other(
|
||||
anyhow::Error::from(err)
|
||||
.context(format!("reading the layer pointer for {name}")),
|
||||
))
|
||||
}
|
||||
};
|
||||
let path = self.dir.join(filename.trim());
|
||||
Ok((path.exists() && sidecar(&path, PROVISIONED_SUFFIX).exists()).then_some(path))
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -479,8 +218,8 @@ impl PrepareRequest {
|
||||
/// dropped, unless [`PreparedRootfs::persist`] ran first.
|
||||
#[derive(Debug)]
|
||||
pub struct PreparedRootfs {
|
||||
rootfs: TempDir,
|
||||
cache_path: PathBuf,
|
||||
pub(crate) rootfs: TempDir,
|
||||
pub(crate) cache_path: PathBuf,
|
||||
}
|
||||
|
||||
impl PreparedRootfs {
|
||||
@@ -516,251 +255,12 @@ impl PreparedRootfs {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_prepared(
|
||||
source: &Path,
|
||||
cache_path: PathBuf,
|
||||
progress: &dyn Progress,
|
||||
) -> anyhow::Result<PreparedRootfs> {
|
||||
let temp = tempfile::tempdir().context("Failed to create temporary directory")?;
|
||||
let rootfs = temp.path().to_path_buf();
|
||||
|
||||
log::debug!("Extracting to: {}", rootfs.display());
|
||||
extract_tarball(source, &rootfs, progress)?;
|
||||
provision_resolver(&rootfs);
|
||||
|
||||
Ok(PreparedRootfs {
|
||||
rootfs: temp,
|
||||
cache_path,
|
||||
})
|
||||
}
|
||||
|
||||
/// Give the guest the host's resolver: the engine proxies guest
|
||||
/// sockets to the host's stack, so a nameserver line that works on the
|
||||
/// host works in the machine, loopback stub included. Without it the
|
||||
/// guest has whatever the image shipped, which is usually nothing.
|
||||
/// Best effort: a rootfs with its own resolver config keeps it.
|
||||
fn provision_resolver(rootfs: &Path) {
|
||||
// The engine proxies guest sockets to the host's stack, so any
|
||||
// host-reachable nameserver works in the machine - but loopback
|
||||
// addresses do not cross: 127.0.0.53 stays inside the guest, where
|
||||
// nothing listens. So the source of truth is systemd-resolved's
|
||||
// uplink config when present, and loopback nameservers are dropped
|
||||
// from whatever is copied. Best effort: failures leave the
|
||||
// image's file.
|
||||
let host = match std::fs::read_to_string("/run/systemd/resolve/resolv.conf")
|
||||
.or_else(|_| std::fs::read_to_string("/etc/resolv.conf"))
|
||||
{
|
||||
Ok(s) if !s.trim().is_empty() => drop_loopback_nameservers(&s),
|
||||
Ok(_) => {
|
||||
log::debug!("host resolver config is empty; leaving the image's in place");
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!(
|
||||
"no host resolver config ({}); leaving the image's in place",
|
||||
e
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if !host.contains("nameserver ") {
|
||||
log::debug!("host resolver config has no usable nameserver; leaving the image's");
|
||||
return;
|
||||
}
|
||||
let target = rootfs.join("etc/resolv.conf");
|
||||
if let Err(e) = std::fs::remove_file(&target) {
|
||||
if e.kind() != std::io::ErrorKind::NotFound {
|
||||
log::debug!("could not clear {}: {}", target.display(), e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Some(parent) = target.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
match std::fs::write(&target, host) {
|
||||
Ok(()) => log::debug!("provisioned a resolver into {}", target.display()),
|
||||
Err(e) => log::debug!("could not write {}: {}", target.display(), e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lines naming a loopback nameserver go: the guest's loopback is its
|
||||
/// own, and the stub resolvers live on the host's.
|
||||
fn drop_loopback_nameservers(config: &str) -> String {
|
||||
config
|
||||
.lines()
|
||||
.filter(|line| {
|
||||
let rest = match line.trim_start().strip_prefix("nameserver ") {
|
||||
Some(r) => r.trim(),
|
||||
None => return true,
|
||||
};
|
||||
!rest.starts_with("127.")
|
||||
})
|
||||
.fold(String::new(), |mut acc, line| {
|
||||
acc.push_str(line);
|
||||
acc.push('\n');
|
||||
acc
|
||||
})
|
||||
}
|
||||
|
||||
/// 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);
|
||||
// The extension comes from the URL. `version` is the
|
||||
// resolved value (resolve_source ran first), so this
|
||||
// derivation is offline: numbers and patch-resolved
|
||||
// strings pass through without lookups. A resolution
|
||||
// failure here degrades to the plain .tar.gz name rather
|
||||
// than blocking every cache-path computation.
|
||||
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,
|
||||
digest,
|
||||
architecture,
|
||||
} => {
|
||||
// Sanitize for filename. A digest pin replaces the tag in
|
||||
// the name: two references differing only by pin must not
|
||||
// share an entry.
|
||||
let safe_registry = registry.replace(['.', ':'], "_");
|
||||
let safe_repo = repository.replace(['/', ':'], "_");
|
||||
let reference = digest.as_deref().unwrap_or(tag).replace(':', "_");
|
||||
format!(
|
||||
"oci-{}-{}-{}-{}.tar.gz",
|
||||
safe_registry, safe_repo, reference, architecture
|
||||
)
|
||||
}
|
||||
ImageSource::LocalTarball { path } => {
|
||||
let stem = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("rootfs")
|
||||
.replace(['/', '\\', ':'], "_");
|
||||
// Size and mtime key the entry: a provisioning over a
|
||||
// local archive persists into the cache under this name,
|
||||
// never over the caller's file, and an edited archive
|
||||
// lands in a fresh entry.
|
||||
let meta = std::fs::metadata(path);
|
||||
let len = meta.as_ref().map(|m| m.len()).unwrap_or(0);
|
||||
let mtime = meta
|
||||
.ok()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
format!("local-{}-{:x}-{:x}.tar.gz", stem, len, mtime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::drop_loopback_nameservers;
|
||||
|
||||
#[test]
|
||||
fn loopback_nameservers_dropped() {
|
||||
let config = "# comment\nnameserver 127.0.0.53\noptions edns0\nnameserver 192.168.1.1\nnameserver 127.0.0.1\n";
|
||||
assert_eq!(
|
||||
drop_loopback_nameservers(config),
|
||||
"# comment\noptions edns0\nnameserver 192.168.1.1\n"
|
||||
);
|
||||
// A search line whose address-ish token is untouched.
|
||||
let config = "search 127.example\nnameserver 10.0.0.1\n";
|
||||
assert_eq!(
|
||||
drop_loopback_nameservers(config),
|
||||
"search 127.example\nnameserver 10.0.0.1\n"
|
||||
);
|
||||
}
|
||||
|
||||
use super::*;
|
||||
use crate::progress::NoProgress;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
fn direct_tarball(distro: Distro, version: &str) -> ImageSource {
|
||||
ImageSource::DirectTarball {
|
||||
distro,
|
||||
version: Some(version.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_filename_direct_tarball() {
|
||||
// Explicit version, already resolved to a number: no network lookups
|
||||
let cache = RootfsCache::new("/cache");
|
||||
assert_eq!(
|
||||
cache.tarball_path(&direct_tarball(Distro::Ubuntu, "24.04"), "amd64"),
|
||||
PathBuf::from("/cache/ubuntu-24.04-amd64.tar.gz")
|
||||
);
|
||||
assert_eq!(
|
||||
cache.tarball_path(&direct_tarball(Distro::Alpine, "3.23.1"), "amd64"),
|
||||
PathBuf::from("/cache/alpine-3.23.1-x86_64.tar.gz")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_filename_oci_sanitized() {
|
||||
let cache = RootfsCache::new("/cache");
|
||||
let source = ImageSource::OciImage {
|
||||
registry: "docker.io".to_string(),
|
||||
repository: "library/ubuntu".to_string(),
|
||||
tag: "latest".to_string(),
|
||||
digest: None,
|
||||
architecture: "amd64".to_string(),
|
||||
};
|
||||
assert_eq!(
|
||||
cache.tarball_path(&source, "amd64"),
|
||||
PathBuf::from("/cache/oci-docker_io-library_ubuntu-latest-amd64.tar.gz")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_arch_defaults_to_host() {
|
||||
let req = PrepareRequest::new("alpine");
|
||||
@@ -774,16 +274,6 @@ mod tests {
|
||||
assert_eq!(req.resolved_arch(), "arm64");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidecar_appends_suffix() {
|
||||
assert_eq!(
|
||||
sidecar(Path::new("/cache/foo.tar.gz"), PROVISIONED_SUFFIX),
|
||||
PathBuf::from("/cache/foo.tar.gz.provisioned")
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a minimal rootfs tree with a nested executable and a symlink,
|
||||
/// pack it, and extract it back through the public extract_tarball path.
|
||||
fn roundtrip(dest_name: &str) {
|
||||
let src = tempfile::tempdir().unwrap();
|
||||
std::fs::write(src.path().join("hello.txt"), b"hello").unwrap();
|
||||
@@ -925,109 +415,4 @@ mod tests {
|
||||
.to_string();
|
||||
assert_ne!(empty_name, grown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_digest_pin_names_its_own_cache_entry() {
|
||||
let cache = RootfsCache::new(std::path::PathBuf::from("/cache"));
|
||||
let tagged = crate::distro::parse_image_ref("ubuntu:24.04", "amd64").unwrap();
|
||||
let pinned = crate::distro::parse_image_ref(
|
||||
"ubuntu:24.04@sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
|
||||
"amd64",
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(
|
||||
cache.tarball_path(&tagged, "amd64"),
|
||||
cache.tarball_path(&pinned, "amd64"),
|
||||
"a pin must not share the tag's entry"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_digest_pin_is_never_checked_for_freshness() {
|
||||
// Even with a :latest tag riding along, the pin wins: this
|
||||
// must return without any registry contact (it would hang the
|
||||
// test suite offline if it tried).
|
||||
let pinned = crate::distro::parse_image_ref(
|
||||
"ubuntu:latest@sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
|
||||
"amd64",
|
||||
)
|
||||
.unwrap();
|
||||
let existing = tempfile::NamedTempFile::new().unwrap();
|
||||
assert!(!oci_freshness_changed(&pinned, existing.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_key_splices_into_the_entry_name() {
|
||||
let cache = RootfsCache::new(std::path::PathBuf::from("/cache"));
|
||||
let source = crate::distro::parse_image_ref("alpine:3.23", "amd64").expect("parses");
|
||||
let plain = cache.entry_path(&source, "amd64", None).unwrap();
|
||||
let keyed = cache.entry_path(&source, "amd64", Some("abc123")).unwrap();
|
||||
let other = cache.entry_path(&source, "amd64", Some("def456")).unwrap();
|
||||
eprintln!("plain={plain:?} keyed={keyed:?} other={other:?}");
|
||||
assert_ne!(plain, keyed, "the key changes the entry");
|
||||
assert_ne!(keyed, other, "two keys are two entries");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_derivation_never_escapes_the_cache_directory() {
|
||||
// The splice point validates: even a consumer that only
|
||||
// derives paths (never writes) gets the named error instead
|
||||
// of a path outside the cache directory.
|
||||
let cache = RootfsCache::new("/cache");
|
||||
let err = cache
|
||||
.resolved_entry_path("alpine:3.23", "amd64", Some("../x"))
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("invalid layer name"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_names_cannot_escape_the_cache_directory() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cache = RootfsCache::new(dir.path().to_path_buf());
|
||||
for bad in ["../evil", "a/b", "", ".", "..", ".hidden", "a\0b"] {
|
||||
assert!(
|
||||
cache.write_layer_pointer(bad, "x.tar.gz").is_err(),
|
||||
"{bad:?} must be rejected"
|
||||
);
|
||||
assert!(cache.layer_entry(bad).is_err(), "{bad:?} must be rejected");
|
||||
}
|
||||
for good in ["base", "dev-tools", "ci_2026", "Layer1"] {
|
||||
assert!(
|
||||
validate_layer_name(good).is_ok(),
|
||||
"{good:?} must be accepted"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_pointer_resolves_only_while_the_entry_exists() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let cache = RootfsCache::new(dir.path().to_path_buf());
|
||||
assert!(
|
||||
cache.layer_entry("nope").expect("reads").is_none(),
|
||||
"no pointer, no entry"
|
||||
);
|
||||
cache
|
||||
.write_layer_pointer("w", "alpine-3.23.7-x86_64.tar.abc.tar.gz")
|
||||
.expect("writes");
|
||||
assert!(
|
||||
cache.layer_entry("w").expect("reads").is_none(),
|
||||
"a pointer without its entry resolves to nothing"
|
||||
);
|
||||
std::fs::write(
|
||||
dir.path().join("alpine-3.23.7-x86_64.tar.abc.tar.gz"),
|
||||
b"tarball",
|
||||
)
|
||||
.expect("the entry appears");
|
||||
std::fs::write(
|
||||
dir.path()
|
||||
.join("alpine-3.23.7-x86_64.tar.abc.tar.gz.provisioned"),
|
||||
b"",
|
||||
)
|
||||
.expect("the marker appears");
|
||||
assert!(
|
||||
cache.layer_entry("w").expect("reads").is_some(),
|
||||
"pointer + entry + marker resolve"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,10 +12,11 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{anyhow, Context};
|
||||
|
||||
use crate::cache::RootfsCache;
|
||||
use crate::guest::{self, GuestShare};
|
||||
use crate::krun::{self, Krun};
|
||||
use crate::progress::Progress;
|
||||
use crate::rootfs::{PrepareRequest, PreparedRootfs, RootfsCache};
|
||||
use crate::rootfs::{PrepareRequest, PreparedRootfs};
|
||||
use crate::utils::{self, HOSTNAME_SUFFIX_LEN};
|
||||
use crate::{Error, Result};
|
||||
|
||||
|
||||
Reference in new issue
Block a user