Skip to content

Commit 3aae4b9

Browse files
Merge pull request #23 from ethannortharc/feat/image-caching
feat: cache provisioned VM images for faster creates
2 parents 6206b06 + cb99dc8 commit 3aae4b9

File tree

5 files changed

+292
-34
lines changed

5 files changed

+292
-34
lines changed

src/runtime/incus.rs

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -150,13 +150,18 @@ impl Runtime for IncusRuntime {
150150
);
151151
}
152152

153-
// Ensure the base image is available (auto-download if missing).
154-
Self::ensure_image(&opts.image).await?;
153+
// Determine which image to launch from: cached or base
154+
let image = if let Some(cached) = &opts.cached_image {
155+
println!("Launching from cached image '{cached}'...");
156+
cached.clone()
157+
} else {
158+
Self::ensure_image(&opts.image).await?;
159+
Self::image_alias(&opts.image).to_string()
160+
};
155161

156162
// Launch the VM
157163
println!("Creating Incus VM '{vm}'...");
158-
let image = Self::image_alias(&opts.image);
159-
let mut launch_args = vec!["launch", image, &vm, "--vm", "-c", "security.secureboot=false"];
164+
let mut launch_args = vec!["launch", &image, &vm, "--vm", "-c", "security.secureboot=false"];
160165

