mirror of
https://github.com/vhaudiquet/ecrou.git
synced 2026-11-12 22:22:44 +00:00
refactor: cut provision
The runtime runs images; building them belongs to the OCI ecosystem — CI builds, digests, scanning — and a keyed local layer could never offer a digest. Provision also installed packages through TSI, whose per-stream throughput makes a real package install crawl exactly at first use, and the bare-command shape mutated the shared cache entry under the image's own name. 0.1 runs images: run, pull, probe — distro aliases, OCI references with digest pins, local rootfs archives. The one honest idea provision carried stays in SPEC's Later: provision by booting, for locked-down hosts that cannot run any builder.
This commit is contained in:
1 parent
e632fa2125
commit
0ad07c182f
10 files changed
+56
-946
No files matched your search
@@ -185,8 +185,8 @@ they need host setup the budget cannot afford. Port publishing is how
|
|||||||
a later in-guest agent serves the host (Later).
|
a later in-guest agent serves the host (Later).
|
||||||
|
|
||||||
**Memory and CPUs.** Default 1G and all logical CPUs; `-m` overrides.
|
**Memory and CPUs.** Default 1G and all logical CPUs; `-m` overrides.
|
||||||
A per-command machine is provisioned per command, and agent consumers
|
Each command gets its own machine, and agent consumers run several
|
||||||
run several concurrently.
|
concurrently.
|
||||||
|
|
||||||
## Trust model
|
## Trust model
|
||||||
|
|
||||||
@@ -279,10 +279,14 @@ build is deleted.
|
|||||||
correct-and-slow v1 posture graduates into this.
|
correct-and-slow v1 posture graduates into this.
|
||||||
- **Guest agent, long-lived machines.** Exec without a boot; the shape
|
- **Guest agent, long-lived machines.** Exec without a boot; the shape
|
||||||
to grow into when per-command VMs stop being right.
|
to grow into when per-command VMs stop being right.
|
||||||
- **Layer eviction.** Provisioned layers are already keyed: a spec
|
- **A build story for hosts that cannot build.** Provision was cut
|
||||||
file (base, packages, run) hashed with its base reference, named by
|
from 0.1: a runtime that also builds images is two products, and
|
||||||
`--layer` for `run --layer`. Nothing prunes the derived entries yet;
|
the OCI ecosystem already builds images — CI, digests, scanning.
|
||||||
eviction (LRU or an explicit prune) keeps them bounded.
|
What has no answer yet is the locked-down host with no daemon, no
|
||||||
|
namespaces, no way to run a builder. There, provision by booting —
|
||||||
|
the guest installs, the host stays confined, the artifact is a
|
||||||
|
keyed cache entry. Waits on TSI throughput and on someone actually
|
||||||
|
needing it.
|
||||||
- **passt** (`krun_set_passt_fd`) if TSI throughput or UDP listening
|
- **passt** (`krun_set_passt_fd`) if TSI throughput or UDP listening
|
||||||
ever matters.
|
ever matters.
|
||||||
- **An agent in the guest** over port publishing: an http backend
|
- **An agent in the guest** over port publishing: an http backend
|
||||||
|
|||||||
@@ -29,34 +29,6 @@ pub enum Command {
|
|||||||
/// away. Persistence crosses a --volume share or it does not
|
/// away. Persistence crosses a --volume share or it does not
|
||||||
/// happen.
|
/// happen.
|
||||||
Run(RunArgs),
|
Run(RunArgs),
|
||||||
/// Bake a provisioned layer over a base image
|
|
||||||
///
|
|
||||||
/// Two shapes. --spec FILE describes the layer in checked-in YAML
|
|
||||||
/// (base, packages, run); the layer is keyed by the file's
|
|
||||||
/// content, so the same spec never provisions twice and a changed
|
|
||||||
/// spec never reuses an old layer, and --layer NAME names it for
|
|
||||||
/// `run --layer NAME`. `provision IMAGE -- COMMAND` bakes the
|
|
||||||
/// command into the image's cache entry itself: every later boot
|
|
||||||
/// of that image carries what the command installed.
|
|
||||||
Provision {
|
|
||||||
/// Base image: distro name or OCI reference; not needed when
|
|
||||||
/// the spec carries a base
|
|
||||||
image: Option<String>,
|
|
||||||
/// Provisioning spec file (YAML: base, packages, run); the
|
|
||||||
/// layer it describes is keyed by the file's content
|
|
||||||
#[arg(long)]
|
|
||||||
spec: Option<std::path::PathBuf>,
|
|
||||||
/// Name the provisioned layer, so `run --layer NAME` boots it
|
|
||||||
#[arg(long)]
|
|
||||||
layer: Option<String>,
|
|
||||||
/// Ignore the cache: re-download the base and re-provision
|
|
||||||
#[arg(long)]
|
|
||||||
no_cache: bool,
|
|
||||||
/// Provisioning command, baked into the image's cache entry
|
|
||||||
/// (use with IMAGE, without --spec)
|
|
||||||
#[arg(last = true)]
|
|
||||||
command: Vec<String>,
|
|
||||||
},
|
|
||||||
/// Fetch or refresh an image's cache entry without booting it
|
/// Fetch or refresh an image's cache entry without booting it
|
||||||
///
|
///
|
||||||
/// Unpinned OCI tags are re-checked against the registry digest
|
/// Unpinned OCI tags are re-checked against the registry digest
|
||||||
@@ -82,9 +54,8 @@ pub enum Command {
|
|||||||
#[derive(Args, Debug)]
|
#[derive(Args, Debug)]
|
||||||
pub struct RunArgs {
|
pub struct RunArgs {
|
||||||
/// Distro name, OCI image reference, or a local rootfs archive
|
/// Distro name, OCI image reference, or a local rootfs archive
|
||||||
/// path (./rootfs.tar.gz); not needed with --layer
|
/// path (./rootfs.tar.gz)
|
||||||
#[arg(required_unless_present = "layer")]
|
pub image: String,
|
||||||
pub image: Option<String>,
|
|
||||||
/// Keep the machine's stdin open: the host's stdin feeds the
|
/// Keep the machine's stdin open: the host's stdin feeds the
|
||||||
/// guest, so shells and prompts read typed input
|
/// guest, so shells and prompts read typed input
|
||||||
#[arg(short = 'i', long)]
|
#[arg(short = 'i', long)]
|
||||||
@@ -122,10 +93,6 @@ pub struct RunArgs {
|
|||||||
/// Ignore the cache: download the image fresh
|
/// Ignore the cache: download the image fresh
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub no_cache: bool,
|
pub no_cache: bool,
|
||||||
/// Boot a named provisioned layer (see provision --layer)
|
|
||||||
/// instead of an image
|
|
||||||
#[arg(long)]
|
|
||||||
pub layer: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse one `HOST:GUEST[:ro]` volume specification. The guest path
|
/// Parse one `HOST:GUEST[:ro]` volume specification. The guest path
|
||||||
@@ -199,7 +166,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
assert!(args.interactive);
|
assert!(args.interactive);
|
||||||
assert!(args.tty);
|
assert!(args.tty);
|
||||||
assert_eq!(args.image.as_deref(), Some("alpine"));
|
assert_eq!(args.image, "alpine");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -20,146 +20,11 @@ fn main() -> std::process::ExitCode {
|
|||||||
logger::init(cli.verbose);
|
logger::init(cli.verbose);
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Command::Run(args) => cmd_run(&args, cli.verbose),
|
Command::Run(args) => cmd_run(&args, cli.verbose),
|
||||||
Command::Provision {
|
|
||||||
image,
|
|
||||||
spec,
|
|
||||||
layer,
|
|
||||||
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::Pull { image, no_cache } => cmd_pull(&image, no_cache, cli.verbose),
|
||||||
Command::Probe => cmd_probe(),
|
Command::Probe => cmd_probe(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_provision(
|
|
||||||
image: Option<String>,
|
|
||||||
spec: Option<std::path::PathBuf>,
|
|
||||||
layer: Option<String>,
|
|
||||||
no_cache: bool,
|
|
||||||
command: &[String],
|
|
||||||
cli_verbose: bool,
|
|
||||||
) -> std::process::ExitCode {
|
|
||||||
use ecrou::cache::RootfsCache;
|
|
||||||
use ecrou::rootfs::PrepareRequest;
|
|
||||||
use ecrou::spec::ProvisionSpec;
|
|
||||||
|
|
||||||
// Two shapes: `provision IMAGE -- COMMAND` bakes a one-off
|
|
||||||
// command; `provision --spec FILE --layer NAME` bakes a checked-in
|
|
||||||
// spec into a named, content-keyed layer. A spec plus a command
|
|
||||||
// has no single meaning.
|
|
||||||
if spec.is_none() == command.is_empty() {
|
|
||||||
return fail(&anyhow::anyhow!(
|
|
||||||
"provision needs either a spec file (--spec) or a command: \
|
|
||||||
ecrou provision <IMAGE> -- <COMMAND...>"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let level = if cli_verbose {
|
|
||||||
LogLevel::Verbose
|
|
||||||
} else {
|
|
||||||
LogLevel::Normal
|
|
||||||
};
|
|
||||||
let mut options = RunOptions {
|
|
||||||
log_level: level,
|
|
||||||
// The machine exists to change the rootfs, not to cross a
|
|
||||||
// share: the workspace would ride the persist into the cache.
|
|
||||||
..RunOptions::default()
|
|
||||||
};
|
|
||||||
options.workdir = "/".to_owned();
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
// The spec decides the request: its base (or the positional
|
|
||||||
// image), its key, and its script — the packages through the
|
|
||||||
// base's package manager, then the run block. The key hashes the
|
|
||||||
// raw bytes and the base reference, so the cache entries two
|
|
||||||
// projects see can never cross.
|
|
||||||
let mut request = PrepareRequest::new(String::new());
|
|
||||||
request.no_cache = no_cache;
|
|
||||||
let mut script;
|
|
||||||
match &spec {
|
|
||||||
Some(path) => {
|
|
||||||
let bytes = match std::fs::read(path) {
|
|
||||||
Ok(bytes) => bytes,
|
|
||||||
Err(err) => return fail(&anyhow::anyhow!("reading {}: {err}", path.display())),
|
|
||||||
};
|
|
||||||
let parsed = match ProvisionSpec::parse(&bytes) {
|
|
||||||
Ok(parsed) => parsed,
|
|
||||||
Err(err) => return fail(&err),
|
|
||||||
};
|
|
||||||
let base = match parsed.base.clone().or_else(|| image.clone()) {
|
|
||||||
Some(base) => base,
|
|
||||||
None => {
|
|
||||||
return fail(&anyhow::anyhow!(
|
|
||||||
"the spec has no base and no image was named: \
|
|
||||||
ecrou provision <IMAGE> --spec {path}",
|
|
||||||
path = path.display()
|
|
||||||
))
|
|
||||||
}
|
|
||||||
};
|
|
||||||
script = parsed.script();
|
|
||||||
let key = ProvisionSpec::key(&bytes, &base);
|
|
||||||
request.image = base;
|
|
||||||
request.layer_key = Some(key);
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
if layer.is_some() {
|
|
||||||
return fail(&anyhow::anyhow!(
|
|
||||||
"--layer names a provisioned spec, not a bare command"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
request.image = image
|
|
||||||
.clone()
|
|
||||||
.expect("clap requires an image without --spec");
|
|
||||||
script = command.join(" ");
|
|
||||||
script.push('\n');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
options.image = request.image.clone();
|
|
||||||
options.command = vec!["/bin/sh".to_owned(), "-c".to_owned(), script];
|
|
||||||
|
|
||||||
let outcome = cache.prepare_provisioned(&request, progress, |rootfs| {
|
|
||||||
let code = ecrou::vm::run_prepared(rootfs, &options)?;
|
|
||||||
if code != 0 {
|
|
||||||
anyhow::bail!(
|
|
||||||
"the provisioning command exited {code}; the cache entry was left unprovisioned"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
});
|
|
||||||
match outcome {
|
|
||||||
Ok(_rootfs) => {
|
|
||||||
// The pointer lands only after the entry does: a name
|
|
||||||
// that resolves is a layer that boots.
|
|
||||||
if let (Some(name), Some(key)) = (&layer, &request.layer_key) {
|
|
||||||
let path = match cache.resolved_entry_path(
|
|
||||||
&request.image,
|
|
||||||
&request.resolved_arch(),
|
|
||||||
Some(key),
|
|
||||||
) {
|
|
||||||
Ok(path) => path,
|
|
||||||
Err(err) => return fail(&anyhow::Error::new(err)),
|
|
||||||
};
|
|
||||||
let filename = path
|
|
||||||
.file_name()
|
|
||||||
.map(|n| n.to_string_lossy().into_owned())
|
|
||||||
.unwrap_or_default();
|
|
||||||
if let Err(err) = cache.write_layer_pointer(name, &filename) {
|
|
||||||
return fail(&anyhow::Error::new(err));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
std::process::ExitCode::SUCCESS
|
|
||||||
}
|
|
||||||
Err(err) => fail(&anyhow::Error::new(err)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cmd_run(args: &RunArgs, cli_verbose: bool) -> std::process::ExitCode {
|
fn cmd_run(args: &RunArgs, cli_verbose: bool) -> std::process::ExitCode {
|
||||||
// A tty drives the payload's output through the terminal while the
|
// A tty drives the payload's output through the terminal while the
|
||||||
// capture share points the same streams at files: one of them has
|
// capture share points the same streams at files: one of them has
|
||||||
@@ -171,9 +36,7 @@ fn cmd_run(args: &RunArgs, cli_verbose: bool) -> std::process::ExitCode {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
let mut options = RunOptions {
|
let mut options = RunOptions {
|
||||||
// Without a layer the image is required by clap; with one the
|
image: args.image.clone(),
|
||||||
// field is dead — the layer entry carries the rootfs.
|
|
||||||
image: args.image.clone().unwrap_or_default(),
|
|
||||||
command: args.command.clone(),
|
command: args.command.clone(),
|
||||||
memory: args.memory.clone(),
|
memory: args.memory.clone(),
|
||||||
workdir: args.workdir.clone(),
|
workdir: args.workdir.clone(),
|
||||||
@@ -187,7 +50,6 @@ fn cmd_run(args: &RunArgs, cli_verbose: bool) -> std::process::ExitCode {
|
|||||||
interactive: args.interactive,
|
interactive: args.interactive,
|
||||||
tty: args.tty,
|
tty: args.tty,
|
||||||
no_cache: args.no_cache,
|
no_cache: args.no_cache,
|
||||||
layer: args.layer.clone(),
|
|
||||||
capture: args.capture.clone(),
|
capture: args.capture.clone(),
|
||||||
..RunOptions::default()
|
..RunOptions::default()
|
||||||
};
|
};
|
||||||
|
|||||||
+12
-254
@@ -1,53 +1,24 @@
|
|||||||
//! The rootfs tarball cache: entries, freshness, and provisioned layers.
|
//! The rootfs tarball cache: entries and freshness.
|
||||||
//!
|
//!
|
||||||
//! One directory of content-addressed-ish entries: every downloaded or
|
//! One directory of content-addressed-ish entries: every downloaded
|
||||||
//! provisioned rootfs lives under a name derived from its source, an
|
//! rootfs lives under a name derived from its source, an advisory lock
|
||||||
//! advisory lock keeps concurrent runs from downloading the same image
|
//! keeps concurrent runs from downloading the same image twice, and a
|
||||||
//! twice, a digest sidecar lets floating tags be re-checked cheaply,
|
//! digest sidecar lets floating tags be re-checked cheaply. The
|
||||||
//! and layer pointers name provisioned entries so runs can boot them
|
//! lifecycle that drives it — prepare, pull — lives in
|
||||||
//! without re-deriving keys. The lifecycle that drives it — prepare,
|
//! [`crate::rootfs`].
|
||||||
//! provision, persist — lives in [`crate::rootfs`].
|
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use anyhow::{anyhow, Context};
|
use anyhow::{anyhow, Context};
|
||||||
|
|
||||||
use crate::distro::{map_arch, resolve_source, Distro, ImageSource};
|
use crate::distro::{map_arch, Distro, ImageSource};
|
||||||
use crate::download::{digest_sidecar, download_image};
|
use crate::download::digest_sidecar;
|
||||||
use crate::error::Error;
|
|
||||||
use crate::extract::extract_tarball;
|
use crate::extract::extract_tarball;
|
||||||
use crate::progress::Progress;
|
use crate::progress::Progress;
|
||||||
use crate::registry::fetch_oci_digest;
|
use crate::registry::fetch_oci_digest;
|
||||||
use crate::rootfs::PreparedRootfs;
|
use crate::rootfs::PreparedRootfs;
|
||||||
use crate::Result;
|
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
|
/// Whether an OCI image with a floating tag (":latest") has moved since
|
||||||
/// it was cached: fetch the current manifest digest from the registry
|
/// it was cached: fetch the current manifest digest from the registry
|
||||||
/// and compare it against the digest stored from the last download.
|
/// and compare it against the digest stored from the last download.
|
||||||
@@ -136,35 +107,6 @@ impl EntryLock {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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`).
|
/// The image tarball cache (default location: `~/.cache/ecrou`).
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct RootfsCache {
|
pub struct RootfsCache {
|
||||||
@@ -191,106 +133,10 @@ impl RootfsCache {
|
|||||||
pub fn tarball_path(&self, source: &ImageSource, arch: &str) -> PathBuf {
|
pub fn tarball_path(&self, source: &ImageSource, arch: &str) -> PathBuf {
|
||||||
self.dir.join(generate_cache_filename(source, arch))
|
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(
|
pub(crate) fn extract_prepared(
|
||||||
source: &Path,
|
source: &Path,
|
||||||
cache_path: PathBuf,
|
|
||||||
progress: &dyn Progress,
|
progress: &dyn Progress,
|
||||||
) -> anyhow::Result<PreparedRootfs> {
|
) -> anyhow::Result<PreparedRootfs> {
|
||||||
let temp = tempfile::tempdir().context("Failed to create temporary directory")?;
|
let temp = tempfile::tempdir().context("Failed to create temporary directory")?;
|
||||||
@@ -300,10 +146,7 @@ pub(crate) fn extract_prepared(
|
|||||||
extract_tarball(source, &rootfs, progress)?;
|
extract_tarball(source, &rootfs, progress)?;
|
||||||
provision_resolver(&rootfs);
|
provision_resolver(&rootfs);
|
||||||
|
|
||||||
Ok(PreparedRootfs {
|
Ok(PreparedRootfs { rootfs: temp })
|
||||||
rootfs: temp,
|
|
||||||
cache_path,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Give the guest the host's resolver: the engine proxies guest
|
/// Give the guest the host's resolver: the engine proxies guest
|
||||||
@@ -421,10 +264,8 @@ fn generate_cache_filename(source: &ImageSource, arch: &str) -> String {
|
|||||||
.and_then(|s| s.to_str())
|
.and_then(|s| s.to_str())
|
||||||
.unwrap_or("rootfs")
|
.unwrap_or("rootfs")
|
||||||
.replace(['/', '\\', ':'], "_");
|
.replace(['/', '\\', ':'], "_");
|
||||||
// Size and mtime key the entry: a provisioning over a
|
// Size and mtime key the entry: an edited archive lands
|
||||||
// local archive persists into the cache under this name,
|
// in a fresh entry name, never a stale one.
|
||||||
// never over the caller's file, and an edited archive
|
|
||||||
// lands in a fresh entry.
|
|
||||||
let meta = std::fs::metadata(path);
|
let meta = std::fs::metadata(path);
|
||||||
let len = meta.as_ref().map(|m| m.len()).unwrap_or(0);
|
let len = meta.as_ref().map(|m| m.len()).unwrap_or(0);
|
||||||
let mtime = meta
|
let mtime = meta
|
||||||
@@ -494,14 +335,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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,
|
/// Build a minimal rootfs tree with a nested executable and a symlink,
|
||||||
/// pack it, and extract it back through the public extract_tarball path.
|
/// pack it, and extract it back through the public extract_tarball path.
|
||||||
|
|
||||||
@@ -534,79 +367,4 @@ mod tests {
|
|||||||
let existing = tempfile::NamedTempFile::new().unwrap();
|
let existing = tempfile::NamedTempFile::new().unwrap();
|
||||||
assert!(!oci_freshness_changed(&pinned, existing.path()));
|
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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -129,9 +129,9 @@ fn extract_multi_layer_oci(
|
|||||||
|
|
||||||
let manifest_path = temp_dir.path().join("layers.manifest");
|
let manifest_path = temp_dir.path().join("layers.manifest");
|
||||||
if !manifest_path.exists() {
|
if !manifest_path.exists() {
|
||||||
// Not our layer-bundle layout: a provisioned rootfs persisted over
|
// Not our layer-bundle layout: an "oci-" entry whose outer
|
||||||
// an OCI cache entry (the dispatch above keys on the "oci-" filename
|
// archive is a plain rootfs tarball (the dispatch keys on the
|
||||||
// prefix). The outer archive is a plain rootfs tarball.
|
// filename prefix, so a non-bundle entry still lands here).
|
||||||
log::debug!("No layers.manifest in bundle; extracting as plain rootfs tarball");
|
log::debug!("No layers.manifest in bundle; extracting as plain rootfs tarball");
|
||||||
return extract_single_archive(tarball, dest, progress);
|
return extract_single_archive(tarball, dest, progress);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,8 @@
|
|||||||
//! socket, which is what lets ecrou run inside a strict snap's seccomp
|
//! socket, which is what lets ecrou run inside a strict snap's seccomp
|
||||||
//! allowlist. See SPEC.md.
|
//! allowlist. See SPEC.md.
|
||||||
//!
|
//!
|
||||||
//! The main entry points are [`rootfs`] (cache-aware rootfs preparation
|
//! The main entry points are [`rootfs`] (cache-aware rootfs
|
||||||
//! and persistence) and [`vm`] (running a command inside a prepared
|
//! preparation) and [`vm`] (running a command inside a prepared
|
||||||
//! rootfs). Public functions return [`Result`] carrying the [`Error`]
|
//! rootfs). Public functions return [`Result`] carrying the [`Error`]
|
||||||
//! enum, so consumers can match on host-readiness failures
|
//! enum, so consumers can match on host-readiness failures
|
||||||
//! ([`Error::KvmUnavailable`], [`Error::ArchNotAvailable`]) versus
|
//! ([`Error::KvmUnavailable`], [`Error::ArchNotAvailable`]) versus
|
||||||
@@ -38,11 +38,9 @@ pub mod error;
|
|||||||
pub mod extract;
|
pub mod extract;
|
||||||
pub mod guest;
|
pub mod guest;
|
||||||
pub mod krun;
|
pub mod krun;
|
||||||
mod pack;
|
|
||||||
pub mod progress;
|
pub mod progress;
|
||||||
mod registry;
|
mod registry;
|
||||||
pub mod rootfs;
|
pub mod rootfs;
|
||||||
pub mod spec;
|
|
||||||
mod supervise;
|
mod supervise;
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
pub mod vm;
|
pub mod vm;
|
||||||
|
|||||||
@@ -1,125 +0,0 @@
|
|||||||
//! Packing a rootfs tree into the compressed tarball a cache entry
|
|
||||||
//! stores, so a provisioned tree persists and later boots skip the
|
|
||||||
//! download. The write lands in a unique partial file and renames
|
|
||||||
//! atomically; compression follows the destination's extension.
|
|
||||||
|
|
||||||
use anyhow::{anyhow, Context};
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
/// Write a tarball of `rootfs` at `dest`, compressed according to dest's
|
|
||||||
/// extension (.tar.gz, .tar.xz, .tar.zst, plain .tar), atomically via a
|
|
||||||
/// unique partial file ([`crate::utils::partial_path`]).
|
|
||||||
pub(crate) fn pack_rootfs(rootfs: &Path, dest: &Path) -> anyhow::Result<()> {
|
|
||||||
// Packing the tree into a tarball placed inside it would recurse
|
|
||||||
// forever: the walker keeps seeing the archive grow.
|
|
||||||
let rootfs_canonical = rootfs
|
|
||||||
.canonicalize()
|
|
||||||
.with_context(|| format!("Failed to resolve rootfs directory {}", rootfs.display()))?;
|
|
||||||
if let Some(parent) = dest.parent() {
|
|
||||||
if let Ok(parent_canonical) = parent.canonicalize() {
|
|
||||||
if parent_canonical.starts_with(&rootfs_canonical) {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"Cache entry {} must live outside the rootfs",
|
|
||||||
dest.display()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let filename = dest.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
|
||||||
let partial = crate::utils::partial_path(dest);
|
|
||||||
|
|
||||||
let file = std::fs::File::create(&partial)
|
|
||||||
.with_context(|| format!("Failed to create {}", partial.display()))?;
|
|
||||||
|
|
||||||
let result: anyhow::Result<()> = (|| {
|
|
||||||
if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") {
|
|
||||||
let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default());
|
|
||||||
let encoder = write_rootfs_tar(rootfs, encoder)?;
|
|
||||||
encoder.finish().context("Failed to finalise gzip stream")?;
|
|
||||||
} else if filename.ends_with(".tar.xz") || filename.ends_with(".txz") {
|
|
||||||
let encoder = xz2::write::XzEncoder::new(file, 6);
|
|
||||||
let encoder = write_rootfs_tar(rootfs, encoder)?;
|
|
||||||
encoder.finish().context("Failed to finalise xz stream")?;
|
|
||||||
} else if filename.ends_with(".tar.zst") || filename.ends_with(".tar.zstd") {
|
|
||||||
let encoder = zstd::stream::write::Encoder::new(file, 0)
|
|
||||||
.context("Failed to create zstd encoder")?;
|
|
||||||
let encoder = write_rootfs_tar(rootfs, encoder)?;
|
|
||||||
encoder.finish().context("Failed to finalise zstd stream")?;
|
|
||||||
} else {
|
|
||||||
write_rootfs_tar(rootfs, file)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
})();
|
|
||||||
|
|
||||||
if result.is_err() {
|
|
||||||
// Best-effort cleanup; ignore errors (file may not exist if creation failed).
|
|
||||||
let _ = std::fs::remove_file(&partial);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::fs::rename(&partial, dest)
|
|
||||||
.with_context(|| format!("Failed to move packed rootfs to {}", dest.display()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write the rootfs tree into a tar archive streamed to `writer`, returning
|
|
||||||
/// the writer so the caller can finalise the compression stream.
|
|
||||||
fn write_rootfs_tar<W: std::io::Write>(rootfs: &Path, writer: W) -> anyhow::Result<W> {
|
|
||||||
let mut builder = tar::Builder::new(writer);
|
|
||||||
// Store symlinks as symlinks; following them would duplicate the target's
|
|
||||||
// contents into the archive (and recurse into symlinked directories).
|
|
||||||
builder.follow_symlinks(false);
|
|
||||||
|
|
||||||
let entries = std::fs::read_dir(rootfs)
|
|
||||||
.with_context(|| format!("Failed to read {}", rootfs.display()))?;
|
|
||||||
|
|
||||||
for entry in entries {
|
|
||||||
let entry =
|
|
||||||
entry.with_context(|| format!("Failed to read entry in {}", rootfs.display()))?;
|
|
||||||
let path = entry.path();
|
|
||||||
let name = entry.file_name();
|
|
||||||
|
|
||||||
// symlink_metadata: never follow symlinks — a top-level symlink to a
|
|
||||||
// directory must be stored as a link, not recursed into.
|
|
||||||
let meta = std::fs::symlink_metadata(&path)
|
|
||||||
.with_context(|| format!("Failed to stat {}", path.display()))?;
|
|
||||||
|
|
||||||
if meta.is_dir() {
|
|
||||||
builder
|
|
||||||
.append_dir_all(&name, &path)
|
|
||||||
.with_context(|| format!("Failed to archive {}", path.display()))?;
|
|
||||||
} else {
|
|
||||||
builder
|
|
||||||
.append_path_with_name(&path, &name)
|
|
||||||
.with_context(|| format!("Failed to archive {}", path.display()))?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
builder
|
|
||||||
.into_inner()
|
|
||||||
.context("Failed to finalise tar archive")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pack_failure_leaves_no_partial_file() {
|
|
||||||
// A nonexistent rootfs must fail cleanly and clean up its
|
|
||||||
// partial file, whatever unique name it carried.
|
|
||||||
let cache = tempfile::tempdir().unwrap();
|
|
||||||
let dest = cache.path().join("x.tar.gz");
|
|
||||||
assert!(pack_rootfs(Path::new("/nonexistent-rootfs"), &dest).is_err());
|
|
||||||
assert!(!dest.exists());
|
|
||||||
let leftovers: Vec<String> = std::fs::read_dir(cache.path())
|
|
||||||
.unwrap()
|
|
||||||
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
|
|
||||||
.filter(|name| name.contains("partial"))
|
|
||||||
.collect();
|
|
||||||
assert!(
|
|
||||||
leftovers.is_empty(),
|
|
||||||
"partial files left behind: {leftovers:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+23
-208
@@ -1,27 +1,17 @@
|
|||||||
//! Cache-aware rootfs lifecycle: resolve an image reference, download
|
//! Cache-aware rootfs lifecycle: resolve an image reference, download
|
||||||
//! through the tarball cache, extract into a scratch directory, and
|
//! through the tarball cache, and extract into a scratch directory for
|
||||||
//! optionally persist a provisioned rootfs back into the cache.
|
//! [`crate::vm`] to boot.
|
||||||
//!
|
//!
|
||||||
//! The hot-cell flow is [`RootfsCache::prepare_provisioned`]: provision
|
//! The run path is [`RootfsCache::prepare`]: a present cache entry is
|
||||||
//! once (package installs, user setup, …), and every subsequent call —
|
//! trusted as-is — the registry is never contacted — and the freshness
|
||||||
//! in this or another process sharing the cache — hits the persisted
|
//! decision belongs to [`RootfsCache::pull`]:
|
||||||
//! entry and skips both the download and the provisioning step:
|
|
||||||
//!
|
//!
|
||||||
//! ```no_run
|
//! ```no_run
|
||||||
//! use ecrou::{NoProgress, PrepareRequest, RootfsCache};
|
//! use ecrou::{NoProgress, PrepareRequest, RootfsCache};
|
||||||
//!
|
//!
|
||||||
//! # fn main() -> anyhow::Result<()> {
|
//! # fn main() -> anyhow::Result<()> {
|
||||||
//! let cache = RootfsCache::new(RootfsCache::default_dir()?);
|
//! let cache = RootfsCache::new(RootfsCache::default_dir()?);
|
||||||
//! let req = PrepareRequest::new("alpine:3.23");
|
//! let rootfs = cache.prepare(&PrepareRequest::new("alpine:3.23"), &NoProgress)?;
|
||||||
//!
|
|
||||||
//! let rootfs = cache.prepare_provisioned(&req, &NoProgress, |rootfs| {
|
|
||||||
//! // Provision by editing the tree host-side. In-rootfs commands
|
|
||||||
//! // arrive with vm::run, which boots the provisioned rootfs.
|
|
||||||
//! std::fs::write(rootfs.dir().join("etc/motd"), "provisioned by ecrou\n")?;
|
|
||||||
//! Ok(())
|
|
||||||
//! })?;
|
|
||||||
//!
|
|
||||||
//! // Amortized runs hit the provisioned entry directly
|
|
||||||
//! let dir = rootfs.dir().to_path_buf();
|
//! let dir = rootfs.dir().to_path_buf();
|
||||||
//! # let _ = dir; Ok(())
|
//! # let _ = dir; Ok(())
|
||||||
//! # }
|
//! # }
|
||||||
@@ -31,11 +21,9 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
use anyhow::{anyhow, Context};
|
use anyhow::{anyhow, Context};
|
||||||
|
|
||||||
use crate::cache::{
|
use crate::cache::{extract_prepared, oci_freshness_changed, EntryLock, RootfsCache};
|
||||||
extract_prepared, fill_entry, oci_freshness_changed, sidecar, EntryLock, RootfsCache,
|
|
||||||
PROVISIONED_SUFFIX,
|
|
||||||
};
|
|
||||||
use crate::distro::{resolve_source, ImageSource};
|
use crate::distro::{resolve_source, ImageSource};
|
||||||
|
use crate::download::download_image;
|
||||||
use crate::error::Error;
|
use crate::error::Error;
|
||||||
use crate::progress::Progress;
|
use crate::progress::Progress;
|
||||||
use crate::Result;
|
use crate::Result;
|
||||||
@@ -63,14 +51,13 @@ impl RootfsCache {
|
|||||||
/// pulls by default, because there is nothing to boot otherwise.
|
/// pulls by default, because there is nothing to boot otherwise.
|
||||||
pub fn prepare(&self, req: &PrepareRequest, progress: &dyn Progress) -> Result<PreparedRootfs> {
|
pub fn prepare(&self, req: &PrepareRequest, progress: &dyn Progress) -> Result<PreparedRootfs> {
|
||||||
let (source, cache_path, _) = self.ensure_entry(req, progress, false)?;
|
let (source, cache_path, _) = self.ensure_entry(req, progress, false)?;
|
||||||
// A local archive bypasses the cache: the file is the pristine
|
// A local archive bypasses the cache: the file is the source
|
||||||
// source on every run (--no-cache has nothing to say about it),
|
// on every run (--no-cache has nothing to say about it), and
|
||||||
// and the derived entry name only matters as the place a
|
// the derived entry name only matters for diagnostics.
|
||||||
// provisioning over it would persist into.
|
|
||||||
if let ImageSource::LocalTarball { path } = &source {
|
if let ImageSource::LocalTarball { path } = &source {
|
||||||
return extract_prepared(path, cache_path, progress).map_err(Error::Other);
|
return extract_prepared(path, progress).map_err(Error::Other);
|
||||||
}
|
}
|
||||||
Ok(extract_prepared(&cache_path.clone(), cache_path, progress)?)
|
Ok(extract_prepared(&cache_path, progress)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch or refresh the image's cache entry without extracting it:
|
/// Fetch or refresh the image's cache entry without extracting it:
|
||||||
@@ -137,7 +124,7 @@ impl RootfsCache {
|
|||||||
format!("Failed to create cache directory {}", self.dir.display())
|
format!("Failed to create cache directory {}", self.dir.display())
|
||||||
})?;
|
})?;
|
||||||
if req.no_cache {
|
if req.no_cache {
|
||||||
fill_entry(&source, &cache_path, &arch, progress)?;
|
download_image(&source, &cache_path, &arch, progress)?;
|
||||||
downloaded = true;
|
downloaded = true;
|
||||||
} else {
|
} else {
|
||||||
// One writer per entry: a concurrent run preparing the
|
// One writer per entry: a concurrent run preparing the
|
||||||
@@ -151,7 +138,7 @@ impl RootfsCache {
|
|||||||
let stale = !cache_path.exists()
|
let stale = !cache_path.exists()
|
||||||
|| (oci_digest_changed && oci_freshness_changed(&source, &cache_path));
|
|| (oci_digest_changed && oci_freshness_changed(&source, &cache_path));
|
||||||
if stale {
|
if stale {
|
||||||
fill_entry(&source, &cache_path, &arch, progress)?;
|
download_image(&source, &cache_path, &arch, progress)?;
|
||||||
downloaded = true;
|
downloaded = true;
|
||||||
} else {
|
} else {
|
||||||
log::debug!(
|
log::debug!(
|
||||||
@@ -168,71 +155,7 @@ impl RootfsCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RootfsCache {
|
/// Request for [`RootfsCache::prepare`] / [`RootfsCache::pull`].
|
||||||
/// 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
|
|
||||||
/// once, and the result is persisted into the cache so subsequent calls
|
|
||||||
/// (including from other processes sharing this cache) skip both the
|
|
||||||
/// download and the provisioning step.
|
|
||||||
///
|
|
||||||
/// 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,
|
|
||||||
progress: &dyn Progress,
|
|
||||||
provision: F,
|
|
||||||
) -> Result<PreparedRootfs>
|
|
||||||
where
|
|
||||||
F: FnOnce(&PreparedRootfs) -> anyhow::Result<()>,
|
|
||||||
{
|
|
||||||
let arch = req.resolved_arch();
|
|
||||||
let source = resolve_source(&req.image, &arch)?;
|
|
||||||
let cache_path = self.entry_path(&source, &arch, req.layer_key.as_deref())?;
|
|
||||||
let marker = sidecar(&cache_path, PROVISIONED_SUFFIX);
|
|
||||||
|
|
||||||
if !req.no_cache && cache_path.exists() && marker.exists() {
|
|
||||||
log::debug!("Using provisioned cache entry: {}", cache_path.display());
|
|
||||||
return Ok(extract_prepared(&cache_path.clone(), cache_path, progress)?);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The miss path prepares the PRISTINE base — a keyed entry
|
|
||||||
// has no tarball of its own to prepare from, and the base
|
|
||||||
// must download if missing. The key is this call's business,
|
|
||||||
// never prepare's.
|
|
||||||
let mut base_req = req.clone();
|
|
||||||
base_req.layer_key = None;
|
|
||||||
let rootfs = self.prepare(&base_req, progress)?;
|
|
||||||
provision(&rootfs)?;
|
|
||||||
if req.layer_key.is_some() {
|
|
||||||
// The prepared scratch is the BASE (a keyed entry has no
|
|
||||||
// tarball to prepare from): the provisioned tree lands in
|
|
||||||
// the keyed entry, not over the base.
|
|
||||||
rootfs.persist_to(&cache_path).with_context(|| {
|
|
||||||
format!(
|
|
||||||
"Failed to persist provisioned layer to {}",
|
|
||||||
cache_path.display()
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
} else {
|
|
||||||
rootfs.persist().with_context(|| {
|
|
||||||
format!(
|
|
||||||
"Failed to persist provisioned rootfs to {}",
|
|
||||||
cache_path.display()
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
Ok(rootfs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Request for [`RootfsCache::prepare`] / [`RootfsCache::pull`] /
|
|
||||||
/// [`RootfsCache::prepare_provisioned`].
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct PrepareRequest {
|
pub struct PrepareRequest {
|
||||||
/// Image reference: a distro name ("ubuntu", "ubuntu:noble",
|
/// Image reference: a distro name ("ubuntu", "ubuntu:noble",
|
||||||
@@ -246,12 +169,6 @@ pub struct PrepareRequest {
|
|||||||
|
|
||||||
/// Ignore the cache and download a fresh image.
|
/// Ignore the cache and download a fresh image.
|
||||||
pub no_cache: bool,
|
pub no_cache: bool,
|
||||||
|
|
||||||
/// A layer key: when set, the cache entry is the keyed layer over
|
|
||||||
/// the image rather than the image itself — the entry is never
|
|
||||||
/// downloaded, only provisioned into existence
|
|
||||||
/// ([`RootfsCache::prepare_provisioned`]).
|
|
||||||
pub layer_key: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PrepareRequest {
|
impl PrepareRequest {
|
||||||
@@ -260,7 +177,6 @@ impl PrepareRequest {
|
|||||||
image: image.into(),
|
image: image.into(),
|
||||||
arch: String::new(),
|
arch: String::new(),
|
||||||
no_cache: false,
|
no_cache: false,
|
||||||
layer_key: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,12 +191,11 @@ impl PrepareRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A rootfs extracted into a scratch directory, ready for
|
/// A rootfs extracted into a scratch directory, ready for
|
||||||
/// [`crate::vm::run`] to boot. The directory is removed when this value is
|
/// [`crate::vm::run`] to boot. The directory is removed when this
|
||||||
/// dropped, unless [`PreparedRootfs::persist`] ran first.
|
/// value is dropped.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct PreparedRootfs {
|
pub struct PreparedRootfs {
|
||||||
pub(crate) rootfs: TempDir,
|
pub(crate) rootfs: TempDir,
|
||||||
pub(crate) cache_path: PathBuf,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PreparedRootfs {
|
impl PreparedRootfs {
|
||||||
@@ -288,39 +203,12 @@ impl PreparedRootfs {
|
|||||||
pub fn dir(&self) -> &Path {
|
pub fn dir(&self) -> &Path {
|
||||||
self.rootfs.path()
|
self.rootfs.path()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persist the current contents of the rootfs into the cache entry it
|
|
||||||
/// was prepared from, replacing the pristine image tarball, and mark
|
|
||||||
/// the entry provisioned. Later preparations for the same image and
|
|
||||||
/// architecture hit this entry directly; note that plain
|
|
||||||
/// [`RootfsCache::prepare`] also returns the provisioned contents
|
|
||||||
/// (indistinguishable from a pristine hit) — only
|
|
||||||
/// [`RootfsCache::prepare_provisioned`] interprets the marker.
|
|
||||||
pub fn persist(&self) -> Result<()> {
|
|
||||||
self.persist_to(&self.cache_path)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Packs the tree into an arbitrary cache entry — how a provisioned
|
|
||||||
/// layer lands under its own key while the prepared scratch still
|
|
||||||
/// carries the base's own cache path.
|
|
||||||
pub fn persist_to(&self, cache_path: &Path) -> Result<()> {
|
|
||||||
crate::pack::pack_rootfs(self.dir(), cache_path)?;
|
|
||||||
std::fs::write(sidecar(cache_path, PROVISIONED_SUFFIX), b"").with_context(|| {
|
|
||||||
format!(
|
|
||||||
"Failed to write provisioned marker for {}",
|
|
||||||
cache_path.display()
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
log::debug!("Persisted rootfs to cache: {}", cache_path.display());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::progress::NoProgress;
|
use crate::progress::NoProgress;
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pull_rejects_local_archives() {
|
fn pull_rejects_local_archives() {
|
||||||
@@ -387,83 +275,6 @@ mod tests {
|
|||||||
assert_eq!(req.resolved_arch(), "arm64");
|
assert_eq!(req.resolved_arch(), "arm64");
|
||||||
}
|
}
|
||||||
|
|
||||||
fn roundtrip(dest_name: &str) {
|
|
||||||
let src = tempfile::tempdir().unwrap();
|
|
||||||
std::fs::write(src.path().join("hello.txt"), b"hello").unwrap();
|
|
||||||
std::fs::create_dir_all(src.path().join("usr/bin")).unwrap();
|
|
||||||
std::fs::write(src.path().join("usr/bin/tool"), b"#!/bin/sh\n").unwrap();
|
|
||||||
std::fs::set_permissions(
|
|
||||||
src.path().join("usr/bin/tool"),
|
|
||||||
std::fs::Permissions::from_mode(0o755),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
std::os::unix::fs::symlink("hello.txt", src.path().join("link.txt")).unwrap();
|
|
||||||
|
|
||||||
let cache = tempfile::tempdir().unwrap();
|
|
||||||
let dest = cache.path().join(dest_name);
|
|
||||||
crate::pack::pack_rootfs(src.path(), &dest).unwrap();
|
|
||||||
|
|
||||||
let out = tempfile::tempdir().unwrap();
|
|
||||||
crate::extract::extract_tarball(&dest, out.path(), &NoProgress).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
std::fs::read(out.path().join("hello.txt")).unwrap(),
|
|
||||||
b"hello"
|
|
||||||
);
|
|
||||||
let mode = std::fs::metadata(out.path().join("usr/bin/tool"))
|
|
||||||
.unwrap()
|
|
||||||
.permissions()
|
|
||||||
.mode();
|
|
||||||
assert_eq!(mode & 0o777, 0o755, "executable bit must survive");
|
|
||||||
assert_eq!(
|
|
||||||
std::fs::read_link(out.path().join("link.txt")).unwrap(),
|
|
||||||
Path::new("hello.txt")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn persist_roundtrip_gzip() {
|
|
||||||
roundtrip("rootfs.tar.gz");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn persist_roundtrip_xz() {
|
|
||||||
roundtrip("rootfs.tar.xz");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn persist_roundtrip_zstd() {
|
|
||||||
roundtrip("rootfs.tar.zst");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn persist_roundtrip_plain_tar() {
|
|
||||||
roundtrip("rootfs.tar");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn persist_writes_entry_and_provisioned_marker() {
|
|
||||||
let rootfs_dir = tempfile::tempdir().unwrap();
|
|
||||||
std::fs::write(rootfs_dir.path().join("file.txt"), b"data").unwrap();
|
|
||||||
|
|
||||||
let cache_dir = tempfile::tempdir().unwrap();
|
|
||||||
let cache_path = cache_dir.path().join("ubuntu-noble-amd64.tar.gz");
|
|
||||||
|
|
||||||
let prepared = PreparedRootfs {
|
|
||||||
rootfs: rootfs_dir,
|
|
||||||
cache_path: cache_path.clone(),
|
|
||||||
};
|
|
||||||
prepared.persist().unwrap();
|
|
||||||
|
|
||||||
assert!(cache_path.exists());
|
|
||||||
assert!(sidecar(&cache_path, PROVISIONED_SUFFIX).exists());
|
|
||||||
|
|
||||||
// The persisted entry extracts back to the original contents
|
|
||||||
let out = tempfile::tempdir().unwrap();
|
|
||||||
crate::extract::extract_tarball(&cache_path, out.path(), &NoProgress).unwrap();
|
|
||||||
assert_eq!(std::fs::read(out.path().join("file.txt")).unwrap(), b"data");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_local_archive_prepares_without_touching_the_cache() {
|
fn a_local_archive_prepares_without_touching_the_cache() {
|
||||||
// A rootfs tree, packed next to itself as ./rootfs.tar.gz, the
|
// A rootfs tree, packed next to itself as ./rootfs.tar.gz, the
|
||||||
@@ -473,7 +284,11 @@ mod tests {
|
|||||||
std::fs::create_dir_all(&tree).unwrap();
|
std::fs::create_dir_all(&tree).unwrap();
|
||||||
std::fs::write(tree.join("etc-marker"), b"local").unwrap();
|
std::fs::write(tree.join("etc-marker"), b"local").unwrap();
|
||||||
let tarball = work.path().join("rootfs.tar.gz");
|
let tarball = work.path().join("rootfs.tar.gz");
|
||||||
crate::pack::pack_rootfs(&tree, &tarball).unwrap();
|
let file = std::fs::File::create(&tarball).unwrap();
|
||||||
|
let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default());
|
||||||
|
let mut tar = tar::Builder::new(encoder);
|
||||||
|
tar.append_dir_all(".", &tree).unwrap();
|
||||||
|
tar.into_inner().unwrap().finish().unwrap();
|
||||||
|
|
||||||
let cache_dir = tempfile::tempdir().unwrap();
|
let cache_dir = tempfile::tempdir().unwrap();
|
||||||
let cache = RootfsCache::new(cache_dir.path().to_path_buf());
|
let cache = RootfsCache::new(cache_dir.path().to_path_buf());
|
||||||
|
|||||||
@@ -1,147 +0,0 @@
|
|||||||
//! Provisioning specs: the checked-in description of what a layer
|
|
||||||
//! carries beyond its base image. A spec is data, not a command —
|
|
||||||
//! the artifact under review is a diff in git, and the layer it
|
|
||||||
//! produces is a pure function of the file's content and its base.
|
|
||||||
|
|
||||||
use serde::Deserialize;
|
|
||||||
use std::fmt::Write as _;
|
|
||||||
|
|
||||||
/// A parsed provisioning spec file (YAML: base, packages, run).
|
|
||||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
|
||||||
pub struct ProvisionSpec {
|
|
||||||
/// The image to provision over. Optional: without it the caller's
|
|
||||||
/// positional image is the base.
|
|
||||||
#[serde(default)]
|
|
||||||
pub base: Option<String>,
|
|
||||||
|
|
||||||
/// Packages installed through whatever package manager the base
|
|
||||||
/// carries (apt, apk, dnf).
|
|
||||||
#[serde(default)]
|
|
||||||
pub packages: Vec<String>,
|
|
||||||
|
|
||||||
/// A shell script run after the packages, for everything a
|
|
||||||
/// package name cannot say.
|
|
||||||
#[serde(default)]
|
|
||||||
pub run: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ProvisionSpec {
|
|
||||||
/// Parses the spec from raw file bytes. Empty and missing optional
|
|
||||||
/// fields stay optional; an unparseable file is an error — a typo
|
|
||||||
/// in the spec must not silently provision a bare base.
|
|
||||||
pub fn parse(bytes: &[u8]) -> anyhow::Result<Self> {
|
|
||||||
let spec: ProvisionSpec = serde_yaml::from_slice(bytes)
|
|
||||||
.map_err(|err| anyhow::anyhow!("the provisioning spec does not parse: {err}"))?;
|
|
||||||
if spec.base.is_none() && spec.packages.is_empty() && spec.run.is_none() {
|
|
||||||
anyhow::bail!("the provisioning spec is empty");
|
|
||||||
}
|
|
||||||
Ok(spec)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The layer key: content the cache entries off. Hash of the raw
|
|
||||||
/// file bytes and the base reference — the bytes, not the parsed
|
|
||||||
/// form, so the key is stable across ecrou versions; reformatting
|
|
||||||
/// the file rebuilds the layer, which is harmless and visible.
|
|
||||||
pub fn key(spec_bytes: &[u8], base_ref: &str) -> String {
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(spec_bytes);
|
|
||||||
hasher.update([0]);
|
|
||||||
hasher.update(base_ref.as_bytes());
|
|
||||||
let digest = hasher.finalize();
|
|
||||||
digest[..8].iter().fold(String::new(), |mut out, byte| {
|
|
||||||
let _ = write!(out, "{byte:02x}");
|
|
||||||
out
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The provisioning script: the packages through the base's
|
|
||||||
/// package manager, then the raw `run` block. Nothing here knows
|
|
||||||
/// which distro it lands on ahead of time — the machine itself
|
|
||||||
/// says, at provision time.
|
|
||||||
pub fn script(&self) -> String {
|
|
||||||
let mut script = String::from("set -e\n");
|
|
||||||
if !self.packages.is_empty() {
|
|
||||||
let packages = self.packages.join(" ");
|
|
||||||
let _ = write!(
|
|
||||||
script,
|
|
||||||
r#"if command -v apt-get >/dev/null 2>&1; then
|
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
|
||||||
apt-get update -qq
|
|
||||||
apt-get install -y -qq --no-install-recommends {packages}
|
|
||||||
elif command -v apk >/dev/null 2>&1; then
|
|
||||||
apk add --no-cache {packages}
|
|
||||||
elif command -v dnf >/dev/null 2>&1; then
|
|
||||||
dnf install -y {packages}
|
|
||||||
else
|
|
||||||
echo "no known package manager in the base image" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
"#
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if let Some(run) = &self.run {
|
|
||||||
script.push_str(run.trim_end());
|
|
||||||
script.push('\n');
|
|
||||||
}
|
|
||||||
script
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_spec_parses_with_all_parts_optional_but_none_empty() {
|
|
||||||
let spec = ProvisionSpec::parse(b"packages:\n - jq\n").expect("parses");
|
|
||||||
assert_eq!(spec.packages, vec!["jq".to_owned()]);
|
|
||||||
assert!(spec.base.is_none());
|
|
||||||
|
|
||||||
let full = ProvisionSpec::parse(
|
|
||||||
b"base: ghcr.io/example/base:v1\npackages:\n - go\nrun: |\n cargo install just\n",
|
|
||||||
)
|
|
||||||
.expect("parses");
|
|
||||||
assert_eq!(full.base.as_deref(), Some("ghcr.io/example/base:v1"));
|
|
||||||
assert!(full.run.as_deref().unwrap().contains("cargo install"));
|
|
||||||
|
|
||||||
assert!(ProvisionSpec::parse(b"\n").is_err(), "empty is an error");
|
|
||||||
assert!(
|
|
||||||
ProvisionSpec::parse(b"packages: [\n").is_err(),
|
|
||||||
"unparseable yaml is an error"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_key_tracks_the_spec_bytes_and_the_base() {
|
|
||||||
let a = ProvisionSpec::key(b"packages: [jq]\n", "alpine:3.23");
|
|
||||||
assert_eq!(a.len(), 16);
|
|
||||||
assert_eq!(a, ProvisionSpec::key(b"packages: [jq]\n", "alpine:3.23"));
|
|
||||||
assert_ne!(a, ProvisionSpec::key(b"packages: [jq]\n", "alpine:3.24"));
|
|
||||||
assert_ne!(a, ProvisionSpec::key(b"packages: [jq] \n", "alpine:3.23"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_script_speaks_the_base_package_manager_and_then_runs() {
|
|
||||||
let spec = ProvisionSpec {
|
|
||||||
base: None,
|
|
||||||
packages: vec!["jq".into(), "go".into()],
|
|
||||||
run: Some("go install m/tool@latest\n".into()),
|
|
||||||
};
|
|
||||||
let script = spec.script();
|
|
||||||
assert!(script.starts_with("set -e"));
|
|
||||||
assert!(script.contains("apt-get install -y -qq --no-install-recommends jq go"));
|
|
||||||
assert!(script.contains("apk add --no-cache jq go"));
|
|
||||||
assert!(script.ends_with("go install m/tool@latest\n"));
|
|
||||||
|
|
||||||
// packages only: no pm block would try to install nothing
|
|
||||||
let bare = ProvisionSpec {
|
|
||||||
packages: vec![],
|
|
||||||
run: Some("echo hi".into()),
|
|
||||||
base: None,
|
|
||||||
};
|
|
||||||
let script = bare.script();
|
|
||||||
assert!(!script.contains("apt-get"));
|
|
||||||
assert!(script.contains("echo hi"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
-24
@@ -55,11 +55,6 @@ pub struct RunOptions {
|
|||||||
/// How much of the engine's own logging reaches stderr
|
/// How much of the engine's own logging reaches stderr
|
||||||
pub log_level: LogLevel,
|
pub log_level: LogLevel,
|
||||||
pub no_cache: bool,
|
pub no_cache: bool,
|
||||||
/// When set, boot the named provisioned layer from the cache
|
|
||||||
/// instead of resolving an image: no download, no resolution —
|
|
||||||
/// the layer is whatever `ecrou provision --spec … --layer …`
|
|
||||||
/// persisted under that name.
|
|
||||||
pub layer: Option<String>,
|
|
||||||
/// When set, the payload's stdout and stderr are captured into
|
/// When set, the payload's stdout and stderr are captured into
|
||||||
/// files in this host directory (`stdout` and `stderr` under it):
|
/// files in this host directory (`stdout` and `stderr` under it):
|
||||||
/// the run adds the directory as an internal read-write share,
|
/// the run adds the directory as an internal read-write share,
|
||||||
@@ -101,7 +96,6 @@ impl Default for RunOptions {
|
|||||||
tty: false,
|
tty: false,
|
||||||
log_level: LogLevel::Normal,
|
log_level: LogLevel::Normal,
|
||||||
no_cache: false,
|
no_cache: false,
|
||||||
layer: None,
|
|
||||||
capture: None,
|
capture: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,20 +103,6 @@ impl Default for RunOptions {
|
|||||||
|
|
||||||
/// Run one machine to completion and return the payload's exit code.
|
/// Run one machine to completion and return the payload's exit code.
|
||||||
pub fn run(options: &RunOptions, progress: &dyn Progress) -> Result<i32> {
|
pub fn run(options: &RunOptions, progress: &dyn Progress) -> Result<i32> {
|
||||||
// A named layer boots straight from its cache entry: no image
|
|
||||||
// resolution, no download — the layer is whatever the provision
|
|
||||||
// call persisted under that name.
|
|
||||||
if let Some(name) = &options.layer {
|
|
||||||
let cache = RootfsCache::new(RootfsCache::default_dir()?);
|
|
||||||
let rootfs = cache.extract_layer(name, progress)?;
|
|
||||||
// Downstream naming (the hostname, diagnostics) rides the
|
|
||||||
// layer name: the entry is what booted, not an image.
|
|
||||||
let options = RunOptions {
|
|
||||||
image: name.clone(),
|
|
||||||
..options.clone()
|
|
||||||
};
|
|
||||||
return run_prepared(&rootfs, &options);
|
|
||||||
}
|
|
||||||
let mut request = PrepareRequest::new(options.image.clone());
|
let mut request = PrepareRequest::new(options.image.clone());
|
||||||
request.arch = options.arch.clone();
|
request.arch = options.arch.clone();
|
||||||
request.no_cache = options.no_cache;
|
request.no_cache = options.no_cache;
|
||||||
@@ -133,10 +113,8 @@ pub fn run(options: &RunOptions, progress: &dyn Progress) -> Result<i32> {
|
|||||||
run_prepared(&rootfs, options)
|
run_prepared(&rootfs, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Boot one machine over an already-prepared rootfs. This is the whole
|
/// Boot one machine over an already-prepared rootfs. This is the
|
||||||
/// boot half of [`run`], and the engine the provisioner drives: it
|
/// boot half of [`run`]: prepare lands the tree, this boots it.
|
||||||
/// wants a machine over the extracted tree it is about to persist,
|
|
||||||
/// before that tree becomes the cache entry every later boot sees.
|
|
||||||
pub fn run_prepared(rootfs: &PreparedRootfs, options: &RunOptions) -> Result<i32> {
|
pub fn run_prepared(rootfs: &PreparedRootfs, options: &RunOptions) -> Result<i32> {
|
||||||
if options.image.is_empty() {
|
if options.image.is_empty() {
|
||||||
return Err(Error::Other(anyhow!("no image given")));
|
return Err(Error::Other(anyhow!("no image given")));
|
||||||
|
|||||||
Reference in new issue
Block a user