Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 55 additions & 11 deletions src/runtime/incus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,13 +150,18 @@ impl Runtime for IncusRuntime {
);
}

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

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

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

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

// Expand the root disk to 20GB (Incus default is 10GB, too small for
// NixOS with dev tools). Must be done after launch, before provisioning.
let _ = run_ok(
"incus",
&["config", "device", "override", &vm, "root", "size=20GiB"],
)
.await;
// Expand disk only for base images (cached images already have 20GB)
if opts.cached_image.is_none() {
let _ = run_ok(
"incus",
&["config", "device", "override", &vm, "root", "size=20GiB"],
)
.await;
}

// Wait for the VM agent to be ready before provisioning.
// The guest agent takes time to start after boot.
Expand Down Expand Up @@ -384,6 +390,44 @@ impl Runtime for IncusRuntime {
async fn update_mounts(&self, _name: &str, _mounts: &[super::Mount]) -> Result<()> {
bail!("Updating mounts is not supported for the Incus runtime")
}

async fn cached_image(&self, cache_key: &str) -> Option<String> {
let alias = format!("devbox-cache-{cache_key}");
let result = run_cmd(
"incus",
&["image", "list", &format!("local:{alias}"), "--format", "json"],
)
.await
.ok()?;
if result.exit_code == 0 {
if let Ok(arr) = serde_json::from_str::<Vec<serde_json::Value>>(&result.stdout) {
if !arr.is_empty() {
return Some(alias);
}
}
}
None
}

async fn cache_image(&self, name: &str, cache_key: &str) -> Result<()> {
let vm = Self::vm_name(name);
let alias = format!("devbox-cache-{cache_key}");

println!("Caching provisioned image as '{alias}'...");

// Stop VM before publishing (required by incus publish)
let _ = run_cmd("incus", &["stop", &vm]).await;

// Publish the VM as a reusable image
run_ok("incus", &["publish", &vm, "--alias", &alias]).await?;

// Restart the VM
run_ok("incus", &["start", &vm]).await?;
Self::wait_for_agent(&vm).await?;

println!("Image cached successfully.");
Ok(())
}
}