161166
let cpu_str;
162167
if opts.cpu > 0 {
@@ -175,13 +180,14 @@ impl Runtime for IncusRuntime {
175180

176181
run_ok("incus", &launch_args).await?;
177182

178-
// Expand the root disk to 20GB (Incus default is 10GB, too small for
179-
// NixOS with dev tools). Must be done after launch, before provisioning.
180-
let _ = run_ok(
181-
"incus",
182-
&["config", "device", "override", &vm, "root", "size=20GiB"],
183-
)
184-
.await;
183+
// Expand disk only for base images (cached images already have 20GB)
184+
if opts.cached_image.is_none() {
185+
let _ = run_ok(
186+
"incus",
187+
&["config", "device", "override", &vm, "root", "size=20GiB"],
188+
)
189+
.await;
190+
}
185191

186192
// Wait for the VM agent to be ready before provisioning.
187193
// The guest agent takes time to start after boot.
@@ -384,6 +390,44 @@ impl Runtime for IncusRuntime {
384390
async fn update_mounts(&self, _name: &str, _mounts: &[super::Mount]) -> Result<()> {
385391
bail!("Updating mounts is not supported for the Incus runtime")
386392
}
393+
394+
async fn cached_image(&self, cache_key: &str) -> Option<String> {
395+
let alias = format!("devbox-cache-{cache_key}");
396+
let result = run_cmd(
397+
"incus",
398+
&["image", "list", &format!("local:{alias}"), "--format", "json"],
399+
)
400+
.await
401+
.ok()?;
402+
if result.exit_code == 0 {
403+
if let Ok(arr) = serde_json::from_str::<Vec<serde_json::Value>>(&result.stdout) {
404+
if !arr.is_empty() {
405+
return Some(alias);
406+
}
407+
}
408+
}
409+
None
410+
}
411+
412+
async fn cache_image(&self, name: &str, cache_key: &str) -> Result<()> {
413+
let vm = Self::vm_name(name);
414+
let alias = format!("devbox-cache-{cache_key}");
415+
416+
println!("Caching provisioned image as '{alias}'...");
417+
418+
// Stop VM before publishing (required by incus publish)
419+
let _ = run_cmd("incus", &["stop", &vm]).await;
420+
421+
// Publish the VM as a reusable image
422+
run_ok("incus", &["publish", &vm, "--alias", &alias]).await?;
423+
424+
// Restart the VM
425+
run_ok("incus", &["start", &vm]).await?;
426+
Self::wait_for_agent(&vm).await?;
427+
428+
println!("Image cached successfully.");
429+
Ok(())
430+
}
387431
}
388432

389433
fn chrono_now() -> String {

src/runtime/lima.rs

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -176,8 +176,13 @@ impl Runtime for LimaRuntime {
176176
} else {
177177
"NixOS"
178178
};
179-
println!("Creating {image_label} VM '{vm}'...");
180-
println!(" (first run downloads {image_label} image, this may take a few minutes)");
179+
180+
if opts.cached_image.is_some() {
181+
println!("Creating {image_label} VM '{vm}' from cache...");
182+
} else {
183+
println!("Creating {image_label} VM '{vm}'...");
184+
println!(" (first run downloads {image_label} image, this may take a few minutes)");
185+
}
181186
run_ok(
182187
"limactl",
183188
&[
@@ -190,6 +195,21 @@ impl Runtime for LimaRuntime {
190195
)
191196
.await?;
192197

198+
// If we have a cached disk, copy it over before first start.
199+
// Lima creates the disk structure during `create`; we overwrite
200+
// `diffdisk` with the cached provisioned state before `start` boots it.
201+
if let Some(cached_disk) = &opts.cached_image {
202+
let home = dirs::home_dir().unwrap_or_default();
203+
let diffdisk = home.join(format!(".lima/{vm}/diffdisk"));
204+
println!("Restoring cached disk image...");
205+
// Use cp -c for APFS clone (instant, zero-cost on macOS)
206+
let _ = run_cmd(
207+
"cp",
208+
&["-c", cached_disk, &diffdisk.to_string_lossy()],
209+
)
210+
.await;
211+
}
212+
193213
println!("Starting {image_label} VM '{vm}'...");
194214
run_ok("limactl", &["start", &vm]).await?;
195215

@@ -377,6 +397,57 @@ impl Runtime for LimaRuntime {
377397

378398
Ok(())
379399
}
400+
401+
async fn cached_image(&self, cache_key: &str) -> Option<String> {
402+
let cache_path = dirs::home_dir()?
403+
.join(format!(".devbox/cache/lima-{cache_key}.disk"));
404+
if cache_path.exists() {
405+
Some(cache_path.to_string_lossy().to_string())
406+
} else {
407+
None
408+
}
409+
}
410+
411+
async fn cache_image(&self, name: &str, cache_key: &str) -> Result<()> {
412+
let vm = Self::vm_name(name);
413+
let home = dirs::home_dir().unwrap_or_default();
414+
let diffdisk = home.join(format!(".lima/{vm}/diffdisk"));
415+
416+
if !diffdisk.exists() {
417+
bail!("Lima disk not found at {}", diffdisk.display());
418+
}
419+
420+
let cache_dir = home.join(".devbox/cache");
421+
std::fs::create_dir_all(&cache_dir)?;
422+
let cache_path = cache_dir.join(format!("lima-{cache_key}.disk"));
423+
424+
println!("Caching provisioned image...");
425+
426+
// Stop VM before copying disk for consistency
427+
let _ = run_cmd("limactl", &["stop", &vm]).await;
428+
429+
// Use cp -c for APFS clone (instant, zero-cost on macOS)
430+
let result = run_cmd(
431+
"cp",
432+
&["-c", &diffdisk.to_string_lossy(), &cache_path.to_string_lossy()],
433+
)
434+
.await;
435+
436+
// Fall back to regular copy if APFS clone fails (non-APFS filesystem)
437+
if result.is_err() || result.as_ref().is_ok_and(|r| r.exit_code != 0) {
438+
let _ = run_cmd(
439+
"cp",
440+
&[&diffdisk.to_string_lossy(), &cache_path.to_string_lossy()],
441+
)
442+
.await;
443+
}
444+
445+
// Restart the VM
446+
run_ok("limactl", &["start", &vm]).await?;
447+
448+
println!("Image cached successfully.");
449+
Ok(())
450+
}
380451
}
381452

382453
fn chrono_now() -> String {
@@ -412,6 +483,7 @@ mod tests {
412483
bare: false,
413484
writable: false,
414485
image: "nixos".to_string(),
486+
cached_image: None,
415487
};
416488
let yaml = LimaRuntime::generate_yaml(&opts);
417489
assert!(yaml.contains("cpus: 4"));
@@ -440,6 +512,7 @@ mod tests {
440512
bare: false,
441513
writable: false,
442514
image: "nixos".to_string(),
515+
cached_image: None,
443516
};
444517
let yaml = LimaRuntime::generate_yaml(&opts);
445518
assert!(yaml.contains("cpus: 4"));
@@ -477,6 +550,7 @@ mod tests {
477550
bare: false,
478551
writable: false,
479552
image: "ubuntu".to_string(),
553+
cached_image: None,
480554
};
481555
let yaml = LimaRuntime::generate_yaml(&opts);
482556
assert!(yaml.contains("ubuntu-24.04"));
@@ -502,6 +576,7 @@ mod tests {
502576
bare: false,
503577
writable: false,
504578
image: "nixos".to_string(),
579+
cached_image: None,
505580
};
506581
let yaml = LimaRuntime::generate_yaml(&opts);
507582
assert!(yaml.contains("nixos-lima"));

src/runtime/mod.rs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ pub struct CreateOpts {
5555
pub writable: bool,
5656
/// Base image type: "nixos" or "ubuntu"
5757
pub image: String,
58+
/// If set, create from this cached image instead of the base image.
59+
/// For Incus: an image alias; for Lima: a path to a cached disk file.
60+
pub cached_image: Option<String>,
5861
}
5962

6063
/// A host-to-VM mount point.
@@ -141,15 +144,15 @@ pub trait Runtime: Send + Sync {
141144
false
142145
}
143146

144-
/// Check if a cached provisioned image exists for the given tool set.
145-
/// Returns the image alias if found.
146-
async fn cached_image(&self, _image: &str, _sets: &[String], _languages: &[String]) -> Option<String> {
147+
/// Check if a cached provisioned image exists for the given cache key.
148+
/// Returns the image alias/path if found.
149+
async fn cached_image(&self, _cache_key: &str) -> Option<String> {
147150
None
148151
}
149152

150153
/// Cache the current VM as a provisioned image for reuse.
151154
/// Called after successful provisioning to speed up future creates.
152-
async fn cache_image(&self, _name: &str, _image: &str, _sets: &[String], _languages: &[String]) -> Result<()> {
155+
async fn cache_image(&self, _name: &str, _cache_key: &str) -> Result<()> {
153156
Ok(())
154157
}
155158

src/sandbox/mod.rs

Lines changed: 43 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,14 @@ impl SandboxManager {
119119
.collect();
120120
mounts.extend_from_slice(extra_mounts);
121121

122+
// Check for a cached provisioned image
123+
let active_sets = config.active_sets();
124+
let active_langs = config.active_languages();
125+
let image = config.sandbox.image.as_str();
126+
let mount_mode = &config.sandbox.mount_mode;
127+
let key = provision::cache_key(image, &active_sets, &active_langs, mount_mode);
128+
let cached = runtime.cached_image(&key).await;
129+
122130
let opts = CreateOpts {
123131
name: name.to_string(),
124132
mounts,
@@ -132,28 +140,46 @@ impl SandboxManager {
132140
bare,
133141
writable: config.sandbox.mount_mode == "writable",
134142
image: config.sandbox.image.clone(),
143+
cached_image: cached.clone(),
135144
};
136145

137146
// Create via runtime
138147
let info = runtime.create(&opts).await?;
139148

140-
// Provision tools in the VM based on selected sets
141-
let active_sets = config.active_sets();
142-
let active_langs = config.active_languages();
143-
let image = config.sandbox.image.as_str();
144-
// Provision tools — pass mount_mode so NixOS module sets up overlay
145-
let mount_mode = &config.sandbox.mount_mode;
146-
if let Err(e) = provision::provision_vm_with_mode(
147-
runtime,
148-
name,
149-
&active_sets,
150-
&active_langs,
151-
image,
152-
mount_mode,
153-
)
154-
.await
155-
{
156-
eprintln!("Warning: provisioning incomplete: {e}");
149+
if cached.is_some() {
150+
// Launched from cached image — skip full provisioning,
151+
// only apply host-specific config (git, devbox binary, state file).
152+
println!("Using cached image — skipping provisioning.");
153+
if let Err(e) = provision::post_cache_setup(
154+
runtime,
155+
name,
156+
&active_sets,
157+
&active_langs,
158+
mount_mode,
159+
)
160+
.await
161+
{
162+
eprintln!("Warning: post-cache setup incomplete: {e}");
163+
}
164+
} else {
165+
// No cache — full provisioning
166+
if let Err(e) = provision::provision_vm_with_mode(
167+
runtime,
168+
name,
169+
&active_sets,
170+
&active_langs,
171+
image,
172+
mount_mode,
173+
)
174+
.await
175+
{
176+
eprintln!("Warning: provisioning incomplete: {e}");
177+
}
178+
179+
// Cache the provisioned image for future creates
180+
if let Err(e) = runtime.cache_image(name, &key).await {
181+
eprintln!("Warning: could not cache image: {e}");
182+
}
157183
}
158184

159185
// Save state

0 commit comments

Comments
 (0)