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
+455
-31
No files matched your search
@@ -20,34 +20,43 @@ fn main() -> std::process::ExitCode {
|
||||
Command::Run(args) => cmd_run(&args, cli.verbose),
|
||||
Command::Provision {
|
||||
image,
|
||||
spec,
|
||||
layer,
|
||||
no_cache,
|
||||
command,
|
||||
} => cmd_provision(&image, no_cache, &command, cli.verbose),
|
||||
} => cmd_provision(image, spec, layer, no_cache, &command, cli.verbose),
|
||||
Command::Probe => cmd_probe(),
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_provision(
|
||||
image: &str,
|
||||
image: Option<String>,
|
||||
spec: Option<std::path::PathBuf>,
|
||||
layer: Option<String>,
|
||||
no_cache: bool,
|
||||
command: &[String],
|
||||
cli_verbose: bool,
|
||||
) -> std::process::ExitCode {
|
||||
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!(
|
||||
"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 {
|
||||
LogLevel::Verbose
|
||||
} else {
|
||||
LogLevel::Normal
|
||||
};
|
||||
let mut options = RunOptions {
|
||||
image: image.to_owned(),
|
||||
command: command.to_owned(),
|
||||
log_level: level,
|
||||
// The machine exists to change the rootfs, not to cross a
|
||||
// share: the workspace would ride the persist into the cache.
|
||||
@@ -56,16 +65,61 @@ fn cmd_provision(
|
||||
options.workdir = "/".to_owned();
|
||||
|
||||
let progress: &dyn Progress = &TermProgress::new();
|
||||
let request = PrepareRequest {
|
||||
image: image.to_owned(),
|
||||
arch: String::new(),
|
||||
no_cache,
|
||||
};
|
||||
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 {
|
||||
@@ -76,14 +130,37 @@ fn cmd_provision(
|
||||
Ok(())
|
||||
});
|
||||
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)),
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_run(args: &RunArgs, cli_verbose: bool) -> std::process::ExitCode {
|
||||
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(),
|
||||
memory: args.memory.clone(),
|
||||
workdir: args.workdir.clone(),
|
||||
@@ -95,6 +172,7 @@ fn cmd_run(args: &RunArgs, cli_verbose: bool) -> std::process::ExitCode {
|
||||
LogLevel::Normal
|
||||
},
|
||||
no_cache: args.no_cache,
|
||||
layer: args.layer.clone(),
|
||||
capture: args.capture.clone(),
|
||||
..RunOptions::default()
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user