mirror of
https://github.com/vhaudiquet/ecrou.git
synced 2026-11-12 22:22:44 +00:00
feat(cli): provision layers from a spec file
A provision command baked by hand - `ecrou provision alpine -- apk add ...` - cannot be reviewed or shared: the artifact worth keeping is a diff in git, not a shell history line. A spec file is that diff. `ecrou provision --spec image.yaml` parses base, packages and a run block, derives the provisioning script from the base's own package manager (apt, apk, dnf, discovered at provision time on the running machine), and bakes the result into a cache entry keyed by the spec's content hash: the same file never provisions twice, an edited file never reuses the old layer, and the key hashes the raw bytes so the layer is stable across ecrou versions. `--layer NAME` names the entry, and `ecrou run --layer NAME` boots it directly - no image resolution, no download; the layer is whatever the provision call persisted under that name. A keyed entry has no tarball of its own, so the base downloads only on the first provisioning and every later run extracts the finished layer.
This commit is contained in:
1 parent
dad5f61833
commit
878ce29b8d
7 files changed
+449
-25
No files matched your search
@@ -27,9 +27,20 @@ pub enum Command {
|
|||||||
/// command installed. Cached entries are reused as-is; --no-cache
|
/// command installed. Cached entries are reused as-is; --no-cache
|
||||||
/// re-downloads and re-provisions.
|
/// re-downloads and re-provisions.
|
||||||
Provision {
|
Provision {
|
||||||
/// Distribution name or OCI image reference
|
/// Distribution name or OCI image reference; the spec's `base`
|
||||||
image: String,
|
/// when omitted and the spec carries one
|
||||||
/// Ignore the cache and download a fresh image
|
image: Option<String>,
|
||||||
|
/// A provisioning spec file (YAML: base, packages, run) — the
|
||||||
|
/// layer it describes is keyed by the file's content, so the
|
||||||
|
/// same spec never provisions twice and a changed spec never
|
||||||
|
/// reuses an old layer
|
||||||
|
#[arg(long)]
|
||||||
|
spec: Option<std::path::PathBuf>,
|
||||||
|
/// Name the provisioned layer, so runs can boot it by name
|
||||||
|
/// (`run --layer NAME`) without re-deriving anything
|
||||||
|
#[arg(long)]
|
||||||
|
layer: Option<String>,
|
||||||
|
/// Ignore the cache: re-download the base and re-provision
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
no_cache: bool,
|
no_cache: bool,
|
||||||
/// The provisioning command. An argument vector after `--` is
|
/// The provisioning command. An argument vector after `--` is
|
||||||
@@ -44,8 +55,10 @@ pub enum Command {
|
|||||||
|
|
||||||
#[derive(Args, Debug)]
|
#[derive(Args, Debug)]
|
||||||
pub struct RunArgs {
|
pub struct RunArgs {
|
||||||
/// Distribution name or OCI image reference
|
/// Distribution name or OCI image reference; not needed with
|
||||||
pub image: String,
|
/// `--layer`, which boots a provisioned cache entry
|
||||||
|
#[arg(required_unless_present = "layer")]
|
||||||
|
pub image: Option<String>,
|
||||||
/// Command to run in the machine; the image's default shell when
|
/// Command to run in the machine; the image's default shell when
|
||||||
/// omitted. An argument vector after `--` is taken literally; a
|
/// omitted. An argument vector after `--` is taken literally; a
|
||||||
/// single quoted line runs through the image's shell.
|
/// single quoted line runs through the image's shell.
|
||||||
@@ -73,6 +86,10 @@ pub struct RunArgs {
|
|||||||
/// Ignore the cache and download a fresh image
|
/// Ignore the cache and download a fresh image
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub no_cache: bool,
|
pub no_cache: bool,
|
||||||
|
/// Boot a named provisioned layer (see `provision --layer`)
|
||||||
|
/// instead of resolving an image
|
||||||
|
#[arg(long)]
|
||||||
|
pub layer: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse one `HOST:GUEST[:ro]` share specification. The guest path must
|
/// Parse one `HOST:GUEST[:ro]` share specification. The guest path must
|
||||||
|
|||||||
@@ -20,34 +20,43 @@ fn main() -> std::process::ExitCode {
|
|||||||
Command::Run(args) => cmd_run(&args, cli.verbose),
|
Command::Run(args) => cmd_run(&args, cli.verbose),
|
||||||
Command::Provision {
|
Command::Provision {
|
||||||
image,
|
image,
|
||||||
|
spec,
|
||||||
|
layer,
|
||||||
no_cache,
|
no_cache,
|
||||||
command,
|
command,
|
||||||
} => cmd_provision(&image, no_cache, &command, cli.verbose),
|
} => cmd_provision(image, spec, layer, no_cache, &command, cli.verbose),
|
||||||
Command::Probe => cmd_probe(),
|
Command::Probe => cmd_probe(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_provision(
|
fn cmd_provision(
|
||||||
image: &str,
|
image: Option<String>,
|
||||||
|
spec: Option<std::path::PathBuf>,
|
||||||
|
layer: Option<String>,
|
||||||
no_cache: bool,
|
no_cache: bool,
|
||||||
command: &[String],
|
command: &[String],
|
||||||
cli_verbose: bool,
|
cli_verbose: bool,
|
||||||
) -> std::process::ExitCode {
|
) -> std::process::ExitCode {
|
||||||
use ecrou::rootfs::{PrepareRequest, RootfsCache};
|
use ecrou::rootfs::{PrepareRequest, RootfsCache};
|
||||||
|
use ecrou::spec::ProvisionSpec;
|
||||||
|
|
||||||
if command.is_empty() {
|
// 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!(
|
return fail(&anyhow::anyhow!(
|
||||||
"provision needs a command: ecrou provision <IMAGE> -- <COMMAND...>"
|
"provision needs either a spec file (--spec) or a command: \
|
||||||
|
ecrou provision <IMAGE> -- <COMMAND...>"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let level = if cli_verbose {
|
let level = if cli_verbose {
|
||||||
LogLevel::Verbose
|
LogLevel::Verbose
|
||||||
} else {
|
} else {
|
||||||
LogLevel::Normal
|
LogLevel::Normal
|
||||||
};
|
};
|
||||||
let mut options = RunOptions {
|
let mut options = RunOptions {
|
||||||
image: image.to_owned(),
|
|
||||||
command: command.to_owned(),
|
|
||||||
log_level: level,
|
log_level: level,
|
||||||
// The machine exists to change the rootfs, not to cross a
|
// The machine exists to change the rootfs, not to cross a
|
||||||
// share: the workspace would ride the persist into the cache.
|
// share: the workspace would ride the persist into the cache.
|
||||||
@@ -56,16 +65,61 @@ fn cmd_provision(
|
|||||||
options.workdir = "/".to_owned();
|
options.workdir = "/".to_owned();
|
||||||
|
|
||||||
let progress: &dyn Progress = &TermProgress::new();
|
let progress: &dyn Progress = &TermProgress::new();
|
||||||
let request = PrepareRequest {
|
|
||||||
image: image.to_owned(),
|
|
||||||
arch: String::new(),
|
|
||||||
no_cache,
|
|
||||||
};
|
|
||||||
let dir = match RootfsCache::default_dir() {
|
let dir = match RootfsCache::default_dir() {
|
||||||
Ok(dir) => dir,
|
Ok(dir) => dir,
|
||||||
Err(err) => return fail(&anyhow::Error::new(err)),
|
Err(err) => return fail(&anyhow::Error::new(err)),
|
||||||
};
|
};
|
||||||
let cache = RootfsCache::new(dir);
|
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 outcome = cache.prepare_provisioned(&request, progress, |rootfs| {
|
||||||
let code = ecrou::vm::run_prepared(rootfs, &options)?;
|
let code = ecrou::vm::run_prepared(rootfs, &options)?;
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
@@ -76,14 +130,37 @@ fn cmd_provision(
|
|||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
match outcome {
|
match outcome {
|
||||||
Ok(_rootfs) => std::process::ExitCode::SUCCESS,
|
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)),
|
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 {
|
||||||
let mut options = RunOptions {
|
let mut options = RunOptions {
|
||||||
image: args.image.clone(),
|
// Without a layer the image is required by clap; with one the
|
||||||
|
// 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(),
|
||||||
@@ -95,6 +172,7 @@ fn cmd_run(args: &RunArgs, cli_verbose: bool) -> std::process::ExitCode {
|
|||||||
LogLevel::Normal
|
LogLevel::Normal
|
||||||
},
|
},
|
||||||
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()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ serde_yaml = "0.9"
|
|||||||
# HTTP downloads
|
# HTTP downloads
|
||||||
reqwest = { version = "0.13", features = ["blocking", "stream"] }
|
reqwest = { version = "0.13", features = ["blocking", "stream"] }
|
||||||
|
|
||||||
|
|
||||||
# Tarball extraction
|
# Tarball extraction
|
||||||
tar = "0.4"
|
tar = "0.4"
|
||||||
flate2 = "1"
|
flate2 = "1"
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ pub mod guest;
|
|||||||
pub mod krun;
|
pub mod krun;
|
||||||
pub mod progress;
|
pub mod progress;
|
||||||
pub mod rootfs;
|
pub mod rootfs;
|
||||||
|
pub mod spec;
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
pub mod vm;
|
pub mod vm;
|
||||||
|
|
||||||
|
|||||||
+164
-6
@@ -31,6 +31,7 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
use anyhow::{anyhow, Context};
|
use anyhow::{anyhow, Context};
|
||||||
|
|
||||||
|
use crate::error::Error;
|
||||||
use crate::Result;
|
use crate::Result;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
@@ -79,6 +80,87 @@ impl RootfsCache {
|
|||||||
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.
|
||||||
|
pub fn entry_path(&self, source: &ImageSource, arch: &str, layer_key: Option<&str>) -> PathBuf {
|
||||||
|
match layer_key {
|
||||||
|
None => self.tarball_path(source, arch),
|
||||||
|
Some(key) => {
|
||||||
|
let filename = generate_cache_filename(source, arch);
|
||||||
|
let stem = filename
|
||||||
|
.rsplit_once('.')
|
||||||
|
.map(|(stem, _)| stem)
|
||||||
|
.unwrap_or(&filename);
|
||||||
|
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> {
|
||||||
|
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"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let arch = std::env::consts::ARCH
|
||||||
|
.replace("x86_64", "amd64")
|
||||||
|
.replace("aarch64", "arm64");
|
||||||
|
extract_prepared(path, arch, 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)?;
|
||||||
|
Ok(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<()> {
|
||||||
|
let dir = self.dir.join("layers");
|
||||||
|
std::fs::create_dir_all(&dir)
|
||||||
|
.with_context(|| format!("Failed to create the layer directory {}", dir.display()))?;
|
||||||
|
std::fs::write(dir.join(name), cache_filename)
|
||||||
|
.with_context(|| format!("Failed to write the layer pointer 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>> {
|
||||||
|
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
|
/// Resolve `req`, download through the cache when needed, and extract
|
||||||
/// into a fresh scratch directory (removed when the returned
|
/// into a fresh scratch directory (removed when the returned
|
||||||
/// [`PreparedRootfs`] is dropped). Download and extraction progress is
|
/// [`PreparedRootfs`] is dropped). Download and extraction progress is
|
||||||
@@ -172,7 +254,7 @@ impl RootfsCache {
|
|||||||
{
|
{
|
||||||
let arch = req.resolved_arch();
|
let arch = req.resolved_arch();
|
||||||
let source = resolve_source(&req.image, &arch)?;
|
let source = resolve_source(&req.image, &arch)?;
|
||||||
let cache_path = self.tarball_path(&source, &arch);
|
let cache_path = self.entry_path(&source, &arch, req.layer_key.as_deref());
|
||||||
let marker = sidecar(&cache_path, PROVISIONED_SUFFIX);
|
let marker = sidecar(&cache_path, PROVISIONED_SUFFIX);
|
||||||
|
|
||||||
if !req.no_cache && cache_path.exists() && marker.exists() {
|
if !req.no_cache && cache_path.exists() && marker.exists() {
|
||||||
@@ -180,14 +262,32 @@ impl RootfsCache {
|
|||||||
return Ok(extract_prepared(cache_path, arch, progress)?);
|
return Ok(extract_prepared(cache_path, arch, progress)?);
|
||||||
}
|
}
|
||||||
|
|
||||||
let rootfs = self.prepare(req, 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)?;
|
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(|| {
|
rootfs.persist().with_context(|| {
|
||||||
format!(
|
format!(
|
||||||
"Failed to persist provisioned rootfs to {}",
|
"Failed to persist provisioned rootfs to {}",
|
||||||
cache_path.display()
|
cache_path.display()
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
}
|
||||||
Ok(rootfs)
|
Ok(rootfs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,6 +306,12 @@ 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 {
|
||||||
@@ -214,6 +320,7 @@ impl PrepareRequest {
|
|||||||
image: image.into(),
|
image: image.into(),
|
||||||
arch: String::new(),
|
arch: String::new(),
|
||||||
no_cache: false,
|
no_cache: false,
|
||||||
|
layer_key: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,14 +368,21 @@ impl PreparedRootfs {
|
|||||||
/// (indistinguishable from a pristine hit) — only
|
/// (indistinguishable from a pristine hit) — only
|
||||||
/// [`RootfsCache::prepare_provisioned`] interprets the marker.
|
/// [`RootfsCache::prepare_provisioned`] interprets the marker.
|
||||||
pub fn persist(&self) -> Result<()> {
|
pub fn persist(&self) -> Result<()> {
|
||||||
pack_rootfs(self.dir(), &self.cache_path)?;
|
self.persist_to(&self.cache_path)
|
||||||
std::fs::write(sidecar(&self.cache_path, PROVISIONED_SUFFIX), b"").with_context(|| {
|
}
|
||||||
|
|
||||||
|
/// 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<()> {
|
||||||
|
pack_rootfs(self.dir(), cache_path)?;
|
||||||
|
std::fs::write(sidecar(cache_path, PROVISIONED_SUFFIX), b"").with_context(|| {
|
||||||
format!(
|
format!(
|
||||||
"Failed to write provisioned marker for {}",
|
"Failed to write provisioned marker for {}",
|
||||||
self.cache_path.display()
|
cache_path.display()
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
log::debug!("Persisted rootfs to cache: {}", self.cache_path.display());
|
log::debug!("Persisted rootfs to cache: {}", cache_path.display());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -693,4 +807,48 @@ mod tests {
|
|||||||
assert!(!dest.exists());
|
assert!(!dest.exists());
|
||||||
assert!(!dest.with_extension("partial").exists());
|
assert!(!dest.with_extension("partial").exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
let keyed = cache.entry_path(&source, "amd64", Some("abc123"));
|
||||||
|
let other = cache.entry_path(&source, "amd64", Some("def456"));
|
||||||
|
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_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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
//! 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 `.rhema/image.yaml` (or any consumer's equivalent).
|
||||||
|
#[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/vhaudiquet/rhema-base:v1\npackages:\n - go\nrun: |\n cargo install just\n",
|
||||||
|
)
|
||||||
|
.expect("parses");
|
||||||
|
assert_eq!(
|
||||||
|
full.base.as_deref(),
|
||||||
|
Some("ghcr.io/vhaudiquet/rhema-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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-1
@@ -45,6 +45,11 @@ 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,
|
||||||
@@ -84,6 +89,7 @@ impl Default for RunOptions {
|
|||||||
hostname: String::new(),
|
hostname: String::new(),
|
||||||
log_level: LogLevel::Normal,
|
log_level: LogLevel::Normal,
|
||||||
no_cache: false,
|
no_cache: false,
|
||||||
|
layer: None,
|
||||||
capture: None,
|
capture: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,6 +97,20 @@ 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;
|
||||||
@@ -109,7 +129,6 @@ 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")));
|
||||||
}
|
}
|
||||||
|
|
||||||
krun::kvm_probe()?;
|
krun::kvm_probe()?;
|
||||||
let krun = Krun::load()?;
|
let krun = Krun::load()?;
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user