1use anyhow::{Context, Result};
13use mvm_core::vm_backend::{VmId, VmStartConfig, VmVolume};
14use mvm_runtime::vm::backend::AnyBackend;
15use mvm_runtime::vm::microvm;
16use serde::Deserialize;
17use std::collections::BTreeMap;
18use std::path::Path;
19
20use crate::ui;
21
22#[derive(Debug, Clone)]
28#[non_exhaustive]
29pub enum ExecTarget {
30 Inline { argv: Vec<String> },
32 LaunchPlan { entrypoint: LaunchEntrypoint },
37 }
40
41#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct LaunchEntrypoint {
50 pub command: Vec<String>,
51 pub working_dir: Option<String>,
52 pub env: BTreeMap<String, String>,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct AddDir {
64 pub host_path: String,
65 pub guest_path: String,
66 pub read_only: bool,
67}
68
69impl AddDir {
70 pub fn parse(spec: &str) -> Result<Self> {
79 let (host, rest) = spec.split_once(':').ok_or_else(|| {
80 anyhow::anyhow!("--add-dir '{spec}': expected 'host:guest[:mode]', missing ':'")
81 })?;
82 if host.is_empty() {
83 anyhow::bail!("--add-dir '{spec}': host path must not be empty");
84 }
85
86 let (guest, read_only) = match rest.rsplit_once(':') {
87 Some((path, "ro")) => (path, true),
88 Some((path, "rw")) => (path, false),
89 Some((_, tail)) if looks_like_mode_typo(tail) => {
90 anyhow::bail!("--add-dir '{spec}': unknown mode '{tail}' (expected 'ro' or 'rw')");
91 }
92 _ => (rest, true),
93 };
94
95 if guest.is_empty() {
96 anyhow::bail!("--add-dir '{spec}': guest path must not be empty");
97 }
98 if !guest.starts_with('/') {
99 anyhow::bail!("--add-dir '{spec}': guest path must be absolute (start with '/')");
100 }
101 Ok(Self {
102 host_path: expand_tilde(host),
103 guest_path: guest.to_string(),
104 read_only,
105 })
106 }
107}
108
109fn looks_like_mode_typo(tail: &str) -> bool {
110 !tail.is_empty()
111 && tail.len() <= 8
112 && !tail.contains('/')
113 && tail.chars().all(|c| c.is_ascii_alphanumeric())
114}
115
116fn expand_tilde(path: &str) -> String {
117 if let Some(rest) = path.strip_prefix("~/")
118 && let Ok(home) = std::env::var("HOME")
119 {
120 return format!("{home}/{rest}");
121 }
122 path.to_string()
123}
124
125#[derive(Debug, Clone)]
127#[non_exhaustive]
128pub enum ImageSource {
129 Template(String),
131 Prebuilt {
133 kernel_path: String,
134 rootfs_path: String,
135 initrd_path: Option<String>,
136 label: String,
138 },
139}
140
141#[derive(Debug, Clone)]
143pub struct ExecRequest {
144 pub image: ImageSource,
145 pub cpus: u32,
146 pub memory_mib: u32,
147 pub add_dirs: Vec<AddDir>,
148 pub env: Vec<(String, String)>,
149 pub target: ExecTarget,
150 pub timeout_secs: u64,
152}
153
154impl ExecRequest {
155 pub fn target_command(&self) -> String {
159 match &self.target {
160 ExecTarget::Inline { argv } => quote_argv_for_exec(argv),
161 ExecTarget::LaunchPlan { entrypoint } => quote_argv_for_exec(&entrypoint.command),
162 }
163 }
164}
165
166fn quote_argv_for_exec(argv: &[String]) -> String {
167 let quoted: Vec<String> = argv.iter().map(|a| shell_quote(a)).collect();
168 format!("exec {}", quoted.join(" "))
169}
170
171#[derive(Debug, Deserialize)]
182struct RawLaunchPlan {
183 #[serde(default)]
184 apps: Vec<RawLaunchApp>,
185}
186
187#[derive(Debug, Deserialize)]
188struct RawLaunchApp {
189 #[serde(default)]
190 name: Option<String>,
191 entrypoint: RawLaunchEntrypoint,
192 #[serde(default)]
193 env: BTreeMap<String, String>,
194}
195
196#[derive(Debug, Deserialize)]
197struct RawLaunchEntrypoint {
198 #[serde(default)]
199 command: Vec<String>,
200 #[serde(default)]
201 working_dir: Option<String>,
202 #[serde(default)]
203 env: BTreeMap<String, String>,
204}
205
206pub fn load_launch_plan(path: &Path) -> Result<LaunchEntrypoint> {
214 let bytes =
215 std::fs::read(path).with_context(|| format!("reading launch plan '{}'", path.display()))?;
216 let raw: RawLaunchPlan = serde_json::from_slice(&bytes)
217 .with_context(|| format!("parsing launch plan '{}' as JSON", path.display()))?;
218 parse_launch_plan(raw, &path.display().to_string())
219}
220
221fn parse_launch_plan(raw: RawLaunchPlan, source: &str) -> Result<LaunchEntrypoint> {
222 if raw.apps.is_empty() {
223 anyhow::bail!("launch plan '{source}' has no `apps[]` entries");
224 }
225 if raw.apps.len() > 1 {
226 let names: Vec<&str> = raw
227 .apps
228 .iter()
229 .map(|a| a.name.as_deref().unwrap_or("<unnamed>"))
230 .collect();
231 anyhow::bail!(
232 "launch plan '{source}' has {} apps ({}); `mvmctl exec` v1 supports single-app workloads only",
233 raw.apps.len(),
234 names.join(", "),
235 );
236 }
237 let RawLaunchApp {
238 name: _,
239 entrypoint,
240 env: app_env,
241 } = raw.apps.into_iter().next().expect("len == 1 above");
242 if entrypoint.command.is_empty() {
243 anyhow::bail!("launch plan '{source}': entrypoint.command must be non-empty");
244 }
245 let mut merged = app_env;
247 for (k, v) in entrypoint.env {
248 merged.insert(k, v);
249 }
250 Ok(LaunchEntrypoint {
251 command: entrypoint.command,
252 working_dir: entrypoint.working_dir,
253 env: merged,
254 })
255}
256
257pub fn shell_quote(arg: &str) -> String {
262 let mut out = String::with_capacity(arg.len() + 2);
263 out.push('\'');
264 for ch in arg.chars() {
265 if ch == '\'' {
266 out.push_str(r"'\''");
267 } else {
268 out.push(ch);
269 }
270 }
271 out.push('\'');
272 out
273}
274
275pub fn build_guest_wrapper(req: &ExecRequest, add_dir_labels: &[String]) -> String {
289 let mut script = String::from("set -e\n");
290 for (dir, label) in req.add_dirs.iter().zip(add_dir_labels.iter()) {
291 let mount_point = shell_quote(&dir.guest_path);
292 let label_q = shell_quote(label);
293 let mount_opts = if dir.read_only { " -o ro" } else { "" };
294 script.push_str(&format!(
295 "mkdir -p {mount_point}\nmount LABEL={label_q} {mount_point}{mount_opts}\n",
296 ));
297 }
298 if let ExecTarget::LaunchPlan { entrypoint } = &req.target {
299 for (k, v) in &entrypoint.env {
300 script.push_str(&format!("export {k}={}\n", shell_quote(v)));
301 }
302 }
303 for (k, v) in &req.env {
304 script.push_str(&format!("export {k}={}\n", shell_quote(v)));
305 }
306 if let ExecTarget::LaunchPlan { entrypoint } = &req.target
307 && let Some(wd) = &entrypoint.working_dir
308 {
309 script.push_str(&format!("cd {}\n", shell_quote(wd)));
310 }
311 script.push_str(&req.target_command());
312 script.push('\n');
313 script
314}
315
316pub fn transient_vm_name() -> String {
318 use std::time::{SystemTime, UNIX_EPOCH};
319 let nanos = SystemTime::now()
320 .duration_since(UNIX_EPOCH)
321 .map(|d| d.subsec_nanos())
322 .unwrap_or_default();
323 let pid = std::process::id();
324 format!("exec-{pid:x}-{nanos:08x}")
325}
326
327pub fn snapshot_eligible(
336 image: &ImageSource,
337 add_dirs: &[AddDir],
338 snap_present: bool,
339 backend_supports_snapshots: bool,
340) -> bool {
341 if !backend_supports_snapshots || !snap_present || !add_dirs.is_empty() {
342 return false;
343 }
344 matches!(image, ImageSource::Template(_))
345}
346
347pub fn run(req: ExecRequest) -> Result<i32> {
353 let backend = AnyBackend::default_backend();
354
355 let (vmlinux, initrd, rootfs, revision, flake_ref, profile, snap_info, template_id) =
359 match &req.image {
360 ImageSource::Template(name) => {
361 let (spec, vmlinux, initrd, rootfs, rev) =
362 mvm_runtime::vm::template::lifecycle::template_artifacts(name)
363 .with_context(|| format!("Loading template '{name}'"))?;
364 let snap = mvm_runtime::vm::template::lifecycle::template_snapshot_info(name)
365 .ok()
366 .flatten();
367 (
368 vmlinux,
369 initrd,
370 rootfs,
371 rev,
372 spec.flake_ref.clone(),
373 Some(spec.profile.clone()),
374 snap,
375 Some(name.clone()),
376 )
377 }
378 ImageSource::Prebuilt {
379 kernel_path,
380 rootfs_path,
381 initrd_path,
382 label,
383 } => (
384 kernel_path.clone(),
385 initrd_path.clone(),
386 rootfs_path.clone(),
387 String::new(),
388 label.clone(),
389 None,
390 None,
391 None,
392 ),
393 };
394
395 let vm_name = transient_vm_name();
398 let staging_dir = format!("{}/{}/extras", mvm_runtime::config::VMS_DIR, vm_name);
399 let mut volumes: Vec<mvm_runtime::vm::image::RuntimeVolume> = Vec::new();
400 let mut add_dir_labels: Vec<String> = Vec::new();
401 for (idx, dir) in req.add_dirs.iter().enumerate() {
402 let label = format!("mvm-extra-{idx}");
403 let image_path = format!("{staging_dir}/extra-{idx}.ext4");
404 mvm_runtime::vm::image::build_dir_image_ro(&dir.host_path, &label, &image_path)
405 .with_context(|| {
406 format!(
407 "preparing --add-dir image for '{}' -> '{}'",
408 dir.host_path, dir.guest_path
409 )
410 })?;
411 volumes.push(mvm_runtime::vm::image::RuntimeVolume {
412 host: image_path,
413 guest: dir.guest_path.clone(),
414 size: String::new(),
415 read_only: dir.read_only,
416 });
417 add_dir_labels.push(label);
418 }
419
420 let use_snapshot = snapshot_eligible(
422 &req.image,
423 &req.add_dirs,
424 snap_info.is_some(),
425 backend.capabilities().snapshots,
426 );
427
428 let start_config = VmStartConfig {
429 name: vm_name.clone(),
430 rootfs_path: rootfs.clone(),
431 kernel_path: Some(vmlinux.clone()),
432 initrd_path: initrd.clone(),
433 revision_hash: revision.clone(),
434 flake_ref: flake_ref.clone(),
435 profile: profile.clone(),
436 cpus: req.cpus,
437 memory_mib: req.memory_mib,
438 ports: Vec::new(),
439 volumes: volumes
440 .iter()
441 .map(|v| VmVolume {
442 host: v.host.clone(),
443 guest: v.guest.clone(),
444 size: v.size.clone(),
445 read_only: v.read_only,
446 })
447 .collect(),
448 config_files: Vec::new(),
449 secret_files: Vec::new(),
450 runner_dir: None,
451 };
452
453 let booted = if use_snapshot {
454 let tmpl = template_id
455 .as_deref()
456 .expect("snapshot_eligible only true for ImageSource::Template");
457 let snap = snap_info
458 .as_ref()
459 .expect("snapshot_eligible requires snap_info.is_some()");
460 ui::info(&format!(
461 "Restoring transient VM '{vm_name}' from template '{tmpl}' snapshot..."
462 ));
463 match restore_via_snapshot(&vm_name, tmpl, snap, &start_config) {
464 Ok(()) => true,
465 Err(e) => {
466 ui::warn(&format!("Snapshot restore failed: {e}; cold-booting."));
470 false
471 }
472 }
473 } else {
474 false
475 };
476
477 if !booted {
478 ui::info(&format!("Booting transient VM '{vm_name}'..."));
479 if let Err(e) = backend.start(&start_config) {
480 let _ = mvm_runtime::shell::run_in_vm(&format!("rm -rf {staging_dir}"));
481 return Err(e).context("starting transient microVM");
482 }
483 }
484
485 let interrupted = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
487 {
488 let interrupted = interrupted.clone();
489 let vm_name = vm_name.clone();
490 let _ = ctrlc::set_handler(move || {
491 interrupted.store(true, std::sync::atomic::Ordering::SeqCst);
492 let backend = AnyBackend::default_backend();
493 let _ = backend.stop(&VmId(vm_name.clone()));
494 });
495 }
496
497 let result = run_in_guest(&vm_name, &req, &add_dir_labels);
499
500 let _ = backend.stop(&VmId(vm_name.clone()));
501
502 for (idx, dir) in req.add_dirs.iter().enumerate() {
507 if dir.read_only {
508 continue;
509 }
510 let image_path = format!("{staging_dir}/extra-{idx}.ext4");
511 if let Err(e) = mvm_runtime::vm::image::rsync_image_to_host(&image_path, &dir.host_path) {
512 ui::warn(&format!(
513 "writable --add-dir sync-back failed for '{}' -> '{}': {e:#}",
514 dir.host_path, dir.guest_path,
515 ));
516 }
517 }
518
519 let _ = mvm_runtime::shell::run_in_vm(&format!("rm -rf {staging_dir}"));
520
521 if interrupted.load(std::sync::atomic::Ordering::SeqCst) {
522 anyhow::bail!("interrupted");
523 }
524 result
525}
526
527fn restore_via_snapshot(
535 vm_name: &str,
536 template_id: &str,
537 snap_info: &mvm_core::template::SnapshotInfo,
538 start_config: &VmStartConfig,
539) -> Result<()> {
540 let slot = mvm_runtime::vm::microvm::allocate_slot(vm_name)?;
541 let run_config = mvm_runtime::vm::microvm::FlakeRunConfig {
542 name: vm_name.to_string(),
543 slot,
544 vmlinux_path: start_config.kernel_path.clone().unwrap_or_default(),
545 initrd_path: start_config.initrd_path.clone(),
546 rootfs_path: start_config.rootfs_path.clone(),
547 revision_hash: start_config.revision_hash.clone(),
548 flake_ref: start_config.flake_ref.clone(),
549 profile: start_config.profile.clone(),
550 cpus: start_config.cpus,
551 memory: start_config.memory_mib,
552 volumes: Vec::new(),
556 config_files: Vec::new(),
557 secret_files: Vec::new(),
558 ports: Vec::new(),
559 network_policy: mvm_core::network_policy::NetworkPolicy::default(),
560 };
561 let rev = mvm_runtime::vm::template::lifecycle::current_revision_id(template_id)?;
562 let snap_dir = mvm_core::template::template_snapshot_dir(template_id, &rev);
563 mvm_runtime::vm::microvm::restore_from_template_snapshot(
564 template_id,
565 &run_config,
566 &snap_dir,
567 snap_info,
568 )
569}
570
571fn run_in_guest(vm_name: &str, req: &ExecRequest, labels: &[String]) -> Result<i32> {
573 if !wait_for_agent(vm_name, 30) {
574 anyhow::bail!("guest agent did not become reachable within 30s");
575 }
576 let wrapper = build_guest_wrapper(req, labels);
577 let resp = send_request(vm_name, &wrapper, req.timeout_secs)?;
578 match resp {
579 mvm_guest::vsock::GuestResponse::ExecResult {
580 exit_code,
581 stdout,
582 stderr,
583 } => {
584 if !stdout.is_empty() {
585 print!("{stdout}");
586 }
587 if !stderr.is_empty() {
588 eprint!("{stderr}");
589 }
590 Ok(exit_code)
591 }
592 mvm_guest::vsock::GuestResponse::Error { message } => {
593 anyhow::bail!("guest exec error: {message}")
594 }
595 other => anyhow::bail!("unexpected guest response: {other:?}"),
596 }
597}
598
599fn wait_for_agent(vm_name: &str, timeout_secs: u64) -> bool {
600 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
601 while std::time::Instant::now() < deadline {
602 if mvm_apple_container::vsock_connect(vm_name, mvm_guest::vsock::GUEST_AGENT_PORT).is_ok() {
603 return true;
604 }
605 if let Ok(instance_dir) = microvm::resolve_running_vm_dir(vm_name) {
606 let uds = mvm_guest::vsock::vsock_uds_path(&instance_dir);
607 if mvm_guest::vsock::ping_at(&uds).unwrap_or(false) {
608 return true;
609 }
610 }
611 std::thread::sleep(std::time::Duration::from_millis(500));
612 }
613 false
614}
615
616fn send_request(
617 vm_name: &str,
618 command: &str,
619 timeout_secs: u64,
620) -> Result<mvm_guest::vsock::GuestResponse> {
621 if let Ok(mut stream) =
622 mvm_apple_container::vsock_connect(vm_name, mvm_guest::vsock::GUEST_AGENT_PORT)
623 {
624 return mvm_guest::vsock::send_request(
625 &mut stream,
626 &mvm_guest::vsock::GuestRequest::Exec {
627 command: command.to_string(),
628 stdin: None,
629 timeout_secs: Some(timeout_secs),
630 },
631 );
632 }
633 let instance_dir = microvm::resolve_running_vm_dir(vm_name)?;
634 mvm_guest::vsock::exec_at(
635 &mvm_guest::vsock::vsock_uds_path(&instance_dir),
636 command,
637 None,
638 timeout_secs,
639 )
640}
641
642#[cfg(test)]
643mod tests {
644 use super::*;
645
646 #[test]
647 fn add_dir_parse_happy_path() {
648 let d = AddDir::parse("/tmp/src:/work").unwrap();
649 assert_eq!(d.host_path, "/tmp/src");
650 assert_eq!(d.guest_path, "/work");
651 }
652
653 #[test]
654 fn add_dir_parse_rejects_missing_colon() {
655 let err = AddDir::parse("/tmp/src").unwrap_err();
656 assert!(err.to_string().contains("missing ':'"));
657 }
658
659 #[test]
660 fn add_dir_parse_rejects_empty_host() {
661 let err = AddDir::parse(":/work").unwrap_err();
662 assert!(err.to_string().contains("host path"));
663 }
664
665 #[test]
666 fn add_dir_parse_rejects_empty_guest() {
667 let err = AddDir::parse("/tmp/src:").unwrap_err();
668 assert!(err.to_string().contains("guest path"));
669 }
670
671 #[test]
672 fn add_dir_parse_rejects_relative_guest() {
673 let err = AddDir::parse("/tmp/src:relative/path").unwrap_err();
674 assert!(err.to_string().contains("absolute"));
675 }
676
677 #[test]
678 fn add_dir_expands_tilde_in_host_path() {
679 unsafe {
681 std::env::set_var("HOME", "/tmp/fakehome");
682 }
683 let d = AddDir::parse("~/configs:/etc/configs").unwrap();
684 assert_eq!(d.host_path, "/tmp/fakehome/configs");
685 assert_eq!(d.guest_path, "/etc/configs");
686 }
687
688 #[test]
689 fn add_dir_parse_default_is_read_only() {
690 let d = AddDir::parse("/tmp/src:/work").unwrap();
691 assert!(d.read_only, "default mode should be read-only");
692 }
693
694 #[test]
695 fn add_dir_parse_explicit_ro() {
696 let d = AddDir::parse("/tmp/src:/work:ro").unwrap();
697 assert_eq!(d.host_path, "/tmp/src");
698 assert_eq!(d.guest_path, "/work");
699 assert!(d.read_only);
700 }
701
702 #[test]
703 fn add_dir_parse_explicit_rw() {
704 let d = AddDir::parse("/tmp/src:/work:rw").unwrap();
705 assert_eq!(d.host_path, "/tmp/src");
706 assert_eq!(d.guest_path, "/work");
707 assert!(!d.read_only);
708 }
709
710 #[test]
711 fn add_dir_parse_rejects_bogus_mode() {
712 let err = AddDir::parse("/tmp/src:/work:bogus").unwrap_err();
713 let msg = err.to_string();
714 assert!(msg.contains("unknown mode"), "got: {msg}");
715 assert!(msg.contains("'bogus'"), "got: {msg}");
716 }
717
718 #[test]
719 fn add_dir_extra_colons_belong_to_guest_path() {
720 let d = AddDir::parse("/host:/weird:path/file").unwrap();
724 assert_eq!(d.host_path, "/host");
725 assert_eq!(d.guest_path, "/weird:path/file");
726 assert!(d.read_only);
727 }
728
729 #[test]
730 fn shell_quote_basic() {
731 assert_eq!(shell_quote("hello"), "'hello'");
732 assert_eq!(shell_quote("hello world"), "'hello world'");
733 }
734
735 #[test]
736 fn shell_quote_escapes_single_quotes() {
737 assert_eq!(shell_quote("it's"), r"'it'\''s'");
738 }
739
740 #[test]
741 fn target_command_inline_quotes_each_arg() {
742 let req = ExecRequest {
743 image: ImageSource::Template("t".into()),
744 cpus: 1,
745 memory_mib: 256,
746 add_dirs: Vec::new(),
747 env: Vec::new(),
748 target: ExecTarget::Inline {
749 argv: vec!["uname".into(), "-a".into()],
750 },
751 timeout_secs: 30,
752 };
753 assert_eq!(req.target_command(), "exec 'uname' '-a'");
754 }
755
756 #[test]
757 fn build_guest_wrapper_no_extras() {
758 let req = ExecRequest {
759 image: ImageSource::Template("t".into()),
760 cpus: 1,
761 memory_mib: 256,
762 add_dirs: Vec::new(),
763 env: Vec::new(),
764 target: ExecTarget::Inline {
765 argv: vec!["true".into()],
766 },
767 timeout_secs: 30,
768 };
769 let script = build_guest_wrapper(&req, &[]);
770 assert!(script.starts_with("set -e\n"));
771 assert!(script.contains("exec 'true'"));
772 assert!(!script.contains("mount"));
773 assert!(!script.contains("export"));
774 }
775
776 #[test]
777 fn build_guest_wrapper_mounts_and_env() {
778 let req = ExecRequest {
779 image: ImageSource::Template("t".into()),
780 cpus: 1,
781 memory_mib: 256,
782 add_dirs: vec![AddDir {
783 host_path: "/h".into(),
784 guest_path: "/g".into(),
785 read_only: true,
786 }],
787 env: vec![("FOO".into(), "bar baz".into())],
788 target: ExecTarget::Inline {
789 argv: vec!["echo".into(), "$FOO".into()],
790 },
791 timeout_secs: 30,
792 };
793 let script = build_guest_wrapper(&req, &["mvm-extra-0".to_string()]);
794 assert!(script.contains("mkdir -p '/g'"));
795 assert!(script.contains("mount LABEL='mvm-extra-0' '/g' -o ro"));
796 assert!(script.contains("export FOO='bar baz'"));
797 assert!(script.contains("exec 'echo' '$FOO'"));
798 }
799
800 #[test]
801 fn build_guest_wrapper_writable_mount_drops_ro_flag() {
802 let req = ExecRequest {
803 image: ImageSource::Template("t".into()),
804 cpus: 1,
805 memory_mib: 256,
806 add_dirs: vec![AddDir {
807 host_path: "/h".into(),
808 guest_path: "/g".into(),
809 read_only: false,
810 }],
811 env: Vec::new(),
812 target: ExecTarget::Inline {
813 argv: vec!["true".into()],
814 },
815 timeout_secs: 30,
816 };
817 let script = build_guest_wrapper(&req, &["mvm-extra-0".to_string()]);
818 assert!(
820 script.contains("mount LABEL='mvm-extra-0' '/g'\n"),
821 "expected unqualified mount line, got: {script}"
822 );
823 assert!(!script.contains("-o ro"), "RW mount must not include -o ro");
824 }
825
826 #[test]
827 fn transient_vm_name_format() {
828 let n = transient_vm_name();
829 assert!(n.starts_with("exec-"));
830 assert!(n.len() > "exec-".len());
831 assert!(!n.contains(' '));
832 assert!(!n.contains('/'));
833 }
834
835 fn parse_str(json: &str) -> Result<LaunchEntrypoint> {
838 let raw: RawLaunchPlan = serde_json::from_str(json).expect("valid json");
839 parse_launch_plan(raw, "test")
840 }
841
842 #[test]
843 fn launch_plan_minimal_app() {
844 let plan = r#"{
845 "apps": [
846 { "entrypoint": { "command": ["python", "-m", "hello"] } }
847 ]
848 }"#;
849 let ep = parse_str(plan).unwrap();
850 assert_eq!(ep.command, vec!["python", "-m", "hello"]);
851 assert!(ep.working_dir.is_none());
852 assert!(ep.env.is_empty());
853 }
854
855 #[test]
856 fn launch_plan_with_working_dir_and_env() {
857 let plan = r#"{
858 "apps": [
859 {
860 "name": "hello",
861 "entrypoint": {
862 "command": ["python", "main.py"],
863 "working_dir": "/app",
864 "env": { "PORT": "8080" }
865 },
866 "env": { "LOG_LEVEL": "info" }
867 }
868 ]
869 }"#;
870 let ep = parse_str(plan).unwrap();
871 assert_eq!(ep.command, vec!["python", "main.py"]);
872 assert_eq!(ep.working_dir.as_deref(), Some("/app"));
873 assert_eq!(ep.env.get("PORT").map(String::as_str), Some("8080"));
874 assert_eq!(ep.env.get("LOG_LEVEL").map(String::as_str), Some("info"));
876 }
877
878 #[test]
879 fn launch_plan_entrypoint_env_overrides_app_env() {
880 let plan = r#"{
881 "apps": [
882 {
883 "entrypoint": {
884 "command": ["true"],
885 "env": { "X": "from-entrypoint" }
886 },
887 "env": { "X": "from-app", "Y": "y" }
888 }
889 ]
890 }"#;
891 let ep = parse_str(plan).unwrap();
892 assert_eq!(ep.env.get("X").map(String::as_str), Some("from-entrypoint"));
893 assert_eq!(ep.env.get("Y").map(String::as_str), Some("y"));
894 }
895
896 #[test]
897 fn launch_plan_ignores_unknown_top_level_fields() {
898 let plan = r#"{
900 "version": "v0",
901 "workload": { "id": "hello" },
902 "apps": [ { "entrypoint": { "command": ["true"] } } ],
903 "future_field": 42
904 }"#;
905 assert!(parse_str(plan).is_ok());
906 }
907
908 #[test]
909 fn launch_plan_rejects_no_apps() {
910 let err = parse_str(r#"{ "apps": [] }"#).unwrap_err();
911 assert!(err.to_string().contains("no `apps[]`"));
912 }
913
914 #[test]
915 fn launch_plan_rejects_multi_app() {
916 let plan = r#"{
917 "apps": [
918 { "name": "a", "entrypoint": { "command": ["x"] } },
919 { "name": "b", "entrypoint": { "command": ["y"] } }
920 ]
921 }"#;
922 let err = parse_str(plan).unwrap_err();
923 let msg = err.to_string();
924 assert!(msg.contains("single-app"), "got: {msg}");
925 assert!(msg.contains("a, b"), "names should appear: {msg}");
926 }
927
928 #[test]
929 fn launch_plan_rejects_empty_command() {
930 let plan = r#"{
931 "apps": [ { "entrypoint": { "command": [] } } ]
932 }"#;
933 let err = parse_str(plan).unwrap_err();
934 assert!(err.to_string().contains("non-empty"));
935 }
936
937 #[test]
938 fn load_launch_plan_reads_file() {
939 let dir = std::env::temp_dir().join(format!("mvm-launch-plan-test-{}", std::process::id()));
940 std::fs::create_dir_all(&dir).unwrap();
941 let path = dir.join("launch.json");
942 std::fs::write(
943 &path,
944 r#"{ "apps": [ { "entrypoint": { "command": ["echo", "hi"] } } ] }"#,
945 )
946 .unwrap();
947 let ep = load_launch_plan(&path).unwrap();
948 assert_eq!(ep.command, vec!["echo", "hi"]);
949 std::fs::remove_dir_all(&dir).ok();
950 }
951
952 #[test]
953 fn load_launch_plan_reports_missing_file() {
954 let err = load_launch_plan(Path::new("/nonexistent/launch.json")).unwrap_err();
955 let msg = format!("{err:#}");
956 assert!(msg.contains("reading launch plan"));
957 }
958
959 #[test]
960 fn target_command_launch_plan_quotes_argv() {
961 let req = ExecRequest {
962 image: ImageSource::Template("t".into()),
963 cpus: 1,
964 memory_mib: 256,
965 add_dirs: Vec::new(),
966 env: Vec::new(),
967 target: ExecTarget::LaunchPlan {
968 entrypoint: LaunchEntrypoint {
969 command: vec!["python".into(), "-m".into(), "x".into()],
970 working_dir: None,
971 env: BTreeMap::new(),
972 },
973 },
974 timeout_secs: 30,
975 };
976 assert_eq!(req.target_command(), "exec 'python' '-m' 'x'");
977 }
978
979 #[test]
980 fn build_guest_wrapper_launch_plan_emits_cd_and_env() {
981 let mut env = BTreeMap::new();
982 env.insert("PORT".to_string(), "8080".to_string());
983 env.insert("LOG".to_string(), "info".to_string());
984 let req = ExecRequest {
985 image: ImageSource::Template("t".into()),
986 cpus: 1,
987 memory_mib: 256,
988 add_dirs: Vec::new(),
989 env: vec![("CLI_OVER".to_string(), "wins".to_string())],
990 target: ExecTarget::LaunchPlan {
991 entrypoint: LaunchEntrypoint {
992 command: vec!["python".into(), "main.py".into()],
993 working_dir: Some("/app".into()),
994 env,
995 },
996 },
997 timeout_secs: 30,
998 };
999 let script = build_guest_wrapper(&req, &[]);
1000 assert!(script.contains("export PORT='8080'"));
1002 assert!(script.contains("export LOG='info'"));
1003 let cli_pos = script
1005 .find("export CLI_OVER='wins'")
1006 .expect("CLI env exported");
1007 let port_pos = script.find("export PORT='8080'").expect("port exported");
1008 assert!(
1009 cli_pos > port_pos,
1010 "CLI env must appear after launch-plan env"
1011 );
1012 assert!(script.contains("cd '/app'"));
1014 let cd_pos = script.find("cd '/app'").unwrap();
1015 let exec_pos = script.find("exec 'python' 'main.py'").unwrap();
1016 assert!(cd_pos < exec_pos, "cd must precede the final exec");
1017 }
1018
1019 #[test]
1020 fn build_guest_wrapper_inline_target_unchanged() {
1021 let req = ExecRequest {
1023 image: ImageSource::Template("t".into()),
1024 cpus: 1,
1025 memory_mib: 256,
1026 add_dirs: Vec::new(),
1027 env: Vec::new(),
1028 target: ExecTarget::Inline {
1029 argv: vec!["true".into()],
1030 },
1031 timeout_secs: 30,
1032 };
1033 let script = build_guest_wrapper(&req, &[]);
1034 assert!(!script.contains("cd "));
1035 assert!(!script.contains("export "));
1036 assert!(script.contains("exec 'true'"));
1037 }
1038
1039 fn template(name: &str) -> ImageSource {
1042 ImageSource::Template(name.into())
1043 }
1044
1045 fn prebuilt() -> ImageSource {
1046 ImageSource::Prebuilt {
1047 kernel_path: "/k".into(),
1048 rootfs_path: "/r".into(),
1049 initrd_path: None,
1050 label: "lbl".into(),
1051 }
1052 }
1053
1054 fn add_dir() -> AddDir {
1055 AddDir {
1056 host_path: "/h".into(),
1057 guest_path: "/g".into(),
1058 read_only: true,
1059 }
1060 }
1061
1062 #[test]
1063 fn snapshot_eligible_true_for_template_no_extras_with_snapshot() {
1064 assert!(snapshot_eligible(&template("t"), &[], true, true));
1065 }
1066
1067 #[test]
1068 fn snapshot_eligible_false_when_backend_lacks_support() {
1069 assert!(!snapshot_eligible(&template("t"), &[], true, false));
1070 }
1071
1072 #[test]
1073 fn snapshot_eligible_false_when_no_snapshot_present() {
1074 assert!(!snapshot_eligible(&template("t"), &[], false, true));
1075 }
1076
1077 #[test]
1078 fn snapshot_eligible_false_with_add_dirs() {
1079 assert!(!snapshot_eligible(&template("t"), &[add_dir()], true, true));
1081 }
1082
1083 #[test]
1084 fn snapshot_eligible_false_for_prebuilt_image() {
1085 assert!(!snapshot_eligible(&prebuilt(), &[], true, true));
1087 }
1088}