use anyhow::{Context, Result};
use mvm_core::vm_backend::{VmId, VmStartConfig, VmVolume};
use mvm_runtime::vm::backend::AnyBackend;
use mvm_runtime::vm::microvm;
use serde::Deserialize;
use std::collections::BTreeMap;
use std::path::Path;
use crate::ui;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum ExecTarget {
Inline { argv: Vec<String> },
LaunchPlan { entrypoint: LaunchEntrypoint },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LaunchEntrypoint {
pub command: Vec<String>,
pub working_dir: Option<String>,
pub env: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AddDir {
pub host_path: String,
pub guest_path: String,
pub read_only: bool,
}
impl AddDir {
pub fn parse(spec: &str) -> Result<Self> {
let (host, rest) = spec.split_once(':').ok_or_else(|| {
anyhow::anyhow!("--add-dir '{spec}': expected 'host:guest[:mode]', missing ':'")
})?;
if host.is_empty() {
anyhow::bail!("--add-dir '{spec}': host path must not be empty");
}
let (guest, read_only) = match rest.rsplit_once(':') {
Some((path, "ro")) => (path, true),
Some((path, "rw")) => (path, false),
Some((_, tail)) if looks_like_mode_typo(tail) => {
anyhow::bail!("--add-dir '{spec}': unknown mode '{tail}' (expected 'ro' or 'rw')");
}
_ => (rest, true),
};
if guest.is_empty() {
anyhow::bail!("--add-dir '{spec}': guest path must not be empty");
}
if !guest.starts_with('/') {
anyhow::bail!("--add-dir '{spec}': guest path must be absolute (start with '/')");
}
Ok(Self {
host_path: expand_tilde(host),
guest_path: guest.to_string(),
read_only,
})
}
}
fn looks_like_mode_typo(tail: &str) -> bool {
!tail.is_empty()
&& tail.len() <= 8
&& !tail.contains('/')
&& tail.chars().all(|c| c.is_ascii_alphanumeric())
}
fn expand_tilde(path: &str) -> String {
if let Some(rest) = path.strip_prefix("~/")
&& let Ok(home) = std::env::var("HOME")
{
return format!("{home}/{rest}");
}
path.to_string()
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum ImageSource {
Template(String),
Prebuilt {
kernel_path: String,
rootfs_path: String,
initrd_path: Option<String>,
label: String,
},
}
#[derive(Debug, Clone)]
pub struct ExecRequest {
pub image: ImageSource,
pub cpus: u32,
pub memory_mib: u32,
pub add_dirs: Vec<AddDir>,
pub env: Vec<(String, String)>,
pub target: ExecTarget,
pub timeout_secs: u64,
}
impl ExecRequest {
pub fn target_command(&self) -> String {
match &self.target {
ExecTarget::Inline { argv } => quote_argv_for_exec(argv),
ExecTarget::LaunchPlan { entrypoint } => quote_argv_for_exec(&entrypoint.command),
}
}
}
fn quote_argv_for_exec(argv: &[String]) -> String {
let quoted: Vec<String> = argv.iter().map(|a| shell_quote(a)).collect();
format!("exec {}", quoted.join(" "))
}
#[derive(Debug, Deserialize)]
struct RawLaunchPlan {
#[serde(default)]
apps: Vec<RawLaunchApp>,
}
#[derive(Debug, Deserialize)]
struct RawLaunchApp {
#[serde(default)]
name: Option<String>,
entrypoint: RawLaunchEntrypoint,
#[serde(default)]
env: BTreeMap<String, String>,
}
#[derive(Debug, Deserialize)]
struct RawLaunchEntrypoint {
#[serde(default)]
command: Vec<String>,
#[serde(default)]
working_dir: Option<String>,
#[serde(default)]
env: BTreeMap<String, String>,
}
pub fn load_launch_plan(path: &Path) -> Result<LaunchEntrypoint> {
let bytes =
std::fs::read(path).with_context(|| format!("reading launch plan '{}'", path.display()))?;
let raw: RawLaunchPlan = serde_json::from_slice(&bytes)
.with_context(|| format!("parsing launch plan '{}' as JSON", path.display()))?;
parse_launch_plan(raw, &path.display().to_string())
}
fn parse_launch_plan(raw: RawLaunchPlan, source: &str) -> Result<LaunchEntrypoint> {
if raw.apps.is_empty() {
anyhow::bail!("launch plan '{source}' has no `apps[]` entries");
}
if raw.apps.len() > 1 {
let names: Vec<&str> = raw
.apps
.iter()
.map(|a| a.name.as_deref().unwrap_or("<unnamed>"))
.collect();
anyhow::bail!(
"launch plan '{source}' has {} apps ({}); `mvmctl exec` v1 supports single-app workloads only",
raw.apps.len(),
names.join(", "),
);
}
let RawLaunchApp {
name: _,
entrypoint,
env: app_env,
} = raw.apps.into_iter().next().expect("len == 1 above");
if entrypoint.command.is_empty() {
anyhow::bail!("launch plan '{source}': entrypoint.command must be non-empty");
}
let mut merged = app_env;
for (k, v) in entrypoint.env {
merged.insert(k, v);
}
Ok(LaunchEntrypoint {
command: entrypoint.command,
working_dir: entrypoint.working_dir,
env: merged,
})
}
pub fn shell_quote(arg: &str) -> String {
let mut out = String::with_capacity(arg.len() + 2);
out.push('\'');
for ch in arg.chars() {
if ch == '\'' {
out.push_str(r"'\''");
} else {
out.push(ch);
}
}
out.push('\'');
out
}
pub fn build_guest_wrapper(req: &ExecRequest, add_dir_labels: &[String]) -> String {
let mut script = String::from("set -e\n");
for (dir, label) in req.add_dirs.iter().zip(add_dir_labels.iter()) {
let mount_point = shell_quote(&dir.guest_path);
let label_q = shell_quote(label);
let mount_opts = if dir.read_only { " -o ro" } else { "" };
script.push_str(&format!(
"mkdir -p {mount_point}\nmount LABEL={label_q} {mount_point}{mount_opts}\n",
));
}
if let ExecTarget::LaunchPlan { entrypoint } = &req.target {
for (k, v) in &entrypoint.env {
script.push_str(&format!("export {k}={}\n", shell_quote(v)));
}
}
for (k, v) in &req.env {
script.push_str(&format!("export {k}={}\n", shell_quote(v)));
}
if let ExecTarget::LaunchPlan { entrypoint } = &req.target
&& let Some(wd) = &entrypoint.working_dir
{
script.push_str(&format!("cd {}\n", shell_quote(wd)));
}
script.push_str(&req.target_command());
script.push('\n');
script
}
pub fn transient_vm_name() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or_default();
let pid = std::process::id();
format!("exec-{pid:x}-{nanos:08x}")
}
pub fn snapshot_eligible(
image: &ImageSource,
add_dirs: &[AddDir],
snap_present: bool,
backend_supports_snapshots: bool,
) -> bool {
if !backend_supports_snapshots || !snap_present || !add_dirs.is_empty() {
return false;
}
matches!(image, ImageSource::Template(_))
}
pub fn run(req: ExecRequest) -> Result<i32> {
let backend = AnyBackend::default_backend();
let (vmlinux, initrd, rootfs, revision, flake_ref, profile, snap_info, template_id) =
match &req.image {
ImageSource::Template(name) => {
let (spec, vmlinux, initrd, rootfs, rev) =
mvm_runtime::vm::template::lifecycle::template_artifacts(name)
.with_context(|| format!("Loading template '{name}'"))?;
let snap = mvm_runtime::vm::template::lifecycle::template_snapshot_info(name)
.ok()
.flatten();
(
vmlinux,
initrd,
rootfs,
rev,
spec.flake_ref.clone(),
Some(spec.profile.clone()),
snap,
Some(name.clone()),
)
}
ImageSource::Prebuilt {
kernel_path,
rootfs_path,
initrd_path,
label,
} => (
kernel_path.clone(),
initrd_path.clone(),
rootfs_path.clone(),
String::new(),
label.clone(),
None,
None,
None,
),
};
let vm_name = transient_vm_name();
let staging_dir = format!("{}/{}/extras", mvm_runtime::config::VMS_DIR, vm_name);
let mut volumes: Vec<mvm_runtime::vm::image::RuntimeVolume> = Vec::new();
let mut add_dir_labels: Vec<String> = Vec::new();
for (idx, dir) in req.add_dirs.iter().enumerate() {
let label = format!("mvm-extra-{idx}");
let image_path = format!("{staging_dir}/extra-{idx}.ext4");
mvm_runtime::vm::image::build_dir_image_ro(&dir.host_path, &label, &image_path)
.with_context(|| {
format!(
"preparing --add-dir image for '{}' -> '{}'",
dir.host_path, dir.guest_path
)
})?;
volumes.push(mvm_runtime::vm::image::RuntimeVolume {
host: image_path,
guest: dir.guest_path.clone(),
size: String::new(),
read_only: dir.read_only,
});
add_dir_labels.push(label);
}
let use_snapshot = snapshot_eligible(
&req.image,
&req.add_dirs,
snap_info.is_some(),
backend.capabilities().snapshots,
);
let start_config = VmStartConfig {
name: vm_name.clone(),
rootfs_path: rootfs.clone(),
kernel_path: Some(vmlinux.clone()),
initrd_path: initrd.clone(),
revision_hash: revision.clone(),
flake_ref: flake_ref.clone(),
profile: profile.clone(),
cpus: req.cpus,
memory_mib: req.memory_mib,
ports: Vec::new(),
volumes: volumes
.iter()
.map(|v| VmVolume {
host: v.host.clone(),
guest: v.guest.clone(),
size: v.size.clone(),
read_only: v.read_only,
})
.collect(),
config_files: Vec::new(),
secret_files: Vec::new(),
runner_dir: None,
};
let booted = if use_snapshot {
let tmpl = template_id
.as_deref()
.expect("snapshot_eligible only true for ImageSource::Template");
let snap = snap_info
.as_ref()
.expect("snapshot_eligible requires snap_info.is_some()");
ui::info(&format!(
"Restoring transient VM '{vm_name}' from template '{tmpl}' snapshot..."
));
match restore_via_snapshot(&vm_name, tmpl, snap, &start_config) {
Ok(()) => true,
Err(e) => {
ui::warn(&format!("Snapshot restore failed: {e}; cold-booting."));
false
}
}
} else {
false
};
if !booted {
ui::info(&format!("Booting transient VM '{vm_name}'..."));
if let Err(e) = backend.start(&start_config) {
let _ = mvm_runtime::shell::run_in_vm(&format!("rm -rf {staging_dir}"));
return Err(e).context("starting transient microVM");
}
}
let interrupted = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
{
let interrupted = interrupted.clone();
let vm_name = vm_name.clone();
let _ = ctrlc::set_handler(move || {
interrupted.store(true, std::sync::atomic::Ordering::SeqCst);
let backend = AnyBackend::default_backend();
let _ = backend.stop(&VmId(vm_name.clone()));
});
}
let result = run_in_guest(&vm_name, &req, &add_dir_labels);
let _ = backend.stop(&VmId(vm_name.clone()));
for (idx, dir) in req.add_dirs.iter().enumerate() {
if dir.read_only {
continue;
}
let image_path = format!("{staging_dir}/extra-{idx}.ext4");
if let Err(e) = mvm_runtime::vm::image::rsync_image_to_host(&image_path, &dir.host_path) {
ui::warn(&format!(
"writable --add-dir sync-back failed for '{}' -> '{}': {e:#}",
dir.host_path, dir.guest_path,
));
}
}
let _ = mvm_runtime::shell::run_in_vm(&format!("rm -rf {staging_dir}"));
if interrupted.load(std::sync::atomic::Ordering::SeqCst) {
anyhow::bail!("interrupted");
}
result
}
fn restore_via_snapshot(
vm_name: &str,
template_id: &str,
snap_info: &mvm_core::template::SnapshotInfo,
start_config: &VmStartConfig,
) -> Result<()> {
let slot = mvm_runtime::vm::microvm::allocate_slot(vm_name)?;
let run_config = mvm_runtime::vm::microvm::FlakeRunConfig {
name: vm_name.to_string(),
slot,
vmlinux_path: start_config.kernel_path.clone().unwrap_or_default(),
initrd_path: start_config.initrd_path.clone(),
rootfs_path: start_config.rootfs_path.clone(),
revision_hash: start_config.revision_hash.clone(),
flake_ref: start_config.flake_ref.clone(),
profile: start_config.profile.clone(),
cpus: start_config.cpus,
memory: start_config.memory_mib,
volumes: Vec::new(),
config_files: Vec::new(),
secret_files: Vec::new(),
ports: Vec::new(),
network_policy: mvm_core::network_policy::NetworkPolicy::default(),
};
let rev = mvm_runtime::vm::template::lifecycle::current_revision_id(template_id)?;
let snap_dir = mvm_core::template::template_snapshot_dir(template_id, &rev);
mvm_runtime::vm::microvm::restore_from_template_snapshot(
template_id,
&run_config,
&snap_dir,
snap_info,
)
}
fn run_in_guest(vm_name: &str, req: &ExecRequest, labels: &[String]) -> Result<i32> {
if !wait_for_agent(vm_name, 30) {
anyhow::bail!("guest agent did not become reachable within 30s");
}
let wrapper = build_guest_wrapper(req, labels);
let resp = send_request(vm_name, &wrapper, req.timeout_secs)?;
match resp {
mvm_guest::vsock::GuestResponse::ExecResult {
exit_code,
stdout,
stderr,
} => {
if !stdout.is_empty() {
print!("{stdout}");
}
if !stderr.is_empty() {
eprint!("{stderr}");
}
Ok(exit_code)
}
mvm_guest::vsock::GuestResponse::Error { message } => {
anyhow::bail!("guest exec error: {message}")
}
other => anyhow::bail!("unexpected guest response: {other:?}"),
}
}
fn wait_for_agent(vm_name: &str, timeout_secs: u64) -> bool {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
while std::time::Instant::now() < deadline {
if mvm_apple_container::vsock_connect(vm_name, mvm_guest::vsock::GUEST_AGENT_PORT).is_ok() {
return true;
}
if let Ok(instance_dir) = microvm::resolve_running_vm_dir(vm_name) {
let uds = mvm_guest::vsock::vsock_uds_path(&instance_dir);
if mvm_guest::vsock::ping_at(&uds).unwrap_or(false) {
return true;
}
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
false
}
fn send_request(
vm_name: &str,
command: &str,
timeout_secs: u64,
) -> Result<mvm_guest::vsock::GuestResponse> {
if let Ok(mut stream) =
mvm_apple_container::vsock_connect(vm_name, mvm_guest::vsock::GUEST_AGENT_PORT)
{
return mvm_guest::vsock::send_request(
&mut stream,
&mvm_guest::vsock::GuestRequest::Exec {
command: command.to_string(),
stdin: None,
timeout_secs: Some(timeout_secs),
},
);
}
let instance_dir = microvm::resolve_running_vm_dir(vm_name)?;
mvm_guest::vsock::exec_at(
&mvm_guest::vsock::vsock_uds_path(&instance_dir),
command,
None,
timeout_secs,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn add_dir_parse_happy_path() {
let d = AddDir::parse("/tmp/src:/work").unwrap();
assert_eq!(d.host_path, "/tmp/src");
assert_eq!(d.guest_path, "/work");
}
#[test]
fn add_dir_parse_rejects_missing_colon() {
let err = AddDir::parse("/tmp/src").unwrap_err();
assert!(err.to_string().contains("missing ':'"));
}
#[test]
fn add_dir_parse_rejects_empty_host() {
let err = AddDir::parse(":/work").unwrap_err();
assert!(err.to_string().contains("host path"));
}
#[test]
fn add_dir_parse_rejects_empty_guest() {
let err = AddDir::parse("/tmp/src:").unwrap_err();
assert!(err.to_string().contains("guest path"));
}
#[test]
fn add_dir_parse_rejects_relative_guest() {
let err = AddDir::parse("/tmp/src:relative/path").unwrap_err();
assert!(err.to_string().contains("absolute"));
}
#[test]
fn add_dir_expands_tilde_in_host_path() {
unsafe {
std::env::set_var("HOME", "/tmp/fakehome");
}
let d = AddDir::parse("~/configs:/etc/configs").unwrap();
assert_eq!(d.host_path, "/tmp/fakehome/configs");
assert_eq!(d.guest_path, "/etc/configs");
}
#[test]
fn add_dir_parse_default_is_read_only() {
let d = AddDir::parse("/tmp/src:/work").unwrap();
assert!(d.read_only, "default mode should be read-only");
}
#[test]
fn add_dir_parse_explicit_ro() {
let d = AddDir::parse("/tmp/src:/work:ro").unwrap();
assert_eq!(d.host_path, "/tmp/src");
assert_eq!(d.guest_path, "/work");
assert!(d.read_only);
}
#[test]
fn add_dir_parse_explicit_rw() {
let d = AddDir::parse("/tmp/src:/work:rw").unwrap();
assert_eq!(d.host_path, "/tmp/src");
assert_eq!(d.guest_path, "/work");
assert!(!d.read_only);
}
#[test]
fn add_dir_parse_rejects_bogus_mode() {
let err = AddDir::parse("/tmp/src:/work:bogus").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("unknown mode"), "got: {msg}");
assert!(msg.contains("'bogus'"), "got: {msg}");
}
#[test]
fn add_dir_extra_colons_belong_to_guest_path() {
let d = AddDir::parse("/host:/weird:path/file").unwrap();
assert_eq!(d.host_path, "/host");
assert_eq!(d.guest_path, "/weird:path/file");
assert!(d.read_only);
}
#[test]
fn shell_quote_basic() {
assert_eq!(shell_quote("hello"), "'hello'");
assert_eq!(shell_quote("hello world"), "'hello world'");
}
#[test]
fn shell_quote_escapes_single_quotes() {
assert_eq!(shell_quote("it's"), r"'it'\''s'");
}
#[test]
fn target_command_inline_quotes_each_arg() {
let req = ExecRequest {
image: ImageSource::Template("t".into()),
cpus: 1,
memory_mib: 256,
add_dirs: Vec::new(),
env: Vec::new(),
target: ExecTarget::Inline {
argv: vec!["uname".into(), "-a".into()],
},
timeout_secs: 30,
};
assert_eq!(req.target_command(), "exec 'uname' '-a'");
}
#[test]
fn build_guest_wrapper_no_extras() {
let req = ExecRequest {
image: ImageSource::Template("t".into()),
cpus: 1,
memory_mib: 256,
add_dirs: Vec::new(),
env: Vec::new(),
target: ExecTarget::Inline {
argv: vec!["true".into()],
},
timeout_secs: 30,
};
let script = build_guest_wrapper(&req, &[]);
assert!(script.starts_with("set -e\n"));
assert!(script.contains("exec 'true'"));
assert!(!script.contains("mount"));
assert!(!script.contains("export"));
}
#[test]
fn build_guest_wrapper_mounts_and_env() {
let req = ExecRequest {
image: ImageSource::Template("t".into()),
cpus: 1,
memory_mib: 256,
add_dirs: vec![AddDir {
host_path: "/h".into(),
guest_path: "/g".into(),
read_only: true,
}],
env: vec![("FOO".into(), "bar baz".into())],
target: ExecTarget::Inline {
argv: vec!["echo".into(), "$FOO".into()],
},
timeout_secs: 30,
};
let script = build_guest_wrapper(&req, &["mvm-extra-0".to_string()]);
assert!(script.contains("mkdir -p '/g'"));
assert!(script.contains("mount LABEL='mvm-extra-0' '/g' -o ro"));
assert!(script.contains("export FOO='bar baz'"));
assert!(script.contains("exec 'echo' '$FOO'"));
}
#[test]
fn build_guest_wrapper_writable_mount_drops_ro_flag() {
let req = ExecRequest {
image: ImageSource::Template("t".into()),
cpus: 1,
memory_mib: 256,
add_dirs: vec![AddDir {
host_path: "/h".into(),
guest_path: "/g".into(),
read_only: false,
}],
env: Vec::new(),
target: ExecTarget::Inline {
argv: vec!["true".into()],
},
timeout_secs: 30,
};
let script = build_guest_wrapper(&req, &["mvm-extra-0".to_string()]);
assert!(
script.contains("mount LABEL='mvm-extra-0' '/g'\n"),
"expected unqualified mount line, got: {script}"
);
assert!(!script.contains("-o ro"), "RW mount must not include -o ro");
}
#[test]
fn transient_vm_name_format() {
let n = transient_vm_name();
assert!(n.starts_with("exec-"));
assert!(n.len() > "exec-".len());
assert!(!n.contains(' '));
assert!(!n.contains('/'));
}
fn parse_str(json: &str) -> Result<LaunchEntrypoint> {
let raw: RawLaunchPlan = serde_json::from_str(json).expect("valid json");
parse_launch_plan(raw, "test")
}
#[test]
fn launch_plan_minimal_app() {
let plan = r#"{
"apps": [
{ "entrypoint": { "command": ["python", "-m", "hello"] } }
]
}"#;
let ep = parse_str(plan).unwrap();
assert_eq!(ep.command, vec!["python", "-m", "hello"]);
assert!(ep.working_dir.is_none());
assert!(ep.env.is_empty());
}
#[test]
fn launch_plan_with_working_dir_and_env() {
let plan = r#"{
"apps": [
{
"name": "hello",
"entrypoint": {
"command": ["python", "main.py"],
"working_dir": "/app",
"env": { "PORT": "8080" }
},
"env": { "LOG_LEVEL": "info" }
}
]
}"#;
let ep = parse_str(plan).unwrap();
assert_eq!(ep.command, vec!["python", "main.py"]);
assert_eq!(ep.working_dir.as_deref(), Some("/app"));
assert_eq!(ep.env.get("PORT").map(String::as_str), Some("8080"));
assert_eq!(ep.env.get("LOG_LEVEL").map(String::as_str), Some("info"));
}
#[test]
fn launch_plan_entrypoint_env_overrides_app_env() {
let plan = r#"{
"apps": [
{
"entrypoint": {
"command": ["true"],
"env": { "X": "from-entrypoint" }
},
"env": { "X": "from-app", "Y": "y" }
}
]
}"#;
let ep = parse_str(plan).unwrap();
assert_eq!(ep.env.get("X").map(String::as_str), Some("from-entrypoint"));
assert_eq!(ep.env.get("Y").map(String::as_str), Some("y"));
}
#[test]
fn launch_plan_ignores_unknown_top_level_fields() {
let plan = r#"{
"version": "v0",
"workload": { "id": "hello" },
"apps": [ { "entrypoint": { "command": ["true"] } } ],
"future_field": 42
}"#;
assert!(parse_str(plan).is_ok());
}
#[test]
fn launch_plan_rejects_no_apps() {
let err = parse_str(r#"{ "apps": [] }"#).unwrap_err();
assert!(err.to_string().contains("no `apps[]`"));
}
#[test]
fn launch_plan_rejects_multi_app() {
let plan = r#"{
"apps": [
{ "name": "a", "entrypoint": { "command": ["x"] } },
{ "name": "b", "entrypoint": { "command": ["y"] } }
]
}"#;
let err = parse_str(plan).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("single-app"), "got: {msg}");
assert!(msg.contains("a, b"), "names should appear: {msg}");
}
#[test]
fn launch_plan_rejects_empty_command() {
let plan = r#"{
"apps": [ { "entrypoint": { "command": [] } } ]
}"#;
let err = parse_str(plan).unwrap_err();
assert!(err.to_string().contains("non-empty"));
}
#[test]
fn load_launch_plan_reads_file() {
let dir = std::env::temp_dir().join(format!("mvm-launch-plan-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("launch.json");
std::fs::write(
&path,
r#"{ "apps": [ { "entrypoint": { "command": ["echo", "hi"] } } ] }"#,
)
.unwrap();
let ep = load_launch_plan(&path).unwrap();
assert_eq!(ep.command, vec!["echo", "hi"]);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn load_launch_plan_reports_missing_file() {
let err = load_launch_plan(Path::new("/nonexistent/launch.json")).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("reading launch plan"));
}
#[test]
fn target_command_launch_plan_quotes_argv() {
let req = ExecRequest {
image: ImageSource::Template("t".into()),
cpus: 1,
memory_mib: 256,
add_dirs: Vec::new(),
env: Vec::new(),
target: ExecTarget::LaunchPlan {
entrypoint: LaunchEntrypoint {
command: vec!["python".into(), "-m".into(), "x".into()],
working_dir: None,
env: BTreeMap::new(),
},
},
timeout_secs: 30,
};
assert_eq!(req.target_command(), "exec 'python' '-m' 'x'");
}
#[test]
fn build_guest_wrapper_launch_plan_emits_cd_and_env() {
let mut env = BTreeMap::new();
env.insert("PORT".to_string(), "8080".to_string());
env.insert("LOG".to_string(), "info".to_string());
let req = ExecRequest {
image: ImageSource::Template("t".into()),
cpus: 1,
memory_mib: 256,
add_dirs: Vec::new(),
env: vec![("CLI_OVER".to_string(), "wins".to_string())],
target: ExecTarget::LaunchPlan {
entrypoint: LaunchEntrypoint {
command: vec!["python".into(), "main.py".into()],
working_dir: Some("/app".into()),
env,
},
},
timeout_secs: 30,
};
let script = build_guest_wrapper(&req, &[]);
assert!(script.contains("export PORT='8080'"));
assert!(script.contains("export LOG='info'"));
let cli_pos = script
.find("export CLI_OVER='wins'")
.expect("CLI env exported");
let port_pos = script.find("export PORT='8080'").expect("port exported");
assert!(
cli_pos > port_pos,
"CLI env must appear after launch-plan env"
);
assert!(script.contains("cd '/app'"));
let cd_pos = script.find("cd '/app'").unwrap();
let exec_pos = script.find("exec 'python' 'main.py'").unwrap();
assert!(cd_pos < exec_pos, "cd must precede the final exec");
}
#[test]
fn build_guest_wrapper_inline_target_unchanged() {
let req = ExecRequest {
image: ImageSource::Template("t".into()),
cpus: 1,
memory_mib: 256,
add_dirs: Vec::new(),
env: Vec::new(),
target: ExecTarget::Inline {
argv: vec!["true".into()],
},
timeout_secs: 30,
};
let script = build_guest_wrapper(&req, &[]);
assert!(!script.contains("cd "));
assert!(!script.contains("export "));
assert!(script.contains("exec 'true'"));
}
fn template(name: &str) -> ImageSource {
ImageSource::Template(name.into())
}
fn prebuilt() -> ImageSource {
ImageSource::Prebuilt {
kernel_path: "/k".into(),
rootfs_path: "/r".into(),
initrd_path: None,
label: "lbl".into(),
}
}
fn add_dir() -> AddDir {
AddDir {
host_path: "/h".into(),
guest_path: "/g".into(),
read_only: true,
}
}
#[test]
fn snapshot_eligible_true_for_template_no_extras_with_snapshot() {
assert!(snapshot_eligible(&template("t"), &[], true, true));
}
#[test]
fn snapshot_eligible_false_when_backend_lacks_support() {
assert!(!snapshot_eligible(&template("t"), &[], true, false));
}
#[test]
fn snapshot_eligible_false_when_no_snapshot_present() {
assert!(!snapshot_eligible(&template("t"), &[], false, true));
}
#[test]
fn snapshot_eligible_false_with_add_dirs() {
assert!(!snapshot_eligible(&template("t"), &[add_dir()], true, true));
}
#[test]
fn snapshot_eligible_false_for_prebuilt_image() {
assert!(!snapshot_eligible(&prebuilt(), &[], true, true));
}
}