Skip to main content

mvm_runtime/vm/
microvm.rs

1use anyhow::Result;
2use mvm_core::platform;
3
4use super::{firecracker, lima, network};
5use crate::config::*;
6use crate::shell::{run_in_vm, run_in_vm_stdout, run_in_vm_visible};
7use crate::ui;
8use crate::vm::image::RuntimeVolume;
9
10/// Ensure we have a Linux execution environment.
11/// On macOS: checks that the Lima VM is running.
12/// On native Linux (including inside Lima): no-op.
13fn require_linux_env() -> Result<()> {
14    if platform::current().needs_lima() {
15        lima::require_running()?;
16    }
17    Ok(())
18}
19
20/// Resolve MICROVM_DIR (~) to an absolute path inside the Lima VM.
21fn resolve_microvm_dir() -> Result<String> {
22    run_in_vm_stdout(&format!("echo {}", MICROVM_DIR))
23}
24
25/// Resolve a per-VM directory path (~ expansion) inside the Lima VM.
26fn resolve_vm_dir(slot: &VmSlot) -> Result<String> {
27    run_in_vm_stdout(&format!("echo {}", slot.vm_dir))
28}
29
30/// Start the Firecracker daemon inside the Lima VM (background).
31fn start_firecracker_daemon(abs_dir: &str) -> Result<()> {
32    ui::info("Starting Firecracker...");
33    run_in_vm_visible(&format!(
34        r#"
35        mkdir -p {dir}
36        sudo rm -f {socket}
37        touch {dir}/console.log {dir}/firecracker.log
38        sudo bash -c 'nohup setsid firecracker --api-sock {socket} --enable-pci \
39            </dev/null >{dir}/console.log 2>{dir}/firecracker.log &
40            echo $! > {dir}/.fc-pid'
41
42        echo "[mvm] Waiting for API socket..."
43        for i in $(seq 1 30); do
44            [ -S {socket} ] && break
45            sleep 0.1
46        done
47
48        if [ ! -S {socket} ]; then
49            echo "[mvm] ERROR: API socket did not appear." >&2
50            exit 1
51        fi
52        echo "[mvm] Firecracker started."
53        "#,
54        socket = API_SOCKET,
55        dir = abs_dir,
56    ))
57}
58
59/// Start a Firecracker daemon in a per-VM directory with its own socket.
60fn start_vm_firecracker(abs_dir: &str, abs_socket: &str) -> Result<()> {
61    ui::info("Starting Firecracker...");
62    run_in_vm_visible(&format!(
63        r#"
64        mkdir -p {dir}
65        sudo rm -f {socket}
66        touch {dir}/console.log {dir}/firecracker.log
67        sudo bash -c 'nohup setsid firecracker --api-sock {socket} --enable-pci \
68            </dev/null >{dir}/console.log 2>{dir}/firecracker.log &
69            echo $! > {dir}/fc.pid'
70
71        echo "[mvm] Waiting for API socket..."
72        for i in $(seq 1 30); do
73            [ -S {socket} ] && break
74            sleep 0.1
75        done
76
77        if [ ! -S {socket} ]; then
78            echo "[mvm] ERROR: API socket did not appear." >&2
79            exit 1
80        fi
81        echo "[mvm] Firecracker started."
82        "#,
83        socket = abs_socket,
84        dir = abs_dir,
85    ))
86}
87
88/// Send API PUT request to Firecracker via its Unix socket.
89fn api_put(path: &str, data: &str) -> Result<()> {
90    api_put_socket(API_SOCKET, path, data)
91}
92
93/// Send API PUT request to a specific Firecracker socket.
94fn api_put_socket(socket: &str, path: &str, data: &str) -> Result<()> {
95    let script = format!(
96        r#"
97        response=$(sudo curl -s -w "\n%{{http_code}}" -X PUT --unix-socket {socket} \
98            --data '{data}' "http://localhost{path}")
99        code=$(echo "$response" | tail -1)
100        body=$(echo "$response" | sed '$d')
101        if [ "$code" -ge 400 ]; then
102            echo "[mvm] ERROR: PUT {path} returned $code: $body" >&2
103            exit 1
104        fi
105        "#,
106        socket = socket,
107        path = path,
108        data = data,
109    );
110    run_in_vm_visible(&script)
111}
112
113/// Configure the microVM via the Firecracker API (dev-mode, legacy).
114fn configure_microvm(state: &MvmState, abs_dir: &str) -> Result<()> {
115    ui::info("Configuring logger...");
116    api_put(
117        "/logger",
118        &format!(
119            r#"{{"log_path": "{dir}/firecracker.log", "level": "Debug", "show_level": true, "show_log_origin": true}}"#,
120            dir = abs_dir,
121        ),
122    )?;
123
124    let kernel_path = format!("{}/{}", abs_dir, state.kernel);
125    let rootfs_path = format!("{}/{}", abs_dir, state.rootfs);
126
127    // Use kernel cmdline IP params (no SSH-based guest network config).
128    // net.ifnames=0 forces classic eth0 naming when PCI is enabled.
129    let kernel_boot_args = format!(
130        "console=ttyS0 reboot=k panic=1 net.ifnames=0 ip={guest}::{gateway}:255.255.255.252::eth0:off",
131        guest = GUEST_IP,
132        gateway = TAP_IP,
133    );
134
135    ui::info(&format!("Setting boot source: {}", state.kernel));
136    api_put(
137        "/boot-source",
138        &format!(
139            r#"{{"kernel_image_path": "{kernel}", "boot_args": "{args}"}}"#,
140            kernel = kernel_path,
141            args = kernel_boot_args,
142        ),
143    )?;
144
145    ui::info(&format!("Setting rootfs: {}", state.rootfs));
146    api_put(
147        "/drives/rootfs",
148        &format!(
149            r#"{{"drive_id": "rootfs", "path_on_host": "{rootfs}", "is_root_device": true, "is_read_only": false}}"#,
150            rootfs = rootfs_path,
151        ),
152    )?;
153
154    ui::info("Setting network interface...");
155    api_put(
156        "/network-interfaces/net1",
157        &format!(
158            r#"{{"iface_id": "net1", "guest_mac": "{mac}", "host_dev_name": "{tap}"}}"#,
159            mac = FC_MAC,
160            tap = TAP_DEV,
161        ),
162    )?;
163
164    ui::info("Setting vsock device...");
165    api_put(
166        "/vsock",
167        &format!(
168            r#"{{"vsock_id": "vsock0", "guest_cid": {cid}, "uds_path": "{dir}/v.sock"}}"#,
169            cid = mvm_guest::vsock::GUEST_CID,
170            dir = abs_dir,
171        ),
172    )?;
173
174    Ok(())
175}
176
177/// Full start sequence: network, firecracker, configure, boot (headless).
178///
179/// MicroVMs never have SSH enabled. They run as headless workloads and
180/// communicate via vsock. Use `mvm shell` to access the Lima VM environment.
181pub fn start() -> Result<()> {
182    require_linux_env()?;
183
184    // Check if already running
185    if firecracker::is_running()? {
186        ui::info("Firecracker is already running.");
187        ui::info("Use 'mvm stop' to shut down, then 'mvm start' to restart.");
188        return Ok(());
189    }
190
191    // Read state file for asset paths
192    let state = read_state_or_discover()?;
193
194    // Resolve ~/microvm to absolute path so it works in both user and sudo contexts
195    let abs_dir = resolve_microvm_dir()?;
196
197    // Set up networking
198    network::setup()?;
199
200    // Start Firecracker daemon
201    start_firecracker_daemon(&abs_dir)?;
202
203    // Configure microVM
204    configure_microvm(&state, &abs_dir)?;
205
206    // Start the instance
207    ui::info("Starting microVM...");
208    std::thread::sleep(std::time::Duration::from_millis(15));
209    api_put("/actions", r#"{"action_type": "InstanceStart"}"#)?;
210
211    // Make vsock socket accessible to the current user
212    let _ = run_in_vm(&format!("sudo chmod 0666 {}/v.sock 2>/dev/null", abs_dir));
213
214    ui::banner(&[
215        "MicroVM is running!",
216        "",
217        &format!("  Guest IP: {}", GUEST_IP),
218        "",
219        "Use 'mvm status' to check the microVM.",
220        "Use 'mvm stop' to shut down the microVM.",
221        "Use 'mvm shell' to access the Lima VM environment.",
222    ]);
223
224    Ok(())
225}
226
227/// Stop the microVM: kill Firecracker, clean up networking (legacy dev-mode).
228pub fn stop() -> Result<()> {
229    require_linux_env()?;
230
231    if !firecracker::is_running()? {
232        ui::info("MicroVM is not running.");
233        return Ok(());
234    }
235
236    ui::info("Stopping microVM...");
237
238    // Try graceful shutdown via API
239    let _ = run_in_vm(&format!(
240        r#"sudo curl -s -X PUT --unix-socket {socket} \
241            --data '{{"action_type": "SendCtrlAltDel"}}' \
242            "http://localhost/actions" 2>/dev/null || true"#,
243        socket = API_SOCKET,
244    ));
245
246    // Give it a moment, then force kill
247    std::thread::sleep(std::time::Duration::from_secs(2));
248
249    run_in_vm(&format!(
250        r#"
251        if [ -f {dir}/.fc-pid ]; then
252            sudo kill $(cat {dir}/.fc-pid) 2>/dev/null || true
253            rm -f {dir}/.fc-pid
254        fi
255        sudo pkill -x firecracker 2>/dev/null || true
256        sudo rm -f {socket}
257        rm -f {dir}/.mvm-run-info
258        rm -f {dir}/v.sock
259        "#,
260        dir = MICROVM_DIR,
261        socket = API_SOCKET,
262    ))?;
263
264    // Tear down networking
265    network::teardown()?;
266
267    ui::success("MicroVM stopped.");
268    Ok(())
269}
270
271/// Read the state file, or discover assets by listing files.
272fn read_state_or_discover() -> Result<MvmState> {
273    let json = run_in_vm_stdout(&format!(
274        "cat {dir}/.mvm-state 2>/dev/null || echo 'null'",
275        dir = MICROVM_DIR,
276    ))?;
277
278    if let Ok(state) = serde_json::from_str::<MvmState>(&json)
279        && !state.kernel.is_empty()
280        && !state.rootfs.is_empty()
281        && !state.ssh_key.is_empty()
282    {
283        return Ok(state);
284    }
285
286    // Discover from files
287    let kernel = run_in_vm_stdout(&format!(
288        "cd {} && ls vmlinux-* 2>/dev/null | tail -1",
289        MICROVM_DIR
290    ))?;
291    let rootfs = run_in_vm_stdout(&format!(
292        "cd {} && ls *.ext4 2>/dev/null | tail -1",
293        MICROVM_DIR
294    ))?;
295    let ssh_key = run_in_vm_stdout(&format!(
296        "cd {} && ls *.id_rsa 2>/dev/null | tail -1",
297        MICROVM_DIR
298    ))?;
299
300    if kernel.is_empty() || rootfs.is_empty() || ssh_key.is_empty() {
301        anyhow::bail!(
302            "Missing microVM assets in {}. Run 'mvm setup' first.\n  kernel={:?} rootfs={:?} ssh_key={:?}",
303            MICROVM_DIR,
304            kernel,
305            rootfs,
306            ssh_key,
307        );
308    }
309
310    Ok(MvmState {
311        kernel,
312        rootfs,
313        ssh_key,
314        fc_pid: None,
315    })
316}
317
318// ============================================================================
319// Flake-based run: multi-VM with bridge networking
320// ============================================================================
321
322/// Configuration for running a Firecracker VM from flake-built artifacts.
323pub struct FlakeRunConfig {
324    /// VM name (user-provided or auto-generated).
325    pub name: String,
326    /// Network slot for this VM.
327    pub slot: VmSlot,
328    /// Absolute path to the kernel image inside the Lima VM.
329    pub vmlinux_path: String,
330    /// Absolute path to the initial ramdisk (NixOS stage-1), if present.
331    pub initrd_path: Option<String>,
332    /// Absolute path to the root filesystem inside the Lima VM.
333    pub rootfs_path: String,
334    /// Nix store revision hash.
335    pub revision_hash: String,
336    /// Original flake reference (for display / status).
337    pub flake_ref: String,
338    /// Flake profile name (e.g. "worker", "gateway"), if specified.
339    pub profile: Option<String>,
340    /// Number of vCPUs.
341    pub cpus: u32,
342    /// Memory in MiB.
343    pub memory: u32,
344    /// Extra volumes to attach (mounted via config drive, not SSH).
345    pub volumes: Vec<RuntimeVolume>,
346}
347
348/// Boot a Firecracker VM from flake-built artifacts (headless).
349///
350/// Each VM gets its own directory under ~/microvm/vms/<name>/ with a
351/// separate Firecracker socket, PID file, and log.  The bridge network
352/// is shared, but each VM has its own TAP device and guest IP.
353pub fn run_from_build(config: &FlakeRunConfig) -> Result<()> {
354    require_linux_env()?;
355
356    let slot = &config.slot;
357
358    // Check if this VM name is already running
359    let abs_dir = resolve_vm_dir(slot)?;
360    let abs_socket = format!("{}/fc.socket", abs_dir);
361    let pid_file = format!("{}/fc.pid", abs_dir);
362
363    if firecracker::is_vm_running(&pid_file)? {
364        ui::info(&format!("VM '{}' is already running.", slot.name));
365        ui::info("Use 'mvm stop <name>' to shut it down first.");
366        return Ok(());
367    }
368
369    // Ensure bridge network exists (idempotent)
370    network::bridge_ensure()?;
371
372    // Create TAP device for this VM
373    network::tap_create(slot)?;
374
375    // Start Firecracker daemon in per-VM directory
376    start_vm_firecracker(&abs_dir, &abs_socket)?;
377
378    // Configure VM via Firecracker API
379    configure_flake_microvm(config, &abs_dir, &abs_socket)?;
380
381    // Boot the instance
382    ui::info("Starting microVM...");
383    std::thread::sleep(std::time::Duration::from_millis(15));
384    api_put_socket(
385        &abs_socket,
386        "/actions",
387        r#"{"action_type": "InstanceStart"}"#,
388    )?;
389
390    // Make vsock socket accessible to the current user
391    let _ = run_in_vm(&format!("sudo chmod 0666 {}/v.sock 2>/dev/null", abs_dir));
392
393    // Persist run info for `mvm status`
394    write_vm_run_info(config, &abs_dir)?;
395
396    ui::banner(&[
397        &format!("MicroVM '{}' is running!", config.name),
398        "",
399        &format!("  Guest IP: {}", slot.guest_ip),
400        &format!("  Revision: {}", config.revision_hash),
401        "",
402        &format!("Use 'mvm stop {}' to shut down this VM.", config.name),
403        "Use 'mvm status' to list all running VMs.",
404    ]);
405
406    Ok(())
407}
408
409/// Stop a specific named VM.
410pub fn stop_vm(name: &str) -> Result<()> {
411    require_linux_env()?;
412
413    let abs_vms = run_in_vm_stdout(&format!("echo {}", VMS_DIR))?;
414    let abs_dir = format!("{}/{}", abs_vms, name);
415    let pid_file = format!("{}/fc.pid", abs_dir);
416    let socket = format!("{}/fc.socket", abs_dir);
417
418    if !firecracker::is_vm_running(&pid_file)? {
419        ui::info(&format!("VM '{}' is not running.", name));
420        return Ok(());
421    }
422
423    ui::info(&format!("Stopping VM '{}'...", name));
424
425    // Try graceful shutdown
426    let _ = run_in_vm(&format!(
427        r#"sudo curl -s -X PUT --unix-socket {socket} \
428            --data '{{"action_type": "SendCtrlAltDel"}}' \
429            "http://localhost/actions" 2>/dev/null || true"#,
430        socket = socket,
431    ));
432
433    std::thread::sleep(std::time::Duration::from_secs(2));
434
435    // Force kill and clean up
436    run_in_vm(&format!(
437        r#"
438        if [ -f {pid} ]; then
439            sudo kill $(cat {pid}) 2>/dev/null || true
440        fi
441        sudo rm -f {socket}
442        "#,
443        pid = pid_file,
444        socket = socket,
445    ))?;
446
447    // Read run info to find the TAP device to destroy
448    if let Some(info) = read_vm_run_info_from(&abs_dir)
449        && let Some(ref vm_name) = info.name
450    {
451        // Reconstruct slot to find TAP name — scan for the index
452        if let Some(idx) = read_slot_index(&abs_dir) {
453            let slot = VmSlot::new(vm_name, idx);
454            let _ = network::tap_destroy(&slot);
455        }
456    }
457
458    // Remove the VM directory
459    let _ = run_in_vm(&format!("rm -rf {}", abs_dir));
460
461    ui::success(&format!("VM '{}' stopped.", name));
462    Ok(())
463}
464
465/// Stop all running VMs.
466pub fn stop_all_vms() -> Result<()> {
467    require_linux_env()?;
468
469    let vms = list_vms()?;
470    if vms.is_empty() {
471        ui::info("No VMs are running.");
472        return Ok(());
473    }
474
475    for info in &vms {
476        if let Some(ref name) = info.name {
477            stop_vm(name)?;
478        }
479    }
480
481    // Clean up bridge if no VMs left
482    let remaining = list_vms()?;
483    if remaining.is_empty() {
484        network::bridge_teardown()?;
485    }
486
487    Ok(())
488}
489
490/// Show logs from a named VM.
491///
492/// By default shows the guest serial console (`console.log`).
493/// With `hypervisor=true`, shows Firecracker hypervisor logs (`firecracker.log`).
494pub fn logs(name: &str, follow: bool, lines: u32, hypervisor: bool) -> Result<()> {
495    require_linux_env()?;
496
497    let abs_vms = run_in_vm_stdout(&format!("echo {}", VMS_DIR))?;
498    let filename = if hypervisor {
499        "firecracker.log"
500    } else {
501        "console.log"
502    };
503    let log_file = format!("{}/{}/{}", abs_vms, name, filename);
504
505    // Check the log file exists; fall back to firecracker.log for VMs started before
506    // the console.log split.
507    let exists = run_in_vm_stdout(&format!("[ -f {} ] && echo yes || echo no", log_file))?;
508    if exists.trim() != "yes" {
509        if !hypervisor {
510            // Try legacy location (pre-split VMs wrote everything to firecracker.log)
511            let fallback = format!("{}/{}/firecracker.log", abs_vms, name);
512            let fb_exists =
513                run_in_vm_stdout(&format!("[ -f {} ] && echo yes || echo no", fallback))?;
514            if fb_exists.trim() == "yes" {
515                ui::warn(
516                    "console.log not found; showing firecracker.log (VM started before log split)",
517                );
518                return show_log_file(&fallback, follow, lines);
519            }
520        }
521        anyhow::bail!("No logs found for VM '{}' (is the name correct?)", name);
522    }
523
524    show_log_file(&log_file, follow, lines)
525}
526
527fn show_log_file(log_file: &str, follow: bool, lines: u32) -> Result<()> {
528    if follow {
529        run_in_vm_visible(&format!("tail -f {}", log_file))?;
530    } else {
531        let output = run_in_vm_stdout(&format!("tail -n {} {}", lines, log_file))?;
532        print!("{}", output);
533    }
534    Ok(())
535}
536
537/// List all running VMs by scanning ~/microvm/vms/*/run-info.json.
538pub fn list_vms() -> Result<Vec<RunInfo>> {
539    let output = run_in_vm_stdout(&format!(
540        "for f in {dir}/*/run-info.json; do [ -f \"$f\" ] && cat \"$f\"; done 2>/dev/null || true",
541        dir = VMS_DIR,
542    ))?;
543
544    let mut vms = Vec::new();
545    for line in output.lines() {
546        let line = line.trim();
547        if line.is_empty() {
548            continue;
549        }
550        if let Ok(info) = serde_json::from_str::<RunInfo>(line) {
551            // Verify the VM is actually running
552            if let Some(ref name) = info.name {
553                let abs_vms = run_in_vm_stdout(&format!("echo {}", VMS_DIR))?;
554                let pid_file = format!("{}/{}/fc.pid", abs_vms, name);
555                if firecracker::is_vm_running(&pid_file).unwrap_or(false) {
556                    vms.push(info);
557                }
558            }
559        }
560    }
561
562    Ok(vms)
563}
564
565/// Allocate the next free slot index by scanning existing VMs.
566pub fn allocate_slot(name: &str) -> Result<VmSlot> {
567    let output = run_in_vm_stdout(&format!(
568        r#"for f in {dir}/*/run-info.json; do [ -f "$f" ] && cat "$f"; done 2>/dev/null || true"#,
569        dir = VMS_DIR,
570    ))?;
571
572    let mut used_indices: Vec<u8> = Vec::new();
573    for line in output.lines() {
574        let line = line.trim();
575        if line.is_empty() {
576            continue;
577        }
578        if let Ok(info) = serde_json::from_str::<serde_json::Value>(line)
579            && let Some(idx) = info.get("slot_index").and_then(|v| v.as_u64())
580        {
581            used_indices.push(idx as u8);
582        }
583    }
584
585    // Find first free index (0..253, since IP = index + 2, max 255)
586    for i in 0..253u8 {
587        if !used_indices.contains(&i) {
588            return Ok(VmSlot::new(name, i));
589        }
590    }
591
592    anyhow::bail!("No free VM slots available (max 253 VMs)")
593}
594
595/// Create a config drive (mvm-config label) with config.json and role-specific toml.
596fn create_dev_config_drive(abs_dir: &str, config: &FlakeRunConfig) -> Result<String> {
597    let path = format!("{}/config.ext4", abs_dir);
598    let slot = &config.slot;
599
600    let config_json = serde_json::json!({
601        "instance_id": config.name,
602        "guest_ip": slot.guest_ip,
603        "role": config.profile.as_deref().unwrap_or("worker"),
604    });
605    let escaped_json = config_json.to_string().replace('\'', "'\\''");
606
607    // Determine role-specific config filename and stub content
608    let role = config.profile.as_deref().unwrap_or("worker");
609    let toml_name = format!("{}.toml", role);
610    let toml_content = format!("# Dev-mode {} config stub\n", role);
611    let escaped_toml = toml_content.replace('\'', "'\\''");
612
613    run_in_vm(&format!(
614        r#"
615        rm -f {path}
616        truncate -s 4M {path}
617        mkfs.ext4 -q -L mvm-config {path}
618
619        MOUNT_DIR=$(mktemp -d)
620        sudo mount {path} "$MOUNT_DIR"
621        echo '{json}' | sudo tee "$MOUNT_DIR/config.json" >/dev/null
622        echo '{toml}' | sudo tee "$MOUNT_DIR/{toml_name}" >/dev/null
623        sudo chmod 0444 "$MOUNT_DIR/config.json" "$MOUNT_DIR/{toml_name}"
624        sudo umount "$MOUNT_DIR"
625        rmdir "$MOUNT_DIR"
626        chmod 0644 {path}
627        "#,
628        path = path,
629        json = escaped_json,
630        toml = escaped_toml,
631        toml_name = toml_name,
632    ))?;
633    Ok(path)
634}
635
636/// Create a secrets drive (mvm-secrets label) with a stub secrets.json.
637fn create_dev_secrets_drive(abs_dir: &str) -> Result<String> {
638    let path = format!("{}/secrets.ext4", abs_dir);
639    run_in_vm(&format!(
640        r#"
641        rm -f {path}
642        truncate -s 4M {path}
643        mkfs.ext4 -q -L mvm-secrets {path}
644
645        MOUNT_DIR=$(mktemp -d)
646        sudo mount {path} "$MOUNT_DIR"
647        echo '{{}}' | sudo tee "$MOUNT_DIR/secrets.json" >/dev/null
648        sudo chmod 0400 "$MOUNT_DIR/secrets.json"
649        sudo umount "$MOUNT_DIR"
650        rmdir "$MOUNT_DIR"
651        chmod 0600 {path}
652        "#,
653        path = path,
654    ))?;
655    Ok(path)
656}
657
658/// Configure a flake-built microVM via the Firecracker API (multi-VM).
659fn configure_flake_microvm(config: &FlakeRunConfig, abs_dir: &str, socket: &str) -> Result<()> {
660    let slot = &config.slot;
661
662    ui::info("Configuring logger...");
663    api_put_socket(
664        socket,
665        "/logger",
666        &format!(
667            r#"{{"log_path": "{dir}/firecracker.log", "level": "Debug", "show_level": true, "show_log_origin": true}}"#,
668            dir = abs_dir,
669        ),
670    )?;
671
672    // Boot args: pass guest IP and gateway via kernel cmdline so the
673    // NixOS guest (systemd-networkd + mvm-network-config service) can
674    // configure eth0 without DHCP.
675    let boot_args = format!(
676        "console=ttyS0 reboot=k panic=1 net.ifnames=0 mvm.ip={ip}/24 mvm.gw={gw}",
677        ip = slot.guest_ip,
678        gw = BRIDGE_IP,
679    );
680
681    ui::info(&format!("Setting boot source: {}", config.vmlinux_path));
682    let boot_source = match &config.initrd_path {
683        Some(initrd) => {
684            ui::info(&format!("Using initrd: {}", initrd));
685            format!(
686                r#"{{"kernel_image_path": "{kernel}", "boot_args": "{args}", "initrd_path": "{initrd}"}}"#,
687                kernel = config.vmlinux_path,
688                args = boot_args,
689                initrd = initrd,
690            )
691        }
692        None => {
693            format!(
694                r#"{{"kernel_image_path": "{kernel}", "boot_args": "{args}"}}"#,
695                kernel = config.vmlinux_path,
696                args = boot_args,
697            )
698        }
699    };
700    api_put_socket(socket, "/boot-source", &boot_source)?;
701
702    ui::info(&format!(
703        "Setting machine config: {} vCPUs, {} MiB",
704        config.cpus, config.memory
705    ));
706    api_put_socket(
707        socket,
708        "/machine-config",
709        &format!(
710            r#"{{"vcpu_count": {cpus}, "mem_size_mib": {mem}}}"#,
711            cpus = config.cpus,
712            mem = config.memory,
713        ),
714    )?;
715
716    ui::info(&format!("Setting rootfs: {}", config.rootfs_path));
717    api_put_socket(
718        socket,
719        "/drives/rootfs",
720        &format!(
721            r#"{{"drive_id": "rootfs", "path_on_host": "{rootfs}", "is_root_device": true, "is_read_only": false}}"#,
722            rootfs = config.rootfs_path,
723        ),
724    )?;
725
726    // Create and attach mvm-config drive (config.json + role.toml)
727    ui::info("Creating config drive...");
728    let config_drive = create_dev_config_drive(abs_dir, config)?;
729    api_put_socket(
730        socket,
731        "/drives/config",
732        &format!(
733            r#"{{"drive_id": "config", "path_on_host": "{path}", "is_root_device": false, "is_read_only": true}}"#,
734            path = config_drive,
735        ),
736    )?;
737
738    // Create and attach mvm-secrets drive (stub secrets.json)
739    ui::info("Creating secrets drive...");
740    let secrets_drive = create_dev_secrets_drive(abs_dir)?;
741    api_put_socket(
742        socket,
743        "/drives/secrets",
744        &format!(
745            r#"{{"drive_id": "secrets", "path_on_host": "{path}", "is_root_device": false, "is_read_only": true}}"#,
746            path = secrets_drive,
747        ),
748    )?;
749
750    for (idx, vol) in config.volumes.iter().enumerate() {
751        let drive_id = format!("vol{}", idx);
752        ui::info(&format!(
753            "Attaching volume {} -> {} (size {})",
754            vol.host, vol.guest, vol.size
755        ));
756        api_put_socket(
757            socket,
758            &format!("/drives/{}", drive_id),
759            &format!(
760                r#"{{"drive_id": "{id}", "path_on_host": "{host}", "is_root_device": false, "is_read_only": false}}"#,
761                id = drive_id,
762                host = vol.host,
763            ),
764        )?;
765    }
766
767    ui::info(&format!(
768        "Setting network interface: {} (MAC {})",
769        slot.tap_dev, slot.mac
770    ));
771    api_put_socket(
772        socket,
773        "/network-interfaces/net1",
774        &format!(
775            r#"{{"iface_id": "net1", "guest_mac": "{mac}", "host_dev_name": "{tap}"}}"#,
776            mac = slot.mac,
777            tap = slot.tap_dev,
778        ),
779    )?;
780
781    ui::info("Setting vsock device...");
782    api_put_socket(
783        socket,
784        "/vsock",
785        &format!(
786            r#"{{"vsock_id": "vsock0", "guest_cid": {cid}, "uds_path": "{dir}/v.sock"}}"#,
787            cid = mvm_guest::vsock::GUEST_CID,
788            dir = abs_dir,
789        ),
790    )?;
791
792    Ok(())
793}
794
795/// Persist run info for a named VM.
796fn write_vm_run_info(config: &FlakeRunConfig, abs_dir: &str) -> Result<()> {
797    let info = RunInfo {
798        mode: "flake".to_string(),
799        name: Some(config.name.clone()),
800        revision: Some(config.revision_hash.clone()),
801        flake_ref: Some(config.flake_ref.clone()),
802        guest_ip: Some(config.slot.guest_ip.clone()),
803        profile: config.profile.clone(),
804        guest_user: String::new(),
805        cpus: config.cpus,
806        memory: config.memory,
807    };
808
809    // Also store slot_index for allocation tracking
810    let mut json_value = serde_json::to_value(&info)?;
811    if let Some(obj) = json_value.as_object_mut() {
812        obj.insert(
813            "slot_index".to_string(),
814            serde_json::Value::Number(config.slot.index.into()),
815        );
816    }
817
818    let json = serde_json::to_string(&json_value)?;
819    run_in_vm(&format!(
820        "echo '{}' > {dir}/run-info.json",
821        json,
822        dir = abs_dir,
823    ))?;
824    Ok(())
825}
826
827/// Read run info from a specific VM directory.
828fn read_vm_run_info_from(abs_dir: &str) -> Option<RunInfo> {
829    let json = run_in_vm_stdout(&format!(
830        "cat {dir}/run-info.json 2>/dev/null || echo 'null'",
831        dir = abs_dir,
832    ))
833    .ok()?;
834    serde_json::from_str(&json).ok()
835}
836
837/// Read the slot_index from a VM's run-info.json.
838fn read_slot_index(abs_dir: &str) -> Option<u8> {
839    let json = run_in_vm_stdout(&format!(
840        "cat {dir}/run-info.json 2>/dev/null || echo 'null'",
841        dir = abs_dir,
842    ))
843    .ok()?;
844    let value: serde_json::Value = serde_json::from_str(&json).ok()?;
845    value.get("slot_index")?.as_u64().map(|v| v as u8)
846}
847
848/// Read persisted run info (returns None if file doesn't exist).
849pub fn read_run_info() -> Option<RunInfo> {
850    let json = run_in_vm_stdout(&format!(
851        "cat {dir}/.mvm-run-info 2>/dev/null || echo 'null'",
852        dir = MICROVM_DIR,
853    ))
854    .ok()?;
855    serde_json::from_str(&json).ok()
856}