fn chrono_now() -> String {
Expand Down
79 changes: 77 additions & 2 deletions src/runtime/lima.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,8 +176,13 @@ impl Runtime for LimaRuntime {
} else {
"NixOS"
};
println!("Creating {image_label} VM '{vm}'...");
println!(" (first run downloads {image_label} image, this may take a few minutes)");

if opts.cached_image.is_some() {
println!("Creating {image_label} VM '{vm}' from cache...");
} else {
println!("Creating {image_label} VM '{vm}'...");
println!(" (first run downloads {image_label} image, this may take a few minutes)");
}
run_ok(
"limactl",
&[
Expand All @@ -190,6 +195,21 @@ impl Runtime for LimaRuntime {
)
.await?;

// If we have a cached disk, copy it over before first start.
// Lima creates the disk structure during `create`; we overwrite
// `diffdisk` with the cached provisioned state before `start` boots it.
if let Some(cached_disk) = &opts.cached_image {
let home = dirs::home_dir().unwrap_or_default();
let diffdisk = home.join(format!(".lima/{vm}/diffdisk"));
println!("Restoring cached disk image...");
// Use cp -c for APFS clone (instant, zero-cost on macOS)
let _ = run_cmd(
"cp",
&["-c", cached_disk, &diffdisk.to_string_lossy()],
)
.await;
}

println!("Starting {image_label} VM '{vm}'...");
run_ok("limactl", &["start", &vm]).await?;

Expand Down Expand Up @@ -377,6 +397,57 @@ impl Runtime for LimaRuntime {

Ok(())
}

async fn cached_image(&self, cache_key: &str) -> Option<String> {
let cache_path = dirs::home_dir()?
.join(format!(".devbox/cache/lima-{cache_key}.disk"));
if cache_path.exists() {
Some(cache_path.to_string_lossy().to_string())
} else {
None
}
}

async fn cache_image(&self, name: &str, cache_key: &str) -> Result<()> {
let vm = Self::vm_name(name);
let home = dirs::home_dir().unwrap_or_default();
let diffdisk = home.join(format!(".lima/{vm}/diffdisk"));

if !diffdisk.exists() {
bail!("Lima disk not found at {}", diffdisk.display());
}

let cache_dir = home.join(".devbox/cache");
std::fs::create_dir_all(&cache_dir)?;
let cache_path = cache_dir.join(format!("lima-{cache_key}.disk"));

println!("Caching provisioned image...");

// Stop VM before copying disk for consistency
let _ = run_cmd("limactl", &["stop", &vm]).await;

// Use cp -c for APFS clone (instant, zero-cost on macOS)
let result = run_cmd(
"cp",
&["-c", &diffdisk.to_string_lossy(), &cache_path.to_string_lossy()],
)
.await;

// Fall back to regular copy if APFS clone fails (non-APFS filesystem)
if result.is_err() || result.as_ref().is_ok_and(|r| r.exit_code != 0) {
let _ = run_cmd(
"cp",
&[&diffdisk.to_string_lossy(), &cache_path.to_string_lossy()],
)
.await;
}

// Restart the VM
run_ok("limactl", &["start", &vm]).await?;

println!("Image cached successfully.");
Ok(())
}
}

fn chrono_now() -> String {
Expand Down Expand Up @@ -412,6 +483,7 @@ mod tests {
bare: false,
writable: false,
image: "nixos".to_string(),
cached_image: None,
};
let yaml = LimaRuntime::generate_yaml(&opts);
assert!(yaml.contains("cpus: 4"));
Expand Down Expand Up @@ -440,6 +512,7 @@ mod tests {
bare: false,
writable: false,
image: "nixos".to_string(),
cached_image: None,
};
let yaml = LimaRuntime::generate_yaml(&opts);
assert!(yaml.contains("cpus: 4"));
Expand Down Expand Up @@ -477,6 +550,7 @@ mod tests {
bare: false,
writable: false,
image: "ubuntu".to_string(),
cached_image: None,
};
let yaml = LimaRuntime::generate_yaml(&opts);
assert!(yaml.contains("ubuntu-24.04"));
Expand All @@ -502,6 +576,7 @@ mod tests {
bare: false,
writable: false,
image: "nixos".to_string(),
cached_image: None,
};
let yaml = LimaRuntime::generate_yaml(&opts);
assert!(yaml.contains("nixos-lima"));
Expand Down
11 changes: 7 additions & 4 deletions src/runtime/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ pub struct CreateOpts {
pub writable: bool,
/// Base image type: "nixos" or "ubuntu"
pub image: String,
/// If set, create from this cached image instead of the base image.
/// For Incus: an image alias; for Lima: a path to a cached disk file.
pub cached_image: Option<String>,
}

/// A host-to-VM mount point.
Expand Down Expand Up @@ -141,15 +144,15 @@ pub trait Runtime: Send + Sync {
false
}

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

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

Expand Down
60 changes: 43 additions & 17 deletions src/sandbox/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,14 @@ impl SandboxManager {
.collect();
mounts.extend_from_slice(extra_mounts);

// Check for a cached provisioned image
let active_sets = config.active_sets();
let active_langs = config.active_languages();
let image = config.sandbox.image.as_str();
let mount_mode = &config.sandbox.mount_mode;
let key = provision::cache_key(image, &active_sets, &active_langs, mount_mode);
let cached = runtime.cached_image(&key).await;

let opts = CreateOpts {
name: name.to_string(),
mounts,
Expand All @@ -132,28 +140,46 @@ impl SandboxManager {
bare,
writable: config.sandbox.mount_mode == "writable",
image: config.sandbox.image.clone(),
cached_image: cached.clone(),
};

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

// Provision tools in the VM based on selected sets
let active_sets = config.active_sets();
let active_langs = config.active_languages();
let image = config.sandbox.image.as_str();
// Provision tools — pass mount_mode so NixOS module sets up overlay
let mount_mode = &config.sandbox.mount_mode;
if let Err(e) = provision::provision_vm_with_mode(
runtime,
name,
&active_sets,
&active_langs,
image,
mount_mode,
)
.await
{
eprintln!("Warning: provisioning incomplete: {e}");
if cached.is_some() {
// Launched from cached image — skip full provisioning,
// only apply host-specific config (git, devbox binary, state file).
println!("Using cached image — skipping provisioning.");
if let Err(e) = provision::post_cache_setup(
runtime,
name,
&active_sets,
&active_langs,
mount_mode,
)
.await
{
eprintln!("Warning: post-cache setup incomplete: {e}");
}
} else {
// No cache — full provisioning
if let Err(e) = provision::provision_vm_with_mode(
runtime,
name,
&active_sets,
&active_langs,
image,
mount_mode,
)
.await
{
eprintln!("Warning: provisioning incomplete: {e}");
}

// Cache the provisioned image for future creates
if let Err(e) = runtime.cache_image(name, &key).await {
eprintln!("Warning: could not cache image: {e}");
}
}

// Save state
Expand Down
Loading
Loading