feat(cli): ecrou provision bakes a command into a cache entry

prepare_provisioned and persist were sitting there with no consumer:
the machinery for "install once, every later boot carries it" that
one-shot machines cannot do on their own - packages, users, anything
short of a share - and no way to reach it.

ecrou provision IMAGE -- COMMAND is the missing surface. It boots a
machine over a freshly prepared rootfs, runs the command, and
persists the tree into the cache entry, marked provisioned; a marked
entry is extracted as-is on every later call, the command skipped.
A failing command leaves the entry unprovisioned for the next
attempt, and --no-cache re-downloads before re-provisioning.

The engine behind run splits out as vm::run_prepared: the boot half
of run, taking the rootfs the provisioner is about to persist. run
itself is prepare plus that.

One cache fix the provision flow surfaced: a digest refresh replaced
the tarball but left the old .provisioned marker, so a fresh pristine
image would present as provisioned and skip provisioning for good.
The marker now dies with the entry it belonged to.
This commit is contained in:
vhaudiquet committed 2026-10-28 16:20:37 +01:00
1 parent 1fb3f0c076
commit d9f9c79dc4
5 files changed
+117 -9

No files matched your search

+13
View File
@@ -129,6 +129,19 @@ impl RootfsCache {
format!("Failed to create cache directory {}", self.dir.display())
})?;
download_image(&source, &cache_path, &arch, progress)?;
// Whatever was provisioned belonged to the entry this just
// replaced. A stale marker here would present the pristine
// image as provisioned and skip the next provisioning for
// good.
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()
)
})?;
}
} else {
log::debug!("Using cached tarball: {}", cache_path.display());
}
+16 -9
View File
@@ -91,6 +91,21 @@ impl Default for RunOptions {
/// Run one machine to completion and return the payload's exit code.
pub fn run(options: &RunOptions, progress: &dyn Progress) -> Result<i32> {
let mut request = PrepareRequest::new(options.image.clone());
request.arch = options.arch.clone();
request.no_cache = options.no_cache;
let cache = RootfsCache::new(RootfsCache::default_dir()?);
let rootfs = cache
.prepare(&request, progress)
.context("preparing the rootfs")?;
run_prepared(&rootfs, options)
}
/// Boot one machine over an already-prepared rootfs. This is the whole
/// boot half of [`run`], and the engine the provisioner drives: 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> {
if options.image.is_empty() {
return Err(Error::Other(anyhow!("no image given")));
}
@@ -101,14 +116,6 @@ pub fn run(options: &RunOptions, progress: &dyn Progress) -> Result<i32> {
let ram_mib = parse_memory_mib(&options.memory).map_err(Error::Other)?;
let vcpus = vcpus();
let mut request = PrepareRequest::new(options.image.clone());
request.arch = options.arch.clone();
request.no_cache = options.no_cache;
let cache = RootfsCache::new(RootfsCache::default_dir()?);
let rootfs = cache
.prepare(&request, progress)
.context("preparing the rootfs")?;
let hostname = if options.hostname.is_empty() {
format!("ecrou-vm-{}", utils::random_suffix(HOSTNAME_SUFFIX_LEN))
} else {
@@ -129,7 +136,7 @@ pub fn run(options: &RunOptions, progress: &dyn Progress) -> Result<i32> {
})
.transpose()
.map_err(Error::Other)?;
let share_pairs = collect_shares(&rootfs, options, capture_share).map_err(Error::Other)?;
let share_pairs = collect_shares(rootfs, options, capture_share).map_err(Error::Other)?;
let shares = share_pairs
.iter()
.map(|(_, gs)| gs.clone())