Skip to main content

mvm_cli/
exec.rs

1//! `mvmctl exec` — boot a transient microVM, run one command, tear down.
2//!
3//! Composes existing primitives: template artifact resolution → backend
4//! start → vsock guest agent → backend stop. The "what to run" is modeled
5//! as a tagged enum so future variants (mvmforge `launch.json`, baked-in
6//! template entrypoint) can be added without churning the inline-command
7//! surface.
8//!
9//! Dev-mode only: inherits the existing `policy.access.debug_exec` gate
10//! enforced by the guest agent.
11
12use 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/// Where to source the command that runs inside the transient microVM.
23///
24/// Marked `non_exhaustive` so future variants (e.g. baked-in template
25/// entrypoint) can be added without breaking match arms in callers outside
26/// this crate.
27#[derive(Debug, Clone)]
28#[non_exhaustive]
29pub enum ExecTarget {
30    /// Argv supplied directly on the CLI.
31    Inline { argv: Vec<String> },
32    /// Entrypoint sourced from an mvmforge `launch.json` workload IR.
33    ///
34    /// v1 supports single-app workloads only. Multi-app workloads require
35    /// orchestration that's out of scope for `mvmctl exec`.
36    LaunchPlan { entrypoint: LaunchEntrypoint },
37    // Future variants (do not implement until needed):
38    // TemplateEntrypoint,               // entrypoint baked into template metadata
39}
40
41/// Resolved entrypoint extracted from an mvmforge `launch.json`.
42///
43/// Mirrors the subset of the v0 IR that `mvmctl exec` needs:
44///   - `command` — argv to exec inside the guest.
45///   - `working_dir` — optional `cd` target before exec.
46///   - `env` — merged from `apps[].env` (lower precedence) and
47///     `apps[].entrypoint.env` (higher precedence), per mvmforge semantics.
48#[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/// One `--add-dir host:guest[:mode]` mapping.
56///
57/// The host directory is materialized into a small ext4 image attached as
58/// an extra Firecracker drive, then mounted at `guest_path` by a wrapper
59/// script before the user's command runs. When `read_only` is false
60/// (mode `:rw`), guest writes land in the ext4 image and are rsynced
61/// back to the host directory after the command exits — see ADR-002.
62#[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    /// Parse a `host:guest[:mode]` spec.
71    ///
72    /// The first colon splits host from guest. An optional trailing
73    /// `:ro` or `:rw` selects the mount mode (default `:ro`). Other
74    /// trailing tokens that look like a mode (no slash, alphanumeric)
75    /// are rejected to catch typos. Guest paths that legitimately
76    /// contain colons remain supported as long as the trailing
77    /// component is unambiguously path-shaped (contains a slash).
78    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/// Where the VM's disk image and kernel come from.
126#[derive(Debug, Clone)]
127#[non_exhaustive]
128pub enum ImageSource {
129    /// A registered template (resolved via `template::lifecycle::template_artifacts`).
130    Template(String),
131    /// Pre-built kernel + rootfs paths (e.g., the cached dev image).
132    Prebuilt {
133        kernel_path: String,
134        rootfs_path: String,
135        initrd_path: Option<String>,
136        /// Display label used in messages and `flake_ref` (no functional effect).
137        label: String,
138    },
139}
140
141/// All inputs to the orchestrator.
142#[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    /// Timeout for the in-guest command in seconds.
151    pub timeout_secs: u64,
152}
153
154impl ExecRequest {
155    /// Convert the target into a single shell command string suitable for
156    /// `GuestRequest::Exec`. Argv is shell-quoted and prefixed with `exec`
157    /// so the process inherits the wrapper's stdio.
158    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// ---------------------------------------------------------------------------
172// mvmforge launch.json parser
173// ---------------------------------------------------------------------------
174
175/// Permissive deserialization shapes for the subset of mvmforge's v0
176/// Workload IR that `mvmctl exec` consumes.
177///
178/// `deny_unknown_fields` is intentionally NOT set so newer mvmforge
179/// releases that add optional fields don't break parsing. We *do* require
180/// `apps[].entrypoint.command` because without it there is nothing to run.
181#[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
206/// Read and parse an mvmforge `launch.json` from disk.
207///
208/// v1 contract:
209///   - file must exist and be readable
210///   - JSON must contain at least one app with a non-empty entrypoint command
211///   - if multiple apps are declared, return an error (v1 doesn't orchestrate
212///     multi-service workloads — that's mvmd's job)
213pub 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    // mvmforge: app.env is merged under (overridden by) entrypoint.env.
246    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
257/// Quote a single argument for inclusion in a shell command line.
258///
259/// Wraps in single quotes and escapes embedded single quotes the
260/// portable POSIX way (`'` → `'\''`).
261pub 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
275/// Build the wrapper script that runs inside the guest:
276///   1. mounts each `--add-dir` ext4 image read-only by label
277///   2. exports launch-plan-derived env vars (when target is LaunchPlan)
278///   3. exports CLI `--env` vars (CLI overrides launch-plan)
279///   4. cds into `working_dir` (when target is LaunchPlan and it's set)
280///   5. execs the resolved command
281///
282/// `add_dir_labels` is the parallel list of ext4 labels assigned to each
283/// `AddDir` (in the same order as `req.add_dirs`).
284///
285/// Env precedence (lowest → highest): launch-plan app.env → launch-plan
286/// entrypoint.env → CLI `--env`. The first two are merged in
287/// `parse_launch_plan`; CLI wins by being emitted last.
288pub 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
316/// Generate a transient VM name for an exec invocation.
317pub 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
327/// Decide whether snapshot restore is safe for this request.
328///
329/// v2 (issue #7) only enables it for the trivial case: a registered template
330/// (so the image has a snapshot at all), no `--add-dir` extras (so the drive
331/// layout matches the snapshot's recorded layout), and a backend that
332/// advertises snapshot support. Adding `--add-dir` would change the drive
333/// count and break the snapshot — that case is tracked separately in #7's
334/// "harder" branch and stays cold-boot for now.
335pub 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
347/// Run the request: boot, run, tear down.
348///
349/// Returns the guest command's exit code. On orchestrator failure (boot,
350/// agent unreachable, vsock error), returns an error; the VM is torn down
351/// best-effort before returning.
352pub fn run(req: ExecRequest) -> Result<i32> {
353    let backend = AnyBackend::default_backend();
354
355    // Resolve image artifacts: either a named template or a pre-built pair.
356    // For templates, also probe for a pre-built snapshot so we can skip the
357    // cold-boot cost when the request is snapshot-eligible.
358    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    // Build read-only ext4 images for each --add-dir, staged in a transient
396    // VMS subdirectory so cleanup is straightforward.
397    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    // Snapshot path is taken when the request is eligible; otherwise cold boot.
421    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                // macOS / Lima QEMU returns os error 95 (EOPNOTSUPP) on vsock
467                // snapshots; cold boot still works there. Fall back rather
468                // than failing the whole exec.
469                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    // Install Ctrl-C handler that tears the VM down.
486    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    // Run the command + always tear down.
498    let result = run_in_guest(&vm_name, &req, &add_dir_labels);
499
500    let _ = backend.stop(&VmId(vm_name.clone()));
501
502    // ADR-002: writable --add-dir uses rsync-back. With the VM stopped the
503    // ext4 image is no longer in use, so we mount it host-side and rsync
504    // its contents over the host directory before nuking the staging dir.
505    // Failures here are warned but do not override the guest exit code.
506    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
527/// Restore a transient microVM from a template snapshot instead of cold-booting.
528///
529/// Mirrors the snapshot path in `cmd_run`: allocate a slot, build a
530/// `FlakeRunConfig` matching the snapshot's recorded layout, then call
531/// `microvm::restore_from_template_snapshot`. The caller is responsible for
532/// ensuring the request is `snapshot_eligible` first (no `--add-dir`,
533/// template image source).
534fn 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        // Snapshot-eligible callers have no extra volumes; if that ever
553        // changes the snapshot layout will mismatch and Firecracker will
554        // refuse to load — `snapshot_eligible` enforces this.
555        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
571/// Send the wrapped command to the guest agent and stream stdout/stderr.
572fn 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        // SAFETY: test process is single-threaded for env access.
680        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        // A guest path that legitimately contains a colon: the trailing
721        // component must be path-shaped (contains a slash) so we can
722        // distinguish it from a mode token.
723        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        // RW mount is unqualified — no `-o ro`.
819        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    // -- launch.json parser --
836
837    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        // app.env merged in (under entrypoint.env precedence, but no conflict here).
875        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        // mvmforge ships `version`, `workload.id`, etc. — we don't care about them.
899        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        // Env from entrypoint exported.
1001        assert!(script.contains("export PORT='8080'"));
1002        assert!(script.contains("export LOG='info'"));
1003        // CLI env exported AFTER entrypoint env, so it wins on conflict.
1004        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        // cd into working_dir before exec.
1013        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        // Sanity: inline target wrapper still does not emit cd or extra env blocks.
1022        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    // -- snapshot_eligible --
1040
1041    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        // Adding extra drives changes the recorded layout; snapshot would fail.
1080        assert!(!snapshot_eligible(&template("t"), &[add_dir()], true, true));
1081    }
1082
1083    #[test]
1084    fn snapshot_eligible_false_for_prebuilt_image() {
1085        // The bundled default image isn't a registered template — no snapshot exists.
1086        assert!(!snapshot_eligible(&prebuilt(), &[], true, true));
1087    }
1088}